8.2.165-stable Switch to dev

http

HTTP/HTTPS client. Runs on libcurl, and verifies TLS against the same trust chain the runtime resolved at startup, so --ca-path and --unsecure apply here exactly as they do to the rest of GreyCat.

@library("http", "0.0.0");

One-shot requests

Http<T> is generic over what the response body deserializes into. Without a generic parameter, or with String, the body is handed back as raw text; with anything else an application/json body is parsed into T.

var body = Http<String> {}.get("https://example.com/", null);

var repo = Http<Repo> {}.post(
    "https://api.example.com/repos",
    Repo { name: "greycat" },
    Map<String, String> { "authorization": "Bearer ${token}" },
);

Http<any> {}.getFile("https://example.com/data.csv", "data/local.csv", null);

send gives access to the status line and the response headers, and takes the options a bare verb cannot express:

var res = Http<String> {}.send(HttpRequest {
    method: HttpMethod::POST,
    url: "https://api.example.com/things",
    headers: Map<String, String> { "content-type": "application/json" },
    body: "{\"name\":\"thing\"}",
    timeout: 30s,
    max_response_size: 1_000_000,
});
println("${res.status_code} ${res.headers.get("content-type")}");

max_response_size bounds the body in bytes and aborts the transfer past it, which is what keeps a chunked or Content-Length-less response from growing without bound. null or 0 means no limit.

The status decides where the body lands

T names the shape of a successful body. A failing request answers with something else, so a non-2xx body is never decoded into T: it comes back as text in error_msg, and content stays null.

var res = Http<Order> {}.send(request);
if (res.status_code >= 200 && res.status_code < 300) {
    println(res.content?.total);
} else {
    println("HTTP ${res.status_code} ${res.error_msg}");
}

Success is strictly 2xx. The two fields are never both set, and a failure carrying no body at all leaves error_msg null too.

A JSON error envelope arrives as its JSON text, so reading a field out of it is the caller’s step and only happens when the detail is wanted:

var detail = Json {}.parse(res.error_msg ?? "{}");

The untyped forms are the exception: Http {}, Http<any> and Http<String> name no schema for a failed body to contradict, so they carry it in content as well, decoded exactly as a 2xx would be.

Streaming responses

chunked returns a reader that yields the body as it arrives instead of buffering it whole, for text/event-stream, newline-delimited JSON and other long-lived streams.

var reader = Http<String> {}.chunked(HttpRequest {
    method: HttpMethod::GET,
    url: "https://api.example.com/v1/stream",
    timeout: 60s,
});
while (reader.can_read()) {
    println(reader.read());
}
reader.close();

Framing follows the response Content-Type. An event stream is read as Server- Sent Events, so one read returns the data of one event with the prefix removed and comment lines skipped; anything else is read line by line. Either way the transfer encoding of the wire is not observable. An event stream ends on data: [DONE], which is reported as the end of the stream rather than handed out.

On a reader, max_response_size caps the bytes held between two reads rather than the whole body, so an endless stream is read at any length as long as the caller keeps up.

HttpReader holds a live connection, so it can neither be persisted nor resumed: read it to completion, or close it, from the task that opened it.

Unix sockets

unix_socket connects through a Unix domain socket instead of resolving the URL’s authority, which is how a local daemon is reached: Docker, podman, systemd, or an application behind nginx.

var res = Http<any> {}.send(HttpRequest {
    method: HttpMethod::GET,
    url: "http://localhost/v1.43/containers/json?all=true",
    unix_socket: "/var/run/docker.sock",
});

The socket decides only where the connection goes. The URL still supplies the request line, its scheme still decides whether TLS is negotiated over the socket, and its host still becomes the Host header, which is why localhost is the convention for an authority nothing routes on. A URL that is a bare path gets http://localhost put in front of it, so one nothing reads can be left out:

url: "/v1.43/containers/json",

chunked takes the field too, so a daemon’s event stream is read exactly like any other stream.

The socket belongs to the connection rather than to the URL, so a redirect is followed over the same socket whatever host it names.

Bodies on disk

A body too large to hold in memory travels as a file. body_file streams the request body straight off the disk, and response_file writes the response body to one as it arrives, so neither passes through the heap. A 64 MB upload whose echo comes back to a file costs the same resident memory as an empty request.

var res = Http<any> {}.send(HttpRequest {
    method: HttpMethod::PUT,
    url: "https://api.example.com/objects/report.parquet",
    headers: Map<String, String> { "content-type": "application/vnd.apache.parquet" },
    body_file: http::FileBody { path: "/data/report.parquet" },
    timeout: 10min,
});
println("${res.status_code} ${res.headers.get("etag")}");

offset and size send a byte range rather than the whole file, which is what a multipart part is. Both default to the whole file, and a range reaching past the end is refused before the request opens rather than stalling the peer mid-transfer.

body_file: http::FileBody { path: "/data/report.parquet", offset: 8_388_608, size: 8_388_608 },

response_file leaves content null, since a body is either returned or written and never both. The status and headers arrive as they do for any other response, which is what tells a downloaded file apart from a downloaded error page:

var res = Http<any> {}.send(HttpRequest {
    method: HttpMethod::GET,
    url: "https://api.example.com/objects/report.parquet",
    response_file: http::FileSink { path: "/data/downloaded.parquet" },
    timeout: 10min,
});
if (res.status_code != 200) {
    File::delete("/data/downloaded.parquet");
    throw "download failed: HTTP ${res.status_code}";
}

Parent directories are created. append adds to the file instead of truncating it. Setting body and body_file on the same request throws rather than picking one.

FileBody and FileSink are always qualified, as http::FileBody and http::FileSink.

For the two common cases there are shortcuts, both returning the response:

var up = Http<any> {}.put_file(url, "/data/report.parquet", headers);
var down = Http<any> {}.get_file(url, "/data/downloaded.parquet", null);

getFile predates all of this and stays as it is. It reports no status, so a 404 writes the error body to the target file and looks like a success; prefer get_file, which returns one.