ESC
Type to search...
S
Soli Docs

HTTP Class

HTTP client class for GET, POST, PUT, PATCH, DELETE requests with JSON support and parallel requests.

HTTP Class

All HTTP functions are accessed via the HTTP class:

response = HTTP.get("https://api.example.com/users")
response = HTTP.post("https://api.example.com/users", { "name": "Alice" })
response = HTTP.request("DELETE", "https://api.example.com/users/1")

SSRF blocklist & redirects

Every URL passed to HTTP.* is validated up-front: schemes other than http/https are rejected, as are hosts that resolve to loopback / private / link-local IP ranges. A request to http://169.254.169.254/... (cloud metadata) or http://10.0.0.1/ fails immediately. The blocklist also covers 0.0.0.0/8, 192.0.0.0/24, 198.18.0.0/15 and 240.0.0.0/4, and IPv6 addresses that embed a blocked IPv4 — NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4 (2002::/16), Teredo and IPv4-compatible forms.

Auto-redirects are not followed by the synchronous HTTP.get / HTTP.post / HTTP.request paths — a 3xx response is returned as-is so a redirect-controlled Location cannot bypass the blocklist. Asynchronous and Model-driven HTTP (the reqwest-backed paths) follow redirects with a custom policy that re-runs the SSRF check on every hop.

Apps that need to follow a 3xx from HTTP.get should inspect response["status"] and response["headers"]["location"] and re-issue the request manually.

Connections & error messages

Connections are pooled and reused across calls, per host, for 15 seconds of idle time — a second request to an API you just called skips the TCP and TLS handshake. Pooling behaves the same for HTTP/1.1 and HTTP/2 hosts.

A transport failure reports its whole cause chain, not just the top line: Request failed: error sending request for url (…) is followed by what actually went wrong (dns error: …, connection closed before message completed, invalid peer certificate). Worth surfacing wherever you handle the {"error": ...} hashes the parallel helpers return.

Basic HTTP Requests

HTTP.get(url, options?)

Perform an HTTP GET request. Returns a Future that resolves to the response body as a string.

resp = HTTP.get("https://api.example.com/users")
if resp["status"] == 200
  println(resp["body"])
end

# Request headers ride along in the options hash.
me = HTTP.get("https://api.example.com/me", {
  "headers": { "Authorization": "Bearer " + token, "X-Client": "soli" }
})

# Per-call timeout in seconds (overrides the 30s default for this request).
resp = HTTP.get("https://api.example.com/slow", { "timeout": 5 })
One options hash for every verb. get, post, put, patch, delete, head, the *_json / get_jsonp variants and (applied to every URL) get_all / get_all_json all take the same trailing options hash with two keys:
  • headers — a hash of name => value. Strings go out as-is, numbers and booleans are stringified, a null value skips that header. A header you set replaces the builtin's own default (Content-Type on post/put/patch, Accept on the JSON variants), matched case-insensitively, so you never send two. A name or value the client would refuse (spaces in the name, a CR/LF in the value) raises before anything is sent — the message names the header but never echoes the value, so a rejected token stays out of the error page and logs. Content-Length and Transfer-Encoding are refused outright: they are derived from the body, and a caller-supplied length would be sent verbatim and desync the upstream connection. Redirects: the client drops Authorization, Cookie and Proxy-Authorization when a redirect changes host or port, but any other header you set (an X-Api-Key, say) follows the redirect to the new host.
  • timeout — per-call timeout in seconds (Int or Float). Overrides the default 30s client timeout for that one request; must be positive, fractions such as 0.5 are allowed.
For HTTP.request, the third argument is a flat headers hash: its timeout key is consumed rather than sent as a header, and a nested headers hash is merged in, so the same options shape works there too. Flat and nested pairs go through the same validation.
HTTP.get_jsonp(url, options?)

Fetch a JSONP endpoint and unwrap the callback(...) padding, returning the parsed value. Use it to consume legacy cross-origin APIs that only expose JSONP; put the ?callback=... name in the URL you pass.

feed = HTTP.get_jsonp("https://api.example.com/feed?callback=cb")
println(feed["items"][0])
HTTP.post(url, body, options?)

Perform an HTTP POST request with a body. A hash body is sent as JSON, a string body as text/plain; a Content-Type in options.headers replaces that default.

resp = HTTP.post(
  "https://api.example.com/users",
  "name=Alice",
  { "headers": { "Content-Type": "application/x-www-form-urlencoded" } }
)
HTTP.put(url, body, options?)

Perform an HTTP PUT request with a body.

