Modules

Http

moduleextensionexperimentalSource

Available on macOS, Linux, Windows, Wasm

#import "Extensions/Http";

HTTP/1.0 and HTTP/1.1 message codecs, plus a server, a client and test tools on top of them for fibers.

The codec has no IO of its own. A Parser reads requests or responses from byte buffers the caller fills, in pieces of any size, and reports the head, the body in pieces and the end of each message with strict RFC 9112 framing; the writers build heads and chunked bodies. Nothing in it touches a socket, so it runs on every target, wasm included. The server (App, serve) and the client (Client, get, send) need fibers and Extensions/Io, and are not built for wasm. Failures are values: the parser's .ERROR event and Parser.error, an Http_Error returned by a handler, a Client_Error returned by a client call. Memory the parser or a response owns is released with parser_free and close; everything else is borrowed or in the temporary allocator, as each procedure says.

Example↗
parser: Parser;
parser_init(*parser, .REQUEST);
defer parser_free(*parser);

request := "GET /users?id=7 HTTP/1.1\r\nHost: example.com\r\n\r\n";
bytes: [] u8 = .{ request.count, request.data };

used, event := parse(*parser, bytes); // any chunking gives the same result
assert(event.kind == .HEAD);
assert(parser.head.method == .GET);
assert(parser.head.target == "/users?id=7");

Procedures#

Types#

method_nameexperimentalSource
(method: Method) -> string

The spelling of method on the wire, such as "GET". .OTHER has no spelling of its own and gives ""; use Head.method_text for the token a request actually carried. The string is a constant. The client refuses to send .OTHER (.INVALID_METHOD).

assert(method_name(.GET) == "GET");
assert(method_name(.OTHER) == "");
↗
parse_methodexperimentalSource
(text: string) -> Method

The Method for a token (case-sensitive: method names are). Unknown tokens are .OTHER.

version_nameexperimentalSource
(version: Version) -> string

The text of version as it appears in a start line, "HTTP/1.0" or "HTTP/1.1". The string is a constant.

assert(version_name(.HTTP_1_0) == "HTTP/1.0");
assert(version_name(.HTTP_1_1) == "HTTP/1.1");
↗
(id: Header_Name) -> string

The canonical spelling of a well-known header, as the writers send it. .UNKNOWN has no spelling and gives "". The string is a constant.

assert(header_name_string(.CONTENT_TYPE) == "Content-Type");
assert(header_name_string(.WWW_AUTHENTICATE) == "WWW-Authenticate");
assert(HEADER_NAME_COUNT == 71);
↗
find_headerexperimental2 overloadsSource
(headers: [] Header_Field, name: string) -> (value: string, found: bool)
(headers: [] Header_Field, id: Header_Name) -> (value: string, found: bool)

The first header called name (any case); found says whether there was one.

has_tokenexperimentalSource
(value: string, token: string) -> bool

True when a comma-separated header value (Connection, Upgrade, Accept-Encoding...) lists token, compared without regard to case. Parameters after ; are ignored.

Parser#

parser_initexperimentalSource
(p: *Parser, kind: Parser_Kind, limits := Limits.{})

Prepares p to read requests or responses. Any state the parser had is released first, so it is safe to call on a parser that was used. limits caps line, head and body sizes (the defaults are in Limits); a message over a limit is refused with the error kind that maps to the matching status. For a response parser, set p.response_to to the request's method before the response head arrives.

limits := Limits.{ max_body_size = 4 };

parser: Parser;
parser_init(*parser, .REQUEST, limits);
defer parser_free(*parser);

text := "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 10\r\n\r\n0123456789";
data: [] u8 = .{ text.count, text.data };

failed := false;
while true {
    used, event := parse(*parser, data);
    data = advance(data, used);

    if event.kind == .ERROR failed = true;
    if event.kind == .ERROR || event.kind == .END || event.kind == .NEED_MORE break;
}

assert(failed);
assert(parser.error.kind == .BODY_TOO_LARGE);
↗
parser_freeexperimentalSource
(p: *Parser)

Releases the parser's buffers. The parser must not be used afterwards, except to start again with parser_init. Strings taken from its head, trailers or events are invalid after this.

parser_trimexperimentalSource
(p: *Parser, limit: int)

Between messages: gives back the head and trailer storage that grew past limit bytes (each of the six arrays on its own), so a connection that once saw a huge head does not keep it. The previous message's head and trailers are gone afterwards, like when the next message begins. Does nothing while a message is under way.

parser_idleexperimentalSource
(p: *Parser) -> bool

True when no message has begun: the connection may close without it being an error.

advanceexperimentalSource
(data: [] u8, count: int) -> [] u8

Drops the first count bytes of data.

parseexperimentalSource
(p: *Parser, data: [] u8) -> (consumed: int, event: Event)

Reads from data and returns the number of bytes used and one event. Call again with the rest until NEED_MORE (the input is then used up); END and HEAD can come with no bytes used, so loop on the event, not on the byte count. BODY data is a slice of data.

parse_eofexperimentalSource
(p: *Parser) -> Event

The connection has closed. Ends a body that runs until close; reports CLOSED when it fell between messages and an UNEXPECTED_EOF error when it fell inside one.

Writer#

(status: int) -> string

The standard reason phrase of a status, or "" for one this table does not know.

(out: *[..] u8, method: string, target: string, headers: [] Header_Field, body_length: s64) -> (Body_Kind, bool)

Appends a request head, framing included, and returns how the body must be sent: with a non-negative body_length, Content-Length is added (except for a GET, HEAD, DELETE, CONNECT or TRACE without a body) and the body is sent as is (.LENGTH, or .NONE for length 0); with -1 the length is not known, Transfer-Encoding: chunked is added and the body goes through write_chunk and write_last_chunk (.CHUNKED). ok is false, and out is left as it was, when a header is not valid or sets the framing itself.

(out: *[..] u8, status: int, headers: [] Header_Field, body_length: s64, options := Response_Options.{}) -> (Body_Kind, bool)

Appends a response head and returns how the body must be sent (see write_request_head). body_length is the length of the body the response is for; for a HEAD response (set request_method) it only fills in Content-Length, and nothing may be sent after the head. 1xx, 204 and CONNECT 2xx responses get no framing headers. A length of -1 means unknown: chunked on HTTP/1.1, and for HTTP/1.0 the body ends when the connection closes (Connection: close is added and the caller must close).

(out: *[..] u8)

HTTP/1.1 100 Continue, the answer to a request with Expect: 100-continue before its body is read.

write_chunkexperimental2 overloadsSource
(out: *[..] u8, data: [] u8)
(out: *[..] u8, data: string)

Appends data as one chunk. An empty data writes nothing: a chunk of size 0 would end the body (use write_last_chunk). Appends data to out as one chunk of a chunked body: the size in hex, CRLF, the bytes, CRLF. Nothing is written for empty data. out is a growing array the caller owns (it uses the context allocator); end the body with write_last_chunk.

out: [..] u8;
defer array_free(out);

write_chunk(*out, "hello");
write_chunk(*out, ""); // empty: nothing is written
assert(write_last_chunk(*out));

assert(string.{ out.count, out.data } == "5\r\nhello\r\n0\r\n\r\n");
↗
write_last_chunkexperimental2 overloadsSource
(out: *[..] u8) -> bool
(out: *[..] u8, trailers: [] Header_Field) -> bool

The zero-size chunk with optional trailers, which ends a chunked body. False, with nothing written, when a trailer is not valid or is a field a trailer may not be (framing, Host). Appends the zero-size chunk that ends a chunked body, with no trailers. Returns true.

Date#

(seconds: s64, out: *[HTTP_DATE_SIZE] u8)

Writes seconds since 1970-01-01 UTC as an IMF-fixdate (always 29 bytes).

text: [29] u8;
format_http_date(784111777, *text);
assert(string.{ text.count, text.data } == "Sun, 06 Nov 1994 08:49:37 GMT");
↗
http_dateexperimentalSource
(cache: *Date_Cache, seconds: s64) -> string

The date for seconds; the string points into cache and stays valid until the next call.

cache: Date_Cache;
assert(http_date(*cache, 0) == "Thu, 01 Jan 1970 00:00:00 GMT");
↗
(text: string) -> (s64, bool)

Reads an IMF-fixdate into seconds since 1970-01-01 UTC. The obsolete RFC 850 and asctime forms are not accepted.

seconds, ok := parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT");
assert(ok && seconds == 784111777);
↗

Date native#

URL#

(text: string, plus_is_space := false) -> string

Decodes %XX escapes (and + as a space when plus_is_space, which forms and queries use but paths do not). The text itself is returned, with no allocation, when there is nothing to decode; otherwise the result comes from context.allocator. A % not followed by two hex digits stays as it is.

assert(percent_decode("a%20b") == "a b");
assert(percent_decode("a+b", plus_is_space = true) == "a b");
assert(percent_decode("100%") == "100%");