resp = HTTP.put(
  "https://api.example.com/users/1",
  { "name": "Alice Updated" }
)
HTTP.delete(url, options?)

Perform an HTTP DELETE request.

resp = HTTP.delete("https://api.example.com/users/1")
HTTP.patch(url, body, options?)

Perform an HTTP PATCH request with a body.

resp = HTTP.patch(
  "https://api.example.com/users/1",
  { "email": "new_email@example.com" }
)
HTTP.head(url, options?)

Perform an HTTP HEAD request.

resp = HTTP.head("https://api.example.com/users")
println(resp["headers"])
HTTP.download(url, path, options?)

Fetch a URL and write the body to a file, returning the number of bytes written. The one method here that does not decode: every other call ends in UTF-8, which a picture, a sound or a PDF is not. Same SSRF check, same SOLI_HTTP_MAX_RESPONSE_BYTES cap, and the path goes through the same jail as the File builtins — a missing parent directory is created, an escaping path is refused, and a symlink standing where the file should go is not followed. Blocking: it returns when the file is on disk.

bytes = HTTP.download("https://example.com/cover.jpg", "public/covers/1.jpg")
println("wrote " + str(bytes))

JSON HTTP Methods

HTTP.get_json(url, options?)

GET request with automatic JSON parsing of response body. Sends Accept: application/json unless options.headers overrides it.

data = HTTP.get_json("https://api.example.com/users/1")
println(data["body"]["name"])

# Authenticated, with a 10-second budget.
me = HTTP.get_json("https://api.example.com/me", {
  "headers": { "Authorization": "Bearer " + token },
  "timeout": 10
})
HTTP.post_json(url, data, options?)

POST request with automatic JSON serialization.

resp = HTTP.post_json(
  "https://api.example.com/users",
  { "name": "Alice", "email": "alice@example.com" }
)
HTTP.put_json(url, data, options?)

PUT request with automatic JSON serialization.

resp = HTTP.put_json(
  "https://api.example.com/users/1",
  { "name": "Alice Updated", "email": "alice_updated@example.com" }
)
HTTP.patch_json(url, data, options?)

PATCH request with automatic JSON serialization.

resp = HTTP.patch_json(
  "https://api.example.com/users/1",
  { "email": "new_email@example.com" }
)

Generic HTTP Request

HTTP.request(method, url, headers?, body?)

Perform any HTTP method (GET, POST, PUT, PATCH, DELETE, etc.). The headers hash accepts a timeout key (seconds) that is consumed as the per-call timeout rather than sent as a header.

resp = HTTP.request("DELETE", "https://api.example.com/users/1")
resp = HTTP.request("PATCH", url, headers, json)

# Custom header plus a 3-second per-call timeout.
resp = HTTP.request("GET", url, { "Authorization": "Bearer " + token, "timeout": 3 })

Status Code Helpers

http_ok(resp)

Check if status is exactly 200

http_success(resp)

Check if status is 2xx

http_redirect(resp)

Check if status is 3xx

http_client_error(resp)

Check if status is 4xx

http_server_error(resp)

Check if status is 5xx

JSON Helpers

HTTP.json_parse(string)

Parse a JSON string into a Soli value

HTTP.json_stringify(value)

Convert a Soli value to a JSON string

Parallel Requests

HTTP.get_all(urls, options?)

Execute multiple GET requests in parallel. Returns response bodies as strings; failed requests appear as {"error": ...} hashes. An optional trailing options hash (headers, timeout — see HTTP.get) applies to every request in the batch.

responses = HTTP.get_all([
  "https://api.example.com/users",
  "https://api.example.com/posts"
])

# Cap every request in the batch at 5 seconds.
responses = HTTP.get_all([
  "https://api.example.com/users",
  "https://api.example.com/posts"
], { "timeout": 5 })
HTTP.get_all_json(urls, options?)

Execute multiple GET requests in parallel and parse each body as JSON. Failed requests, non-2xx responses, or unparseable bodies appear as {"error": ...} hashes. Accepts the same optional trailing options hash (headers, timeout) as HTTP.get_all.

responses = HTTP.get_all_json([
  "https://api.example.com/users.json",
  "https://api.example.com/posts.json"
])

users = responses[0]
if users.has_key("error") {
  print("users failed: " + users["error"])
}
HTTP.parallel(requests)

Execute multiple requests of different methods in parallel. Each request hash accepts method, url, optional headers, optional body, and an optional per-request timeout (seconds).

responses = HTTP.parallel([
  { "method": "GET", "url": "https://api.example.com/users", "timeout": 5 },
  { "method": "POST", "url": "https://api.example.com/logs", "body": "{}" }
])