Doesn't run in the browser (skipped on wasm).

parse_queryexperimentalSource
(query: string) -> [] Pair

Splits a=1&b=two&flag into decoded pairs (flag has an empty value). The pairs and the strings that needed decoding come from context.allocator; the server calls this with the temporary allocator.

pairs := parse_query("name=Ada%20L&tag=a&tag=b&flag");
assert(pairs.count == 4);
assert(pairs[0].key == "name" && pairs[0].value == "Ada L");
assert(pairs[3].key == "flag" && pairs[3].value == "");

Doesn't run in the browser (skipped on wasm).

(text: string) -> string

Percent-encodes everything but unreserved characters (RFC 3986), for building queries and URLs.

text := percent_encode("a b&c");
assert(text == "a%20b%26c");
free(text);

Doesn't run in the browser (skipped on wasm).

parse_urlexperimentalSource
(text: string) -> (Url, Url_Problem)

Reads an absolute http:// or https:// URL (the port defaults to 80 and 443).

url, problem := parse_url("https://example.com:8443/a/b?x=1");
assert(problem == .NONE);
assert(url.secure && url.host == "example.com" && url.port == 8443);
assert(url.target == "/a/b?x=1");

_, problem = parse_url("ftp://example.com/");
assert(problem == .UNSUPPORTED_SCHEME);

_, problem = parse_url("/relative/path");
assert(problem == .NOT_A_URL);

Doesn't run in the browser (skipped on wasm).

Message#

query_paramexperimentalSource
(request: *Request, name: string) -> (string, bool)

The first query parameter called name, percent-decoded, and whether it was given.

app_stateexperimentalsince 0.30.0Source
(request: *Request, $T: Type) -> *T

The state of the app that serves request (app.state), or null when it was never set. For middleware; handlers take a *State(T) instead.

with_headerexperimental2 overloadsSource
(response: Response, name: string, value: string) -> Response
(error: Http_Error, name: string, value: string) -> Http_Error

response with one more header: with_header(json(user), "ETag", tag). The name and value must stay valid until the response is written.

response := with_header(text("hi"), "Cache-Control", "no-store");
value, found := find_header(response.headers, "cache-control");
assert(found && value == "no-store");

Doesn't run in the browser (skipped on wasm).

status_onlyexperimentalSource
(status: int) -> Response

An empty response with only a status (status_only(204)).

response := status_only(204);
assert(response.status == 204 && response.body.count == 0);

Doesn't run in the browser (skipped on wasm).

jsonexperimentalSource
(value: $T, status := 200) -> Response

value as JSON with Content-Type: application/json. The body is built in the temporary allocator.

response := json(s32.[1, 2, 3]);
assert(response.status == 200);
assert(string.{ response.body.count, response.body.data } == "[1,2,3]");

Doesn't run in the browser (skipped on wasm).

createdexperimentalsince 0.30.0Source
(value: $T, location := "") -> Response

value as JSON with status 201 and, when location is given, a Location header: the answer to a request that made something.

response := created(42, tprint("/users/%", 42));

assert(response.status == 201);
location, found := find_header(response.headers, "Location");
assert(found && location == "/users/42");

Doesn't run in the browser (skipped on wasm).

textexperimentalSource
(body: string, status := 200) -> Response

Plain text with Content-Type: text/plain; charset=utf-8.

response := text("not here", 404);
assert(response.status == 404);
assert(string.{ response.body.count, response.body.data } == "not here");

Doesn't run in the browser (skipped on wasm).

htmlexperimentalSource
(body: string, status := 200) -> Response

HTML with Content-Type: text/html; charset=utf-8.

response := html("<h1>Hello</h1>");
type, _ := find_header(response.headers, "Content-Type");
assert(type == "text/html; charset=utf-8");

Doesn't run in the browser (skipped on wasm).

(body: $T, content_type: string, status := 200) -> Response

Raw bytes (a [] u8 or a string) with a content type of your choice. The bytes must stay valid until the response is written.

response := bytes_response(u8.[137, 80, 78, 71], "image/png");
assert(response.body.count == 4);
type, _ := find_header(response.headers, "Content-Type");
assert(type == "image/png");

Doesn't run in the browser (skipped on wasm).

redirectexperimentalSource
(location: string, status := 302) -> Response

A redirect to location: 302 unless you pass 301, 303, 307 or 308.

response := redirect("/login", 303);
location, _ := find_header(response.headers, "Location");
assert(response.status == 303 && location == "/login");

Doesn't run in the browser (skipped on wasm).

http_errorexperimentalSource
(status: int, detail := "") -> Http_Error

An error with a status and a detail text (used as it is). The named helpers below take a tprint format instead: not_found("No user %", id).

error := http_error(418, "short and stout");
assert(error.kind == .STATUS && error.status == 418);

Doesn't run in the browser (skipped on wasm).

bad_requestexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 400, for when the request is malformed or its content is invalid. format and args make the detail text as tprint does, in the temporary allocator.

error := bad_request("the field % is empty", "name");
assert(error.status == 400 && error.detail == "the field name is empty");

Doesn't run in the browser (skipped on wasm).

unauthorizedexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 401, for when the caller has not authenticated. Add WWW-Authenticate with with_header. format and args make the detail text as tprint does, in the temporary allocator.

error := with_header(unauthorized("a token is required"), "WWW-Authenticate", "Bearer");

assert(error.status == 401);
assert(error.headers.count == 1);

Doesn't run in the browser (skipped on wasm).

forbiddenexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 403, for when the caller is known but not allowed. format and args make the detail text as tprint does, in the temporary allocator.

error := forbidden("admins only");
assert(error.status == 403);

Doesn't run in the browser (skipped on wasm).

not_foundexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 404, for when there is nothing at the requested resource. format and args make the detail text as tprint does, in the temporary allocator.

error := not_found("No user %", 7);

assert(error.status == 404);
assert(error.title == "Not Found");
assert(error.detail == "No user 7");

Doesn't run in the browser (skipped on wasm).

conflictexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 409, for when the request clashes with the current state, such as a duplicate. format and args make the detail text as tprint does, in the temporary allocator.

error := conflict("% already exists", "ada");
assert(error.status == 409);

Doesn't run in the browser (skipped on wasm).

goneexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 410, for when the resource existed and has been removed for good. format and args make the detail text as tprint does, in the temporary allocator.

assert(gone("removed").status == 410);

Doesn't run in the browser (skipped on wasm).

(format := "", args: ..Any) -> Http_Error

An Http_Error with status 422, for when the request is well-formed but its content cannot be processed. format and args make the detail text as tprint does, in the temporary allocator.

assert(unprocessable("age must be positive").status == 422);

Doesn't run in the browser (skipped on wasm).

(format := "", args: ..Any) -> Http_Error

An Http_Error with status 429, for when the caller is over a rate limit; add Retry-After with with_header. format and args make the detail text as tprint does, in the temporary allocator.

error := with_header(too_many_requests("slow down"), "Retry-After", "30");
assert(error.status == 429 && error.headers.count == 1);

Doesn't run in the browser (skipped on wasm).

(format := "", args: ..Any) -> Http_Error

An Http_Error with status 500, for when something failed that is not the caller's fault. format and args make the detail text as tprint does, in the temporary allocator.

assert(internal_error("the disk is full").status == 500);

Doesn't run in the browser (skipped on wasm).

unavailableexperimentalSource
(format := "", args: ..Any) -> Http_Error

An Http_Error with status 503, for when the service cannot answer now; add Retry-After with with_header. format and args make the detail text as tprint does, in the temporary allocator.

assert(unavailable("down for maintenance").status == 503);

Doesn't run in the browser (skipped on wasm).

(request: *Request, error: Http_Error) -> Response

The default format, RFC 9457: application/problem+json with type, title, status, detail, instance (the request path) and, for bad input, location and field.

input_errorexperimentalSource
(status: int, location: string, field: string, detail: string) -> Http_Error

The error for bad input found by an extractor: names where the input came from and which part.

Body#

readexperimentalsince 0.30.0Source
(body: *Body, buffer: [] u8) -> (int, Http_Error)

Copies the next bytes of the body, at most buffer.count, into buffer and returns how many. Zero means the body is over (an empty buffer also reads zero). Waits for bytes when none have arrived, within config.body_timeout. A body over the route's limit (max_body, else config.max_body_size) is a 413, and a client that stops sending a 408; the error stays the same on later calls.

read_allexperimentalsince 0.30.0Source
(body: *Body) -> ([] u8, Http_Error)

The rest of the body. The bytes live in the connection's own buffer and stay valid until the response has been written; copy them to keep them longer. Read as read: over the limit is a 413, and a declared length over the limit is refused without reading anything.

read_jsonexperimental2 overloadsSource
(body: *Body, $T: Type) -> (T, Http_Error)
(response: *Client_Response, $T: Type) -> (T, Client_Error)

The rest of the body decoded as JSON into a T, with app.json_options. Content-Type: application/json (or +json) is required (415); a body that is not JSON is a 400 and one of the wrong shape a 422, both naming the JSON path.

Stream#

file_responseexperimentalsince 0.30.0Source
(path: string, content_type := "", status := 200) -> Response

A response with the contents of the file at path, read in pieces as the client takes them (Content-Length is the file's size when the response is sent). An empty content_type is chosen from the file's extension (application/octet-stream when unknown). A file that cannot be opened is a 404, or a 500 for any other failure; a file that shrinks while it is sent cuts the connection.

send_chunkexperimental2 overloadssince 0.30.0Source
(stream: *Stream, data: [] u8) -> bool
(stream: *Stream, text: string) -> bool

Sends data to the client now, as one chunk of the body. False when the client has gone or the write timed out: the producer should return.

App#

free_appexperimentalSource
(app: *App)

Releases what the app owns after servers and requests have stopped using it.

useexperimental2 overloadsSource
(app: *App, middleware: Middleware)
(group: *Group($P), middleware: Middleware)

Adds middleware that wraps every request (in the order added), including ones with no route.

hello :: () -> string {
    return "hello";
}

add_header :: (request: *Request, next: Next) -> Response {
    return with_header(next(request), "X-Served-By", "jacket");
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);
    use(*app, add_header);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    value, found := header(*response, "X-Served-By");
    assert(found && value == "jacket");
}

Doesn't run in the browser (skipped on wasm).

shutdownexperimentalSource
(app: *App)

Asks the servers started by serve to stop: they stop accepting and finish the requests in progress. Safe to call from any thread; serve returns once they are done.

// From a signal handler's polling loop, a timer thread or an admin route:
app: App;
shutdown(*app);

This one is only checked to compile, not run.

groupexperimental2 overloadsSource
(app: *App, $prefix: string, middleware: ..Middleware) -> *Group(prefix)
(parent: *Group($P), $prefix: string, middleware: ..Middleware) -> *Group(#run join_template(P, prefix))

Routes under prefix that share middleware, which runs after the app's own. The prefix is part of the group's type, so a handler's Path(...) is checked against the whole route. The group belongs to the app and is released with it: do not free it. Groups nest with group(parent, "/more"), and the middleware add up.

guard :: (request: *Request, next: Next) -> Response {
    _, found := header(request, "Authorization");

    if !found return error_response(request, unauthorized("a token is required"));

    return next(request);
}

stats :: () -> string {
    return "stats";
}

main :: () {
    app: App;
    defer free_app(*app);

    admin := group(*app, "/admin", guard);
    get(admin, "/stats", stats); // GET /admin/stats

    api := test_client(*app);

    refused, _ := get(api, "/admin/stats");
    assert(refused.status == 401);
    close(*refused);

    token := Header_Field.[.{ name = "Authorization", value = "secret" }];
    allowed, _ := get(api, "/admin/stats", token);
    assert(allowed.status == 200);
    close(*allowed);
}

Doesn't run in the browser (skipped on wasm).

getexperimental3 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

get(*app, "/users/{id}", handler). max_body (all the verbs take it) is the most request body bytes the route reads: 0 uses config.max_body_size, -1 allows any size (for a handler that streams its *Body); a body over it is a 413 from Json, Form, read and read_all. The template and the handler are compile-time arguments: the template is parsed and every extractor of the handler is checked against it while the program compiles, and the code that fills them in is generated for this route. A route that does not fit is an error that points at the call.

find_user :: (id: Path("id", int)) -> string, Http_Error {
    if id.value != 1 return "", not_found("No user %", id.value);

    return "Ada", .{};
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/users/{id}", find_user);

    api := test_client(*app);

    found, _ := get(api, "/users/1");
    assert(found.status == 200);
    close(*found);

    missing, _ := get(api, "/users/2");
    assert(missing.status == 404);
    close(*missing);
}

Doesn't run in the browser (skipped on wasm).

postexperimental4 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, value: $V, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)
(client: *Client, url: string, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

Adds a POST route; the template, handler and max_body work as for get. A handler reads the body through Json(T), Form(T) or a *Body parameter.

Item :: struct {
    name: string;
}

add :: (item: Json(Item)) -> string {
    return tprint("added %", item.temporary.name);
}

main :: () {
    app: App;
    defer free_app(*app);

    post(*app, "/items", add);

    api := test_client(*app);
    response, _ := post(api, "/items", Item.{ name = "pen" }); // a struct is sent as JSON
    defer close(*response);

    text, _ := read_text(*response);
    defer free(text);

    assert(response.status == 200 && text == "added pen");
}

Doesn't run in the browser (skipped on wasm).

putexperimental3 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, value: $V, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

Adds a PUT route; the template, handler and max_body work as for get.

replace :: (id: Path("id", int)) -> string {
    return tprint("replaced %", id.value);
}

main :: () {
    app: App;
    defer free_app(*app);

    put(*app, "/items/{id}", replace);

    api := test_client(*app);

    response, _ := put(api, "/items/3", "new value");
    defer close(*response);

    text, _ := read_text(*response);
    defer free(text);
    assert(text == "replaced 3");
}

Doesn't run in the browser (skipped on wasm).

patchexperimental3 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, value: $V, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

Adds a PATCH route; the template, handler and max_body work as for get.

touch :: (id: Path("id", int)) -> Response {
    return status_only(204);
}

main :: () {
    app: App;
    defer free_app(*app);

    patch(*app, "/items/{id}", touch);

    api := test_client(*app);

    response, _ := patch(api, "/items/3", "changed");
    defer close(*response);

    assert(response.status == 204);
}

Doesn't run in the browser (skipped on wasm).

deleteexperimental3 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

Adds a DELETE route; the template, handler and max_body work as for get. A handler that answers with nothing to say can return status_only(204).

discard :: (id: Path("id", int)) -> Response {
    return status_only(204);
}

main :: () {
    app: App;
    defer free_app(*app);

    delete(*app, "/items/{id}", discard);

    api := test_client(*app);
    response, _ := delete(api, "/items/7");
    defer close(*response);

    assert(response.status == 204);
}

Doesn't run in the browser (skipped on wasm).

optionsexperimental3 overloadsSource
(app: *App, $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(group: *Group($P), $template: string, $handler: $H, max_body := 0, $loc := #caller_location)
(client: *Client, url: string, headers: [] Header_Field = .[]) -> (Client_Response, Client_Error)

Adds an OPTIONS route. Register one only to change the default, which answers with an Allow list of the methods the path has. The template, handler and max_body work as for get.

choices :: () -> Response {
    return with_header(status_only(204), "Allow", "GET, POST");
}

main :: () {
    app: App;
    defer free_app(*app);

    options(*app, "/things", choices);

    api := test_client(*app);
    response, _ := options(api, "/things");
    defer close(*response);

    assert(response.status == 204);
    allow, found := find_header(response.headers, "allow");
    assert(found && allow == "GET, POST");
}

Doesn't run in the browser (skipped on wasm).

closeexperimental2 overloadsSource
(link: Link, now := false) -> Io.Io_Error
(response: *Client_Response, now := false)

Closes the connection: both halves share one socket or pipe end, so closing the reader closes it once. With now it does not wait (see Io.close).

withinexperimentalSource
(timeout: s64) -> s64

A with_deadline argument for a timeout setting in milliseconds where 0 or less means no limit.

Server#

startexperimentalSource
(app: *App, reuse_port := false) -> (*Server, Serve_Error)

Starts listening on the running scheduler (call it inside Io.io_run, from a fiber) and returns at once; connections are served by fibers of this scheduler. Fails if the address cannot be bound, the routes conflict or config.tls is not usable. stop and wait end it.

Io :: #import "Extensions/Io";

app: App;

hello :: () -> string {
    return "hello";
}

main :: () {
    get(*app, "/hello", hello);
    app.config.address = "127.0.0.1:0"; // any free port

    Io.io_run(() {
        server, error := start(*app);
        assert(error.kind == .NONE);

        client: Client;
        response, _ := get(*client, tprint("%/hello", server_url(server)));
        text, _ := read_text(*response);
        assert(text == "hello");
        close(*response);

        stop(server);
        wait(server);
    });
}

Doesn't run in the browser (skipped on wasm).

server_urlexperimentalSource
(server: *Server) -> string

The http://host:port (https:// with config.tls) the server is reachable at (a wildcard address shows as 127.0.0.1).

stopexperimentalSource
(server: *Server)

Starts the graceful shutdown: no new connections, idle ones close, requests in progress finish (up to config.shutdown_timeout, then the connections are cut). Returns at once; wait returns when it is done.

waitexperimentalSource
(server: *Server)

Blocks the calling fiber until the server has stopped and its connections are gone. The Server itself stays valid (its fibers may still be winding down) and costs a few hundred bytes; nothing needs it afterwards.

serveexperimentalSource
(app: *App) -> Serve_Error

Runs the app until it is stopped: SIGINT or SIGTERM (when config.handle_signals), or shutdown(*app) from another thread. One scheduler per config.threads (default: the processor count), each with its own listener on the same port. Returns once the servers have stopped; the error says why it could not start (the port is taken, two routes conflict, the TLS configuration is unusable) and is .NONE for a clean stop.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    error := serve(*app);   // returns after Ctrl-C
    if error.kind != .NONE print("%\n", error_text(error));
}

This one is only checked to compile, not run.

Client#

free_errorexperimentalSource
(error: *Client_Error)

Releases the strings an error from the client holds.

api: Client;
_, error := get(*api, "ftp://example.com/file");
free_error(*error);
assert(error.kind == .NONE);

Doesn't run in the browser (skipped on wasm).

set_headerexperimentalSource
(client: *Client, name: string, value: string)

A header sent with every request of client that does not set it: set_header(*api, "Authorization", "Bearer abc"). Setting a name again replaces the value.

(client: *Client, name: string)

Takes a default header off again.

api: Client;
set_header(*api, "X-Debug", "1");
remove_header(*api, "x-debug");
assert(api.headers.count == 0);

Doesn't run in the browser (skipped on wasm).

bytes_ofexperimentalSource
(text: string) -> [] u8

The bytes of text (the same memory), for a request's body.

request := Client_Request.{ method = .POST, url = "/echo", body = bytes_of("payload") };
assert(request.body.count == 7);

Doesn't run in the browser (skipped on wasm).

sendexperimentalSource

Sends request and returns once the status line and headers have arrived. The response is whatever the server answered (a 404 is a response, not an error); the body is read from it with read_text, read_bytes, read_json or read_some. Errors are only about not getting a response: the URL, the connection, a timeout, a reply that is not HTTP, too many redirects.

echo :: (body: *Body) -> string {
    data, _ := read_all(body);
    return cast(string) data;
}

main :: () {
    app: App;
    defer free_app(*app);

    post(*app, "/echo", echo);

    api := test_client(*app);

    response, error := send(api, .{ method = .POST, url = "/echo", body = bytes_of("ping") });
    assert(error.kind == .NONE);
    defer close(*response);

    text, _ := read_text(*response);
    defer free(text);
    assert(text == "ping");
}

Doesn't run in the browser (skipped on wasm).

read_textexperimentalSource
(response: *Client_Response) -> (string, Client_Error)

The whole body as text, in memory from the context allocator (release it with free). A body longer than the request's max_response_size is a .TOO_LARGE error and is not read.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    text, error := read_text(*response);
    defer free(text);
    assert(error.kind == .NONE && text == "hello");
}

Doesn't run in the browser (skipped on wasm).

read_bytesexperimentalSource
(response: *Client_Response) -> ([] u8, Client_Error)

The whole body, in memory from the context allocator (release it with free).

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    bytes, error := read_bytes(*response);
    defer free(bytes.data);
    assert(error.kind == .NONE && bytes.count == 5);
}

Doesn't run in the browser (skipped on wasm).

(data: *void, buffer: [] u8) -> (s64, Io.Io_Error)

The Io.Reader read procedure over a response body: it reads through read_some, stops at the request's max_response_size, and keeps the client error it ran into in the Body_Reader (it can report only an Io_Error).

read_someexperimentalSource
(response: *Client_Response, buffer: [] u8) -> (int, Client_Error)

Reads the next part of the body into buffer and returns how many bytes it wrote: at least one while there is more, then 0 once the body has ended (and on every call after that). It waits for data when none has arrived. For a body that should not be held in memory:

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/", hello);

    api := test_client(*app);
    response, _ := get(api, "/");
    defer close(*response);

    buffer: [4] u8;
    total := 0;

    while true {
        count, error := read_some(*response, buffer);
        if error.kind != .NONE || count == 0 break;
        total += count;
    }

    assert(total == 5);
}

Doesn't run in the browser (skipped on wasm).

A non-2xx response as an error, for code that treats those as failures: .NONE for a 2xx. The error carries status, title and detail (from an RFC 9457 problem body, the kind the server sends, when there is one) and the body text, so this reads the body. Release the error with free_error.

main :: () {
    app: App;
    defer free_app(*app);

    api := test_client(*app);
    response, _ := get(api, "/missing");
    defer close(*response);

    error := error_for_status(*response);
    defer free_error(*error);

    assert(error.kind == .STATUS && error.status == 404);
}

Doesn't run in the browser (skipped on wasm).

()

Closes every idle connection of the running scheduler, HTTP/2 ones included (they are also closed when it ends). It returns once they are closed, so a TLS config they used may be freed after it.

The counts of the running scheduler's pools, for tests and diagnostics. Call it on a scheduler, inside Io.io_run or a test client call. A program that closes or reads every response to the end shows no open responses.

Io :: #import "Extensions/Io";

main :: () {
    Io.io_run(() {
        statistics := http_statistics();

        assert(statistics.open_responses == 0);
        assert(statistics.idle_connections == 0);
    });
}

Doesn't run in the browser (skipped on wasm).

Test client#

test_clientexperimentalSource
(app: *App, options := Test_Options.{}) -> *Client

A client for app. The app must stay alive and unchanged in its routes while the client is used; its state pointer is yours to inspect between requests. The client belongs to the test run (run_tests frees it afterwards); free_test_client releases one sooner.

(client: *Client)

Releases a client made by test_client before the test ends. run_tests frees the clients of a test by itself, so call this only for a client that should not live until then. The client and its responses must not be used afterwards.

Expect#

expectexperimentalSource
(condition: bool, what := "", loc := #caller_location) -> bool

A condition the test relies on, for anything that is not a response: the state a handler left behind, a count. what says what the condition means.

assert(expect(1 + 1 == 2, "arithmetic works"));

Doesn't run in the browser (skipped on wasm).

expect_equalexperimentalSource
(actual: $T, expected: T, what := "", loc := #caller_location) -> bool

actual == expected, showing both when they differ.

assert(expect_equal(2 + 2, 4));
assert(expect_equal("abc", "abc", "the name"));

Doesn't run in the browser (skipped on wasm).

(response: Client_Response, status: int, loc := #caller_location) -> bool

The status of the response is exactly status.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    assert(expect_status(response, 200));
}

Doesn't run in the browser (skipped on wasm).

(response: Client_Response, loc := #caller_location) -> bool

The status is 2xx.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    assert(expect_success(response));
}

Doesn't run in the browser (skipped on wasm).

expect_headerexperimental2 overloadsSource
(response: Client_Response, name: string, value: string, loc := #caller_location) -> bool
(response: Client_Response, name: string, loc := #caller_location) -> bool

The response has the header, with exactly this value (any case in the name). For Content-Type a value without parameters matches the media type: "application/json" accepts application/json; charset=utf-8.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    assert(expect_header(response, "Content-Type", "text/plain"));
}

Doesn't run in the browser (skipped on wasm).

(response: Client_Response, name: string, loc := #caller_location) -> bool

The response has no header called name (any case). A failure names the header and its value.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);
    response, _ := get(api, "/hello");
    defer close(*response);

    assert(expect_no_header(response, "Set-Cookie"));
    assert(expect_header(response, "Content-Type"));
}

Doesn't run in the browser (skipped on wasm).

expect_textexperimentalSource
(response: Client_Response, expected: string, loc := #caller_location) -> bool

The body is exactly expected.

hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app);

    response, _ := get(api, "/hello");
    defer close(*response);

    assert(expect_text(response, "hello"));
}

Doesn't run in the browser (skipped on wasm).

(response: Client_Response, location: string, status := 0, loc := #caller_location) -> bool

The response redirects (3xx, or exactly status when given) to location.

go_home :: () -> Response {
    return redirect("/home");
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/", go_home);

    api := test_client(*app);

    response, _ := get(api, "/");
    defer close(*response);

    assert(expect_redirect(response, "/home", 302));
}

Doesn't run in the browser (skipped on wasm).

expect_jsonexperimentalSource
(response: Client_Response, $T: Type, status := 0, loc := #caller_location) -> T

The body decoded as JSON into a T. The status must be status, or 2xx when it is left out, and the Content-Type JSON. On failure the value is empty. The strings in it are allocated with the context allocator; a test may leave them.

Note :: struct {
    title: string;
}

show :: () -> Response {
    return json(Note.{ title = "milk" });
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/note", show);

    api := test_client(*app);

    response, _ := get(api, "/note");
    defer close(*response);

    note := expect_json(response, Note);
    assert(note.title == "milk");
}

Doesn't run in the browser (skipped on wasm).

(response: Client_Response, status: int, detail := "", loc := #caller_location) -> Problem

The response is an RFC 9457 problem document with this status and, when detail is given, a detail that contains it. Returns the decoded document (empty when the check failed).

find :: (id: Path("id", int)) -> string, Http_Error {
    return "", not_found("No note %", id.value);
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/notes/{id}", find);

    api := test_client(*app);

    response, _ := get(api, "/notes/9");
    defer close(*response);

    problem := expect_problem(response, 404, "No note 9");
    assert(problem.title == "Not Found");
}

Doesn't run in the browser (skipped on wasm).

run_testsexperimentalSource
(cases: [] Test_Case)

Runs the tests one after another on the calling thread, prints a line per test (and the details of what failed), then a summary. Exits with status 1 when any expectation failed. Clients made by test_client are released after each test.

()

Collects the failure text that would be printed instead of printing it, and does not count the failures against the run: for tests of the test tools themselves. end_failure_capture returns the text and how many failures there were.

() -> (string, int)

Ends a begin_failure_capture and returns the failure text collected since, in the temporary allocator, and how many expectations failed. Failures that were captured are not counted against the run.

main :: () {
    begin_failure_capture();
    expect_equal(1, 2, "numbers");
    text, count := end_failure_capture();

    assert(count == 1);
    assert(text.count > 0);
}

Doesn't run in the browser (skipped on wasm).

() -> int

Totals so far, for programs that run their own loop instead of run_tests.

Types#

MethodenumexperimentalSource
:: enum

The request methods the codec knows. A token that is not one of them is .OTHER and its spelling stays in Head.method_text. parse_method turns text into a Method and method_name turns it back.

OTHER
any other token: the spelling is in Head.method_text
GET
HEAD
POST
PUT
DELETE
CONNECT
OPTIONS
TRACE
PATCH
assert(parse_method("PATCH") == .PATCH);
assert(parse_method("get") == .OTHER); // method names are case-sensitive
assert(method_name(.DELETE) == "DELETE");
↗
VersionenumexperimentalSource
:: enum

The HTTP versions the codec speaks: HTTP_1_0 and HTTP_1_1. Anything else in a start line is refused as .VERSION_NOT_SUPPORTED (or .BAD_VERSION when it is not shaped like a version).

HTTP_1_0
HTTP_1_1
Target_FormenumexperimentalSource
:: enum

What shape the request target has (RFC 9112 3.2).

NONE
a response
ORIGIN
/path?query
ABSOLUTE
http://host/path
AUTHORITY
host:port, CONNECT only
ASTERISK
*, OPTIONS only
Body_KindenumexperimentalSource
:: enum

How a message body is delimited.

NONE
LENGTH
Content-Length bytes
CHUNKED
UNTIL_CLOSE
responses only: the body ends when the connection does
Header_NameenumexperimentalSource
:: enum

Well-known header names. Parsed headers carry the id so code can compare an enum instead of strings; HEADER_NAMES holds the canonical spelling the writers use.

UNKNOWN
ACCEPT
ACCEPT_CHARSET
ACCEPT_ENCODING
ACCEPT_LANGUAGE
ACCEPT_RANGES
ACCESS_CONTROL_ALLOW_CREDENTIALS
ACCESS_CONTROL_ALLOW_HEADERS
ACCESS_CONTROL_ALLOW_METHODS
ACCESS_CONTROL_ALLOW_ORIGIN
ACCESS_CONTROL_EXPOSE_HEADERS
ACCESS_CONTROL_MAX_AGE
ACCESS_CONTROL_REQUEST_HEADERS
ACCESS_CONTROL_REQUEST_METHOD
AGE
ALLOW
AUTHORIZATION
CACHE_CONTROL
CONNECTION
CONTENT_DISPOSITION
CONTENT_ENCODING
CONTENT_LANGUAGE
CONTENT_LENGTH
CONTENT_LOCATION
CONTENT_RANGE
CONTENT_TYPE
COOKIE
DATE
ETAG
EXPECT
EXPIRES
FORWARDED
FROM
HOST
IF_MATCH
IF_MODIFIED_SINCE
IF_NONE_MATCH
IF_RANGE
IF_UNMODIFIED_SINCE
KEEP_ALIVE
LAST_MODIFIED
LINK
LOCATION
MAX_FORWARDS
ORIGIN
PRAGMA
PROXY_AUTHENTICATE
PROXY_AUTHORIZATION
PROXY_CONNECTION
RANGE
REFERER
RETRY_AFTER
SEC_WEBSOCKET_ACCEPT
SEC_WEBSOCKET_KEY
SEC_WEBSOCKET_PROTOCOL
SEC_WEBSOCKET_VERSION
SERVER
SET_COOKIE
TE
TRAILER
TRANSFER_ENCODING
UPGRADE
USER_AGENT
VARY
VIA
WARNING
WWW_AUTHENTICATE
X_FORWARDED_FOR
X_FORWARDED_HOST
X_FORWARDED_PROTO
X_REQUEST_ID
Header_FieldstructexperimentalSource
:: struct

One header field line. name keeps the spelling on the wire; id says which well-known header it is (.UNKNOWN for others). value has no leading or trailing blanks. Strings of a parsed head point into the parser's buffer.

name: string
value: string
Parse_Error_KindenumexperimentalSource
:: enum

Why the parser refused a message, one value per rule it enforces. The comments give the status a server answers with (error_status returns the same number). The kind is in Parser.error after an .ERROR event, and a failed parser stays failed.

NONE
BAD_START_LINE
400: not METHOD target HTTP/1.x (or a status line)
BAD_METHOD
400
BAD_TARGET
400: bytes a target may not hold, or a form the method does not allow
BAD_VERSION
400: not shaped like HTTP/d.d
VERSION_NOT_SUPPORTED
505: HTTP/0.9, HTTP/2, HTTP/1.2...
BAD_STATUS
400: a response status that is not 100-599
BAD_LINE_ENDING
400: a bare LF, or a CR not followed by LF
BAD_HEADER_NAME
400: not a token, or no colon
WHITESPACE_BEFORE_COLON
400
OBS_FOLD
400: a header line starting with SP or HTAB
BAD_HEADER_VALUE
400: control bytes in a value
MISSING_HOST
400: an HTTP/1.1 request without Host
DUPLICATE_HOST
400
BAD_CONTENT_LENGTH
400: not 1*DIGIT, or too big for 63 bits
DUPLICATE_CONTENT_LENGTH
400
CONTENT_LENGTH_WITH_TRANSFER_ENCODING
400: request smuggling
BAD_TRANSFER_ENCODING
400: empty, repeated chunked, or chunked not last
UNSUPPORTED_TRANSFER_ENCODING
501: gzip, deflate... around chunked
BAD_EXPECT
417: an expectation other than 100-continue
BAD_CHUNK
400: chunk size, extension or framing
CHUNK_SIZE_OVERFLOW
400: more than 63 bits of chunk size
BAD_TRAILER
400
URI_TOO_LONG
414: the request line is over max_line_size
HEADERS_TOO_LARGE
431: a header line or the head is over its limit
TOO_MANY_HEADERS
431
BODY_TOO_LARGE
413
DATA_AFTER_CLOSE
400: bytes after a message that ends the connection
UNEXPECTED_EOF
400: the connection closed inside a message
parser: Parser;
parser_init(*parser, .REQUEST);
defer parser_free(*parser);

// An HTTP/1.1 request without a Host header.
text := "GET / HTTP/1.1\r\n\r\n";
_, event := parse(*parser, .{ text.count, text.data });

assert(event.kind == .ERROR);
assert(parser.error.kind == .MISSING_HOST);
assert(error_status(parser.error.kind) == 400);
↗
Parse_ErrorstructexperimentalSource
:: struct

Why parsing stopped. message is a static string.

message: string
offset: int
bytes of the current message consumed before the fault
LimitsstructexperimentalSource
:: struct

Limits on what the parser accepts. A message over a limit is refused with the kind that maps to the status the limit names.

max_line_size: := 8192
the request line (414) or one header line (431), bytes, CRLF excluded
max_head_size: := 65536
the request line and headers together (431)
max_headers: := 100
header lines (431)
max_trailer_size: := 8192
all trailer lines together (431)
max_chunk_extension: := 1024
bytes after the chunk size on its line (400)
max_body_size: s64
decoded body bytes; -1 for no limit (413)
require_host: := true
an HTTP/1.1 request needs exactly one Host (RFC 9112 3.2)
skip_leading_empty_lines: := true
ignore CRLFs before the start line (RFC 9112 2.2 asks it of servers; clients benefit too)
Parser_KindenumexperimentalSource
:: enum

Whether a Parser reads requests (as a server does) or responses (as a client does). Set once by parser_init.

REQUEST
RESPONSE
Event_KindenumexperimentalSource
:: enum

What parse found. Loop on the event, not on the number of bytes used: HEAD and END can come with none. The comments say where the data of each kind lives.

NEED_MORE
all of the input was used; give more
HEAD
parser.head is filled in
BODY
event.data is a piece of the body (a slice of the caller's input)
END
the message is complete (parser.trailers holds chunked trailers)
SWITCHED
101 or a CONNECT tunnel: the rest of the stream is not HTTP
CLOSED
parse_eof: the connection ended between messages
ERROR
parser.error says why; the parser stays failed
parser: Parser;
parser_init(*parser, .RESPONSE);
defer parser_free(*parser);

text := "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello";
data: [] u8 = .{ text.count, text.data };

kinds: [..] Event_Kind;
defer array_free(kinds);

body: [..] u8;
defer array_free(body);

while true {
    used, event := parse(*parser, data);
    data = advance(data, used);
    array_add(*kinds, event.kind);

    if event.kind == .BODY array_add(*body, ..event.data);
    if event.kind == .END || event.kind == .NEED_MORE || event.kind == .ERROR break;
}

assert(kinds.count == 3 && kinds[0] == .HEAD && kinds[1] == .BODY && kinds[2] == .END);
assert(parser.head.status == 200);
assert(string.{ body.count, body.data } == "hello");
↗
EventstructexperimentalSource
:: struct

One result of parse: its kind and, for .BODY, data, a slice of the input that was handed to parse (not a copy), so it is valid only as long as that input is. For the other kinds data is empty.

data: [] u8
SpanstructexperimentalSource
:: struct

A header line of the head or the trailers, as offsets into the buffer that holds it (the buffer moves while it grows; strings are made once the head is complete).

name_start: int
name_end: int
value_start: int
value_end: int
Ext_StateenumexperimentalSource
:: enum

Where the reader is inside a chunk extension (RFC 9112 7.1.1).

BEFORE_SEMI
after the size or an extension: blanks, then ; or CRLF
NAME_START
after ;
NAME
AFTER_NAME
blanks, then =, ; or CRLF
VALUE_START
after =
TOKEN_VALUE
QUOTED
QUOTED_PAIR
AFTER_QUOTED
right after the closing quote
AFTER_VALUE
Parser_StateenumexperimentalSource
:: enum

Where a Parser is in a message. It is bookkeeping for the parser itself; callers watch the events parse returns instead.

START
reading the start line and the headers
BODY_NONE
the head is out and there is no body: END is next
BODY_LENGTH
BODY_CLOSE
CHUNK_SIZE
CHUNK_EXT
CHUNK_SIZE_LF
CHUNK_DATA
CHUNK_DATA_CR
CHUNK_DATA_LF
TRAILERS
DONE
the message ended the connection (Connection: close...): nothing may follow
SWITCHED
FAILED
ParserstructexperimentalSource
:: struct

An incremental HTTP/1.x parser for one direction of a connection. Start it with parser_init, feed it with parse in pieces of any size, and release it with parser_free. The result does not depend on how the bytes were split.

limits: Limits
response_to: Method
For a response parser: the method of the request being answered. HEAD and CONNECT change how the response body is framed (RFC 9112 6.3), so set it before the response head arrives.
head: Head
valid after a HEAD event, until the next message begins
trailers: [] Header_Field
valid after END of a chunked body, until the next message begins
started: bool
Internal.
in_headers: bool
the start line has been read
bytes: [..] u8
the head: start line and header lines, CRLFs included
line_start: int
spans: [..] Span
list: [..] Header_Field
skipped_lines: int
method_start: int
The start line, as offsets into bytes.
method_end: int
target_start: int
target_end: int
reason_start: int
reason_end: int
version: Version
status: int
method: Method
content_length_seen: bool
What the headers seen so far say (note_header).
content_length: s64
encoding_seen: bool
chunked: bool
other_coding: bool
host_count: int
close: bool
keep_alive_token: bool
upgrade_token: bool
upgrade_header: bool
expect_continue: bool
remaining: s64
bytes left in the Content-Length body or the current chunk
total: s64
decoded body bytes so far
chunk_size: s64
chunk_digits: int
chunk_ext: int
bytes of the extension so far
ext_token: int
bytes of the name or value being read
offset: int
bytes of this message consumed before the current call
tbytes: [..] u8
trailers
tline_start: int
tspans: [..] Span
tlist: [..] Header_Field
parser: Parser;
parser_init(*parser, .REQUEST);
defer parser_free(*parser);

text := "POST /items HTTP/1.1\r\nHost: example.com\r\nContent-Length: 2\r\n\r\nhi";
data: [] u8 = .{ text.count, text.data };

body: [..] u8;
defer array_free(body);

while true {
    used, event := parse(*parser, data);
    data = advance(data, used);

    if event.kind == .HEAD {
        assert(parser.head.method == .POST);
        assert(parser.head.target == "/items");
        assert(parser.head.content_length == 2);
    }

    if event.kind == .BODY array_add(*body, ..event.data);
    if event.kind == .END || event.kind == .NEED_MORE || event.kind == .ERROR break;
}

assert(string.{ body.count, body.data } == "hi");
↗
Response_OptionsstructexperimentalSource
:: struct

What the response writer needs to know besides the status and the headers.

version: := Version.HTTP_1_1
reason: string
empty for the standard phrase of the status
request_method: := Method.GET
HEAD responses and CONNECT tunnels carry no body
keep_alive: := true
false adds Connection: close
date: string
when not empty, sent as the Date header (see Date_Cache)
server: string
when not empty, sent as the Server header
Date_CachestructexperimentalSource
:: struct

The date text for the current second, formatted again only when the second changes. One cache per thread (it is not synchronized).

second: s64
text: [29] u8
UrlstructexperimentalSource
:: struct

The parts of an http://host:port/path?query (or https://) URL. All strings point into the text given to parse_url.

scheme: string
secure: bool
https
host: string
without the brackets of an IPv6 literal
port: u16
target: string
path and query as sent: always starts with /
path: string
query: string
ipv6: bool
Url_ProblemenumexperimentalSource
:: enum

Why parse_url refused a text, with .NONE for success.

NONE
NOT_A_URL
no scheme://
UNSUPPORTED_SCHEME
anything but http and https
NO_HOST
BAD_PORT
BAD_HOST
RequeststructexperimentalSource
:: struct

A parsed request, as handlers and middleware see it. The strings (target, header names and values) stay valid until the response has been written: copy what has to outlive the request. The body is not here: take a *Body parameter to read it (see Body), or Json(T), Form(T).

method: Method
method_text: string
the spelling on the wire, for methods the codec does not know
target: string
the request target as sent: /users/7?x=1
path: string
the target without its query, still percent-encoded
query: string
the text after ?, still percent-encoded
version: Version
headers: [] Header_Field
HandlertypeexperimentalSource
:: #type (request: *Request) -> Response

A handler after its extractors are built: (request) -> Response. Routes store one per route.

NexttypeexperimentalSource
:: #type (request: *Request) -> Response

The rest of the middleware chain and then the handler: next(request) runs it and returns its response, which the middleware may change or replace.

MiddlewaretypeexperimentalSource
:: #type (request: *Request, next: Next) -> Response

A request filter: log, check a token, add headers, or answer itself without calling next.

require_token :: (request: *Request, next: Next) -> Response {
    token, found := header(request, "Authorization");
    if !found return error_response(request, unauthorized("a token is required"));

    return next(request);
}

Doesn't run in the browser (skipped on wasm).

ResponsestructexperimentalSource
:: struct

What a handler returns when it wants full control. The headers and body must stay valid until the response is written: use string literals or the temporary allocator (tprint, ,, temp), which the server resets after each response. The builders below do.

status: := 200
headers: [..] Header_Field
body: [] u8
source: *Response_Source
Set by stream_response and file_response: the body is produced while it is sent.
Http_Error_KindenumexperimentalSource
:: enum

Whether an Http_Error is set: .NONE (a handler's .{}) or .STATUS, an HTTP status to answer with.

NONE
no error: .{}
STATUS
an HTTP status, from a handler
Http_ErrorstructexperimentalSource
:: struct

What a handler returns to answer with a status instead of a value. A handler's .{} means no error. The server sends it as an RFC 9457 application/problem+json document:

status: int
the HTTP status
title: string
short and fixed per status; the reason phrase when empty
detail: string
what happened this time
problem_type: string
a URI naming the kind of problem; "about:blank" when empty
location: string
for bad input: body, query, path, header or form
field: string
for bad input: the parameter, header or JSON path that was wrong
headers: [] Header_Field
sent with the error (Retry-After, WWW-Authenticate); see with_header
find_user :: (id: int) -> string, Http_Error {
    return "", not_found("No user with id %", id);
}

_, error := find_user(7);

// Sent as 404 {"type":"about:blank","title":"Not Found","status":404,"detail":"No user with id 7"}
assert(error.kind == .STATUS);
assert(error.status == 404);
assert(error.detail == "No user with id 7");

Doesn't run in the browser (skipped on wasm).

Bodystructexperimentalsince 0.30.0Source
:: struct

The body of the request being handled. Take it as a handler parameter (body: *Body); it is valid until the handler returns.

content_length: s64
The length the client declared, or -1 when the body is chunked.
Stream_Producertypeexperimentalsince 0.30.0Source
:: #type (stream: *Stream, data: *void)

Where a stream's bytes come from. Called once, after the head has been sent, on the connection's fiber; it may wait (Io.sleep, channels) between pieces. Not called for a HEAD request.

Streamstructexperimentalsince 0.30.0Source
:: struct

The sending end of a streamed response, valid while its producer runs.

chunked: bool
True on HTTP/1.1, where every send_chunk is one chunk of the chunked body.
PathstructexperimentalSource
:: struct

One {name} of the route, parsed as a T: integers, floats, bools, strings and enums (by member name). A value that does not parse is a 400 that names the parameter.

value: T
ParamsstructexperimentalSource
:: struct

Several {params} at once: every field of T names a parameter of the route. The T lives in temporary storage for the request.

temporary: T
Post_Key :: struct {
    user: int;
    post: string;
}

get_post :: (key: Params(Post_Key)) -> string {
    return tprint("% / %", key.temporary.user, key.temporary.post);
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/users/{user}/posts/{post}", get_post);

    api := test_client(*app);
    response, _ := get(api, "/users/7/posts/intro");
    defer close(*response);

    text, _ := read_text(*response);
    defer free(text);

    assert(text == "7 / intro");
}

Doesn't run in the browser (skipped on wasm).

ReststructexperimentalSource
:: struct

The {name...} tail of the route as one string (the rest of the path, percent-decoded).

value: string
QuerystructexperimentalSource
:: struct

The query string, decoded into a T with the rules and notes of JSON (default, rename, alias...): a field without default is required, repeated keys fill arrays. The T lives in temporary storage for the request.

temporary: T
JsonstructexperimentalSource
:: struct

The whole request body as JSON, read before the handler runs (read_json does the same from a *Body). Content-Type: application/json is required (415 otherwise), a body that is not valid JSON is a 400 and one of the wrong shape a 422; both name the JSON path. A body over the route's max_body is a 413. The T lives in temporary storage for the request: copy the strings you keep.

temporary: T
New_User :: struct {
    name: string;
}

User :: struct {
    id: int;
    name: string;
}

create :: (body: Json(New_User)) -> User {
    return .{ id = 1, name = copy_string(body.temporary.name) };
}

main :: () {
    app: App;
    defer free_app(*app);

    post(*app, "/users", create);

    api := test_client(*app);
    response, _ := post(api, "/users", New_User.{ name = "ada" });
    defer close(*response);

    text, _ := read_text(*response);
    defer free(text);

    assert(response.status == 200 && text.count > 0);
}

Doesn't run in the browser (skipped on wasm).

FormstructexperimentalSource
:: struct

An application/x-www-form-urlencoded body, read before the handler runs and decoded like a query string. The same limit and the same lifetime as Json.

temporary: T
Header_OptionalstructexperimentalSource
:: struct

One request header that may be missing, parsed as a T when it is there. present says which; when it is false value is the zero value. A header that is present but does not parse as T is still a 400. Strings point into the request and last as long as it does.

value: T
present: bool
StatestructexperimentalSource
:: struct

The shared state set with app.state = *my_state. Take it as *State(My_State); the fields of My_State are reachable directly. The pointer is the state itself, not a copy: handlers on several threads see the same memory, so guard what they change.

value: T
Server_ConfigstructexperimentalSource
:: struct

What an App does when nothing else is said. Every number is a limit or a timeout in milliseconds unless it says otherwise; 0 turns the ones that allow it off.

address: := ":8080"
Where to listen: ":8080", "127.0.0.1:3000", "[::1]:8080". Port 0 picks a free one.
threads: := 0
Schedulers (OS threads), each with its own listener. 0 (or less) is one per processor.
header_timeout: := 10_000
How long a client may take to send the request head, from its first byte; also the limit for the TLS handshake of an https connection. Over it: 408.
body_timeout: := 30_000
How long a client may stay silent while sending a request body. Over it: 408.
idle_timeout: := 60_000
How long a keep-alive connection may wait for its next request before it is closed. With no limit it waits until the client sends something or hangs up (stop still closes it).
write_timeout: := 30_000
How long one write of a response may take; a slower client is disconnected.
shutdown_timeout: := 10_000
How long stop waits for requests in progress before it cuts connections off. Here 0 or less does not mean no limit: the wait is over at once and connections are cut without waiting, so there is no way to wait forever.
max_body_size: := 1024 * 1024
Request limits. A request over one is refused with 413, 414 or 431 and the connection closes.
max_head_size: := 64 * 1024
max_headers: := 100
max_line_size: := 8 * 1024
max_requests_per_connection: := 10_000
Requests served on one connection before it is closed (0 or less: no limit).
read_buffer_size: := 16 * 1024
Bytes read from a socket at a time (one buffer per connection).
server_name: := "jaic"
The Server header; empty sends none.
log_requests: := false
Print one line per request to standard output: method, target, status and time.
handle_signals: := true
Stop on SIGINT and SIGTERM (Ctrl-C): see serve.
tls: *Tls.Tls_Config
Serve HTTPS: every accepted connection does a TLS handshake first (within header_timeout). A Tls_Config that went through Tls.tls_config_init, with a certificate chain and private key, that outlives the server. Its alpn picks the protocols: empty or .["http/1.1"] is HTTP/1.1 only, and adding "h2" (.["h2", "http/1.1"]) also serves HTTP/2. Null: plain HTTP.
http2: := true
Serve HTTP/2 to clients that negotiate h2. Off, the server leaves h2 out of the ALPN protocols it accepts (a copy of tls is made at start), so clients fall back to HTTP/1.1.
h2_max_streams: := 100
HTTP/2 only: requests in progress on one connection, and the bytes a client may send on one request before the handler takes some (the window of a stream).
h2_window: := 256 * 1024
h2_connection_window: := 1024 * 1024
HTTP/2 only: the bytes a client may send on a whole connection ahead of the handlers.
app: App;
app.config.address = "127.0.0.1:3000";
app.config.log_requests = true;
app.config.max_body_size = 5 * 1024 * 1024;
free_app(*app);

Doesn't run in the browser (skipped on wasm).

Error_HandlertypeexperimentalSource
:: #type (error: Http_Error, request: *Request) -> Response

Called for every error response (a handler's Http_Error, a failed extractor, 404, 405, 413...) to make the response. Set app.on_error to use your own format instead of problem+json.

AppstructexperimentalSource
:: struct

A web application: its routes, middleware, settings and shared state. A zeroed App is a working one. Add routes with get, post and the other verbs, then serve it with serve or start, or drive it in memory with test_client. Routes are added before serving and not changed while the app serves; the handlers run on the scheduler threads. The app owns the router, groups and setup errors it builds: release them with free_app after the servers have stopped. Strings you store in its fields (config.address, ...) are yours and are not copied.

state: *void
Shared state: app.state = *my_state; handlers take it as *State(My_State).
on_error: Error_Handler
Replaces the problem+json format of error responses.
json_options: Json_Options
Limits for the JSON bodies Json(T) and read_json read.
middleware: [..] Middleware
Middleware that wraps every request, including ones no route answers.
router: Router
setup_errors: [..] string
Problems found while routes were added (two routes that conflict), reported at startup.
groups: [..] *void
Groups made by group, released with the app.
shutdown_flag: s32
Set by shutdown; the servers of serve poll it.
hello :: () -> string {
    return "hello";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/hello", hello);

    api := test_client(*app); // the app, driven in memory: no socket
    response, error := get(api, "/hello");
    defer close(*response);

    assert(error.kind == .NONE && response.status == 200);

    text, read_error := read_text(*response);
    defer free(text);

    assert(read_error.kind == .NONE && text == "hello");
}

Doesn't run in the browser (skipped on wasm).

GroupstructexperimentalSource
:: struct

A prefix and middleware shared by the routes registered through it. The prefix is part of the type, so a handler's Path("org", ...) is checked against the whole route, prefix included.

app: *App
middleware: [..] Middleware
Serve_Error_Kindenumexperimentalsince 0.30.0Source
:: enum

Why a server did not start (Serve_Error.kind), with .NONE for success. The comments say what each kind points at. Routes are checked when the server starts, not when they are added, so a conflict shows up here.

NONE
INVALID_ROUTES
two routes match the same requests; message names them
INVALID_TLS
config.tls has no certificate and key, or Tls.tls_config_init was not called on it
CANNOT_LISTEN
the address is taken, malformed or not allowed; io_error says why
Serve_Errorstructexperimentalsince 0.30.0Source
:: struct

Why a server did not start. A kind of .NONE is success.

message: string
in the temporary allocator of the caller
io_error: Io.Io_Error
for .CANNOT_LISTEN
ServerstructexperimentalSource
:: struct

One scheduler's listener and its connections, as start returns it.

app: *App
listener: Io.Tcp_Listener
address: Io.Address
where it listens (the real port when the config asked for 0)
stopping: bool
connections: *Connection
Connections that are open, for graceful shutdown.
count: int
tls: *Tls.Tls_Config
The TLS config connections are accepted with: config.tls, or without h2 in its ALPN list when config.http2 is off (then own_tls is that copy, and the server frees it).
own_tls: *Tls.Tls_Config
dates: Date_Cache
finished: Io.Wait_Group
served: s64
requests answered, for tests and logs
Client_RequeststructexperimentalSource
:: struct

A request. Only url is needed; .{ url = "http://..." } is a GET.

method: := Method.GET
url: string
http://host[:port]/path?query (or https://), or /path?query relative to the client's base_url.
headers: [] Header_Field
Host, Content-Length and User-Agent are added when missing
body: [] u8
timeout: := -1
Milliseconds for the whole exchange, redirects and reading the body included (0: no limit, -1: the client's timeout).
max_redirects: := -1
Follow 301, 302, 303, 307 and 308 up to this many times (more is an error), or not at all when 0: the 3xx response is returned. -1 is the client's max_redirects. 301, 302 and 303 turn a POST into a GET without a body; 307 and 308 repeat the request. Authorization and Cookie are not sent to another host.
max_response_size: := 64 * 1024 * 1024
The largest body read_text, read_bytes and read_json accept, in bytes. read_some has no limit: it hands the body over in pieces.
Client_ResponsestructexperimentalSource
:: struct

A response: the status and headers, and the body still waiting on its connection. close releases it.

status: int
reason: string
headers: [] Header_Field
method: Method
The request this answers, as it was given: the method, the URL (possibly relative) and the first bytes of its body (request_body_size is the full size).
url: string
request_body: string
request_body_size: int
http2: bool
Whether the answer came over HTTP/2 (otherwise HTTP/1.1).
storage: *void
source: *Body_Source
Client_Error_KindenumexperimentalSource
:: enum

What went wrong when the client did not get, or could not read, an answer (Client_Error.kind), with .NONE for success. A response with any HTTP status, a 404 included, is not an error; error_for_status turns a non-2xx status into .STATUS.

NONE
no error: .{}
TRANSPORT
connecting, sending or receiving failed (transport says how)
TIMED_OUT
the request took longer than its timeout
INVALID_URL
the URL cannot be requested
TLS
the TLS handshake or the check of the server's certificate failed (detail says why)
REDIRECTS
too many redirects
DECODE
the body was not what read_json asked for (json says why)
BODY
the body cannot be read: it was read already, or the response was closed
TOO_LARGE
the body is longer than the request's max_response_size
STATUS
error_for_status: the response was not 2xx
INVALID_METHOD
the request's method is .OTHER, which has no spelling to put on the wire
// A URL the client cannot request is refused before anything is sent.
api: Client;
response, error := get(*api, "ftp://example.com/file");

assert(error.kind == .INVALID_URL);
assert(response.status == 0);

Doesn't run in the browser (skipped on wasm).

Client_ErrorstructexperimentalSource
:: struct

What the client returns when something went wrong. A handler's Http_Error is about answering with a status; this is about not getting, or not reading, an answer. error_text makes one line of it.

detail: string
what happened
transport: Io.Io_Error
for TRANSPORT and TIMED_OUT: the Io error behind it
json: Json_Error
for DECODE: where and why the JSON did not fit
status: int
For DECODE and STATUS: the response that was being read.
title: string
For STATUS: the reason phrase, or the title of a problem+json body, and the body text.
body: string
owned: bool
Set when the strings above were allocated for this error: release them with free_error.
TransportstructexperimentalSource
:: struct

How a client reaches a server. Both procedures run on a fiber of the running scheduler.

data: *void
Passed to both procedures; connections made through one transport are only reused by it.
base: #type (data: *void) -> string
Where a relative URL points ("http://localhost:8080"); null: the client needs a base_url for relative URLs.
open: #type (data: *void, url: Url) -> Link, Io.Io_Error
A new connection for url (its scheme, host and port); null: a TCP connection to the host.
ClientstructexperimentalSource
:: struct

What requests have in common.

base_url: string
Relative request URLs (/users/1) are joined to this: "http://localhost:8080".
headers: [..] Header_Field
Sent with every request that does not set the header itself (an Authorization token).
timeout: := 30_000
Milliseconds for a request that does not set its own timeout.
max_redirects: := 10
Redirects followed for a request that does not set its own max_redirects (0: none).
transport: Transport
tls: *Tls.Tls_Config
The TLS settings for https:// URLs; null: trust the system's roots and offer http/1.1 (a config the client makes once for the whole program). A config of your own must have gone through Tls.tls_config_init and must outlive the client's connections.
Client_SettingsstructexperimentalSource
:: struct

Pool settings, for the whole program. Idle connections are closed when they have waited idle_timeout milliseconds or when more than max_idle_per_host pile up for one host.

max_idle_per_host: := 8
idle_timeout: := 30_000
user_agent: := "jaic-http"
max_open_responses: := 64
Responses whose body is neither read to the end nor closed may hold this many connections per scheduler; past it the oldest is closed (its body can no longer be read).
http2: := true
Offer HTTP/2 (ALPN h2) on https connections. When the server picks it, requests to that origin share one connection. Off, h2 is never offered, even when a tls config of your own lists it in alpn (the client then uses a copy of that config without it).
Body_ReaderstructexperimentalSource
:: struct

The body of a response as an Io.Reader, for read_json: each read is a read_some that stops at the request's max_response_size and keeps the client error it ran into, which the reader can only report as an Io_Error.

response: *Client_Response
source: *Body_Source
total: int
failure: Client_Error
Http_StatisticsstructexperimentalSource
:: struct

What the running scheduler's pools hold right now, for tests and diagnostics (nothing here is needed to use the module). Call it on a scheduler: inside Io.io_run or a test client call.

read_buffers_in_use: int
server read buffers held by connections that are reading a request
read_buffers_free: int
read buffers kept for reuse (a connection between requests holds none)
idle_connections: int
client connections waiting in the pool, HTTP/2 ones with no stream open included
connections_opened: int
client connections opened over the scheduler's life
open_responses: int
responses that still hold their connection (body neither read to the end nor closed)
body_sources_free: int
response states kept for reuse
body_buffers_free: int
response read buffers kept for reuse
body_sources_created: int
response states allocated over the scheduler's life
Test_OptionsstructexperimentalSource
:: struct

How test_client reaches the app: in memory (the default) or over a loopback socket, whether redirects are followed, and the timeout of a request.

over_tcp: := false
Serve the app on a loopback socket (a free port, started on first use) instead of in memory. This sets app.config.address and turns handle_signals off.
follow_redirects: := false
Follow redirects like the real client. Off, a 3xx is the response, so it can be checked (expect_redirect); send(api, .{ url = ..., max_redirects = 5 }) follows for one request.
timeout: := 10_000
Milliseconds a request may take before it fails with .TIMED_OUT (the client's timeout).
go_home :: () -> Response {
    return redirect("/home");
}

home :: () -> string {
    return "home";
}

main :: () {
    app: App;
    defer free_app(*app);

    get(*app, "/", go_home);
    get(*app, "/home", home);

    plain := test_client(*app);
    first, _ := get(plain, "/");
    assert(first.status == 302); // the default hands the redirect back
    close(*first);

    following := test_client(*app, .{ follow_redirects = true });
    second, _ := get(following, "/");
    assert(second.status == 200);
    close(*second);
}

Doesn't run in the browser (skipped on wasm).

ProblemstructexperimentalSource
:: struct

The RFC 9457 document the server sends for errors, as expect_problem decodes it.

type: string
title: string
status: int
detail: string
instance: string
location: string
for bad input: body, query, path, header or form
field: string
for bad input: the parameter, header or JSON path
Test_CasestructexperimentalSource
:: struct

One test for run_tests: a name to print and a procedure to run. The procedure makes its checks with the expect_ calls; a failed one is printed and counted, and the test goes on.

name: string
check_math :: () {
    expect_equal(1 + 1, 2, "addition");
}

main :: () {
    run_tests(.[.{ name = "math", run = check_math }]);
}

Doesn't run in the browser (skipped on wasm).

Constants#

HEADER_NAMESexperimentalSource
:: string.[ "", "Accept", "Accept-Charset", "Accept-Encoding", "Accept-Language", "Accept-Ranges", "Access-Control-Allow-Credentials", "Access-Control-Allow-Header ...

Indexed by Header_Name.

PairexperimentalSource

One decoded name=value of a query string or a form, or one path parameter. The same type Extensions/Json decodes typed values from.