api

An HTTP server: routing, responses and the accept loop in one module. It plays the part of a web framework and its server at once, so there is nothing to install alongside it.

It serves JSON APIs and it serves websites — .html pages, stylesheets and images straight off disk. See Serving files for the file helpers and Build a website for a whole site.

Oxigen
introduce api

net gives you sockets; api gives you a server built on them.

#Contents


#Quick start

Oxigen
introduce api

fun index(req <map>) { {"service": "oxigen"} }

fun show_user(req <map>) { {"id": req.params["id"], "page": req.query["page"]} }

fun create_user(req <map>) { api.json(api.body_json(req), 201) }

fun routes(cfg <map>) {
    app := api.app(cfg)
    app.get("/", index)
    app.get("/users/:id", show_user)
    app.post("/users", create_user)
    app.listen()
}

main { api.serve(routes, port=8000) }
$ curl localhost:8000/
{"service":"oxigen"}

$ curl 'localhost:8000/users/42?page=2'
{"id":"42","page":"2"}

#Where cfg and req come from

Two values arrive from the framework rather than from your code, and neither is magic — each has exactly one source.

cfg <map> is handed to your worker function by serve. You never build it and never read from it directly; you pass it straight to api.app(cfg), which turns it into an App. It carries the three things a worker thread needs:

KeyTypeFrom
serverintthe listening socket handle serve bound
keep_aliveboolthe keep_alive argument you gave serve
idle_timeoutintthe idle_timeout argument you gave serve
header_timeoutintthe header_timeout argument you gave serve
body_timeoutintthe body_timeout argument you gave serve

It is a plain map rather than an App because it has to cross a thread boundary — see why the routes live in a function.

req <map> is handed to your handler by the dispatcher, once per request, after the raw bytes have been parsed. Its full contents are listed under the request map. You do not construct it, and there is nothing else in it.

So a complete server has exactly two framework-supplied names, both <map>:

Oxigen
fun routes(cfg <map>) { ... }     // cfg  from serve, once per worker thread
fun index(req <map>) { ... }      // req  from the dispatcher, once per request

#Running a server

FunctionSignature
serveserve(worker, host = "127.0.0.1", port = 8000, workers = 64, keep_alive = True, idle_timeout = 5000, header_timeout = 10000, body_timeout = 60000)
appapp(cfg)

#serve(worker, host, port, workers, keep_alive, idle_timeout, header_timeout, body_timeout)

Bind host:port and run worker on workers threads, each with its own router over the shared listening socket. Blocks until the acceptors stop.

ParameterDefaultMeaning
worker—a top-level function taking one argument, the config map
host"127.0.0.1"interface to bind; "0.0.0.0" to accept connections from other machines
port8000port to bind
workers64how many connections are served at once
keep_aliveTruereuse a connection for more requests
idle_timeout5000ms a single read may stall before the connection is dropped
header_timeout10000ms the whole request head may take to arrive; past it, 408
body_timeout60000ms the whole body may take to arrive; past it, 408

The default host is loopback only, so a server is not reachable from the network unless you ask for it with host="0.0.0.0" (in a container, that is what publishing a port needs).

#Why your routes live in a function

If you know FastAPI, this is the one place the shape differs, so it is worth one paragraph. There, app = FastAPI() sits at module level, decorators register handlers onto it, and uvicorn imports that object. Here you write a function that builds the app, and serve calls it on every worker thread.

The reason is not taste. Oxigen's concurrency is share-nothing: the only thing that crosses a thread boundary is the spawned function's name. A function is not a sendable value at all —

Oxigen
__spawn(runner, {"fn": some_function})
// spawn(): cannot send value of type FUNCTION across threads

— so serve cannot build one App and hand it, or your handlers, to the workers. What it can do is spawn your named function on each thread and give it the socket. Each worker then builds its own routing table from its module's top-level functions, which never move anywhere.

The consequences, all of them:

  • serve takes a top-level function, not a configured app.
  • That function takes cfg and must call api.app(cfg) to get its App.
  • It must end with app.listen(), because that call is the worker's accept loop — serve cannot run it for you, since it happens on the worker thread.
  • No handler is ever copied between threads, and the table is built once per worker rather than once per request.

There are no decorators, no module-level app, and no dependency injection. What you get instead is app.get(path, handler) registration, which is closer to Express than to FastAPI.

Oxigen
main { api.serve(routes, host="127.0.0.1", port=3000, workers=16) }

On start it prints:

api listening on http://127.0.0.1:3000 (16 workers)

#app(cfg)

Build this worker's router from the config map serve handed it. Returns an App. Call it as the first line of your worker function.

Oxigen
fun routes(cfg <map>) {
    app := api.app(cfg)
    // ... register routes ...
    app.listen()
}

A struct cannot be rebuilt on a thread that did not declare it, which is why what crosses the thread boundary is a plain map and the App is constructed here, on the worker.


#Routes

MethodSignature
app.get / post / put / patch / deleteapp.get(path, handler)
app.routeapp.route(method, path, handler) — any other verb
app.listenapp.listen()

#app.get(path, handler) and friends

Register a handler for one method and path. Each returns the App, so calls can be chained.

Oxigen
app.get("/health", health)
app.post("/users", create_user)
app.put("/users/:id", replace_user)
app.patch("/users/:id", update_user)
app.delete("/users/:id", delete_user)

Paths take parameters as :name — "/users/:id/posts". (Not {name}: a bare { inside an Oxigen string opens an interpolation, so that form would need escaping at every call site.)

Registration sorts routes by shape: a path with no parameter is an O(1) map lookup, and only parameterized paths are compared segment by segment.

#app.route(method, path, handler)

The general form — use it for verbs without a shorthand.

Oxigen
app.route("OPTIONS", "/", preflight)
app.route("HEAD", "/", index)

HEAD is not answered automatically: register it if a client needs it, otherwise a HEAD request to a GET-only path gets a 405.

#app.listen()

Accept connections forever. Call it last in your worker function.

It contains failures rather than dying on them: a failed accept is logged and retried, and a connection that breaks mid-request is logged and dropped without taking the acceptor with it. After 100 consecutive accept failures the loop gives up and logs why — one failure is routine (a peer vanished mid-handshake), a hundred in a row means the listener is broken and retrying forever would spin a core.


#Handlers and the request

A handler is a function of one argument that returns whatever it wants to send:

ReturnedSent as
a Res from json/text/html/redirect/respond/fail_withthat response
a string200 text/plain
anything else (map, array, number, struct)200 application/json
Oxigen
fun index(req <map>) { {"service": "oxigen"} }    // 200 application/json
fun plain(req <map>) { "just text" }              // 200 text/plain
fun page(req <map>) { api.html("<h1>hi</h1>") }   // 200 text/html

A handler that raises answers 500 and logs the real message server-side; the client is told only {"error": "internal server error"}, and the worker keeps serving.

A request that matches no route answers 404 — unless the path is registered under some other method, which answers 405 with an Allow header naming the methods that would have worked:

$ curl -i localhost:8000/echo        # /echo is registered as POST
HTTP/1.1 405 Method Not Allowed
Allow: POST

{"error":"method not allowed","allow":["POST"]}

The distinction matters when calling your own server: a 404 on a URL you know is correct sends you hunting for a typo, when the real answer is that you used the wrong verb. Note that net.get and friends raise on any non-2xx, so a client halts before it can read that body — normalize the call to see it:

Oxigen
resp := <type<Error> || <Value>>(net.get("{url}/echo"))
println(resp.msg) when is_error(resp)     // http error: http status: 405

#The request map

The request is a map, so req.params and req["params"] both work:

KeyTypeContents
methodstr"GET"
pathstr"/users/42" (no query string)
versionstr"HTTP/1.1"
paramsmappath parameters, percent-decoded — {"id": "42"}
querymapquery string, percent-decoded — {"page": "2"}
headersmapheader names lowercased — {"content-type": "application/json"}
bodystrthe raw body string

A missing query key reads as None, which serializes to null.

Oxigen
fun show_user(req <map>) {
    id <str> := req.params["id"]
    option {
        has(users, id) -> { {"id": id, "name": users[id], "query": req.query} }
        { api.fail_with(404, "no user {id}") }
    }
}

#Responses

FunctionSignatureContent-Type
jsonjson(data, status = 200)application/json
texttext(body, status = 200)text/plain; charset=utf-8
htmlhtml(body, status = 200)text/html; charset=utf-8
redirectredirect(location, status = 302)— (Location header)
respondrespond(status, body, headers = {})whatever you pass
fail_withfail_with(status, message)application/json

Each returns a Res.

#json(data, status)

JSON response from any Oxigen value — maps, arrays, structs, primitives.

Oxigen
fun create_user(req <map>) { api.json(api.body_json(req), 201) }

Returning a plain map from a handler does the same thing with status 200; use json when you need a different status.

#text(body, status)

Oxigen
fun health(req <map>) { api.text("ok") }

#html(body, status)

Oxigen
fun page(req <map>) { api.html("<h1>hi</h1>") }

#redirect(location, status)

302 by default; pass 301 for a permanent move.

Oxigen
fun go(req <map>) { api.redirect("/") }
fun moved(req <map>) { api.redirect("https://new.example.com", 301) }

#respond(status, body, headers)

The escape hatch when the helpers do not fit — explicit status, body, and headers.

Oxigen
fun custom(req <map>) { api.respond(202, "queued", {"X-Job": "7"}) }
HTTP/1.1 202 Accepted
X-Job: 7
Content-Length: 6

#fail_with(status, message)

A JSON error body under the given status — the same shape unmatched routes and failed handlers return, so clients see one error format.

Oxigen
fun show(req <map>) {
    option {
        has(users, req.params["id"]) -> { users[req.params["id"]] }
        { api.fail_with(404, "no such user") }
    }
}
// {"error":"no such user"}

#Serving files

Handlers can return files from disk as well as values built in memory. This is what makes the module serve a website and not only an API.

FunctionSignatureWhat it does
filefile(path, status = 200)one file, Content-Type from its extension
static_dirstatic_dir(req, root)the request path, resolved under root
content_typecontent_type(path)the Content-Type a path maps to

#file(path, status)

Serves the file with its Content-Type set from the extension, so a browser renders a page instead of showing its source. A missing file, or a directory, is a 404, not a crash.

It returns a FileRes, not a Res: the file is not read into a string but streamed to the client byte for byte when the response is sent, so images, fonts and other binary files arrive intact.

Oxigen
fun home(req <map>) { api.file("site/index.html") }

Paths are relative to the working directory the server was started from.

#static_dir(req, root)

Serves whatever the request asked for, from under root — pages, stylesheets, scripts, images. A path ending in / serves index.html from that directory.

Oxigen
fun assets(req <map>) { api.static_dir(req, "site") }

To serve a whole tree, register it on a route ending in /*, which matches every path under its prefix (the matched tail is in req.params["*"]):

Oxigen
app.get("/*", assets)            // everything not matched by another route
app.get("/static/*", assets)     // only paths under /static/

Routes without a wildcard are tried first, so app.get("/*", ...) does not shadow the rest of your API.

A request path comes from the caller, so it is resolved and then checked to be inside root before anything is read: /../../etc/passwd returns 404. Do not hand-roll this with path.join — join follows PathBuf semantics, where an absolute component discards the base and .. is never resolved, so a joined path can point anywhere on disk. static_dir uses path.normalize to resolve the traversal and path.is_within to decide containment component-wise.

#content_type(path)

The Content-Type file would use. Exposed so you can special-case one:

Oxigen
fun dl(req <map>) {
    api.respond(200, io.read_file("report.csv"), {"Content-Type": "text/csv"})
}

Known: html, htm, css, js, mjs, json, svg, png, jpg, jpeg, gif, webp, ico, woff, woff2, txt, xml, pdf. Anything else is application/octet-stream, which browsers download rather than guess at.


#Reading the request

FunctionSignatureReturns
body_jsonbody_json(req)parsed value
formform(req)map
parse_queryparse_query(s)map
decodedecode(s)str

#body_json(req)

Parse the request body as JSON.

Oxigen
fun echo(req <map>) { {"you_sent": api.body_json(req), "bytes": len(req.body)} }

A malformed body makes this raise, which the dispatcher turns into a 500. To answer 400 instead, normalize it:

Oxigen
fun echo(req <map>) {
    parsed := <type<Error> || <Value>>(api.body_json(req))
    option {
        is_error(parsed) -> { api.fail_with(400, "body must be JSON") }
        { parsed.value }
    }
}

#form(req)

Parse the body as an HTML form (application/x-www-form-urlencoded). Keys and values are percent-decoded.

Oxigen
fun submit(req <map>) { api.form(req) }
$ curl -X POST localhost:8000/submit -d 'a=1&b=hello%20world'
{"a":"1","b":"hello world"}

#parse_query(s)

Parse a query string ("a=1&b=hello%20world") into a map. req.query is already parsed for you; this is for query strings from elsewhere.

A key with no = gets ""; a value containing = keeps everything after the first one.

#decode(s)

Percent-decode, falling back to the raw text on a malformed escape. A client controls these strings, so a bad %ZZ must not put an error value where a handler expects a string. Used internally for path parameters and query values.


#Recipes

Every handler below is complete and typed, and every response shown was produced by running it. They all register the same way:

Oxigen
fun routes(cfg <map>) {
    app := api.app(cfg)
    app.get("/users/:id", show_user)      // ... and so on
    app.listen()
}

#Read a path parameter

Oxigen
users <map> = {"1": "ada", "42": "grace"}

fun show_user(req <map>) {
    id <str> := req.params["id"]
    option {
        has(users, id) -> { {"id": id, "name": users[id]} }
        { api.fail_with(404, "no user {id}") }
    }
}
$ curl localhost:8000/users/42          # app.get("/users/:id", show_user)
{"id":"42","name":"grace"}

$ curl localhost:8000/users/9
{"error":"no user 9"}

Values are always strings — convert with int(...) if you need a number.

#Two path parameters

Oxigen
fun user_post(req <map>) { {"user": req.params["id"], "post": req.params["post_id"]} }
$ curl localhost:8000/users/42/posts/7  # app.get("/users/:id/posts/:post_id", user_post)
{"user":"42","post":"7"}

#Read the query string, with a default

req.query always exists; the keys in it do not, so guard with has rather than dot access.

Oxigen
fun search(req <map>) {
    page <str> := option {
        has(req.query, "page") -> { req.query["page"] }
        { "1" }
    }
    {"q": req.query["q"], "page": int(page)}
}
$ curl 'localhost:8000/search?q=hi%20there&page=3'
{"q":"hi there","page":3}

$ curl 'localhost:8000/search?q=solo'
{"q":"solo","page":1}

Percent-decoding is done for you.

#Accept a JSON body and answer 201

Oxigen
fun create_user(req <map>) {
    body := <type<Error> || <Value>>(api.body_json(req))
    option {
        is_error(body) -> { api.fail_with(400, "body must be JSON") }
        { api.json({"created": body.value}, 201) }
    }
}
$ curl -X POST localhost:8000/users -d '{"name":"ada"}'
{"created":{"name":"ada"}}

$ curl -X POST localhost:8000/users -d 'nope'
{"error":"body must be JSON"}

Without the <type<Error> || <Value>> wrapper a malformed body raises, and the dispatcher turns that into a 500. Wrapping it is what makes it a 400.

#Accept an HTML form

Oxigen
fun submit(req <map>) { api.json({"form": api.form(req)}) }
$ curl -X POST localhost:8000/submit -d 'a=1&b=hello%20world'
{"form":{"a":"1","b":"hello world"}}

#Read a request header

Header names are lowercased, and content-type is not an identifier, so these are bracket lookups.

Oxigen
introduce strings

fun secret(req <map>) {
    auth <str> := option {
        has(req.headers, "authorization") -> { req.headers["authorization"] }
        { "" }
    }
    option {
        strings.starts_with(auth, "Bearer ") -> { {"token": strings.replace(auth, "Bearer ", "")} }
        { api.fail_with(401, "missing bearer token") }
    }
}
$ curl -H 'Authorization: Bearer abc123' localhost:8000/secret
{"token":"abc123"}

$ curl localhost:8000/secret
{"error":"missing bearer token"}

#Inspect the request itself

Oxigen
fun index(req <map>) { {"service": "oxigen", "method": req.method, "path": req.path} }
$ curl localhost:8000/
{"service":"oxigen","method":"GET","path":"/"}

#Build a website

A whole site from .html files on disk — the runnable version is example/website_server.oxi.

Oxigen
introduce api

fun home(req <map>) { api.file("site/index.html") }
fun assets(req <map>) { api.static_dir(req, "site") }

fun routes(cfg <map>) {
    app := api.app(cfg)
    app.get("/", home)
    app.get("/:page", assets)      // /about.html
    app.get("/css/:file", assets)  // /css/style.css
    app.listen()
}

main { api.serve(routes, port=8000) }

api.file and api.static_dir set the Content-Type from the file extension, so pages render and stylesheets apply. Returning a bare string does not:

Oxigen
fun page(req <map>) { "<h1>hi</h1>" }        // text/plain — browser shows the source
fun page(req <map>) { api.html("<h1>hi</h1>") }  // text/html — browser renders it

A handler that returns a plain string gets text/plain, because a string is far more often a message than markup. Use api.html for inline markup and api.file for a page on disk.

#Non-JSON responses

Oxigen
fun health(req <map>) { api.text("ok") }

fun page(req <map>) { api.html("<h1>hi</h1>") }

fun old(req <map>) { api.redirect("/", 301) }

fun queued(req <map>) { api.respond(202, "queued", {"X-Job": "7"}) }
$ curl -i localhost:8000/old
HTTP/1.1 301 Moved Permanently
Location: /

$ curl -i -X POST localhost:8000/jobs
HTTP/1.1 202 Accepted
X-Job: 7
Content-Length: 6

#Concurrency and limits

This is a thread-per-connection server: serve runs workers accept loops on real OS threads over one shared listening socket, and a connection holds its worker until it closes. So workers is the number of connections served at once — size it for the clients you expect connected simultaneously, not for core count.

Keeping connections alive is worth roughly double the throughput and is the default. Two limits keep that from turning into a stuck server when clients outnumber workers:

  • idle_timeout (5s) reclaims a worker whose client went quiet.
  • keep_alive_max (100 requests) closes a connection so a busy client's worker returns to the queue.

Over capacity the server slows down; it does not stop accepting. Pass keep_alive=False to answer one request per connection instead — concurrency then caps at workers requests rather than workers clients.

idle_timeout bounds each individual read, so it cannot stop a client that keeps sending one byte at a time (slowloris). The request as a whole is bounded too: its head must arrive within header_timeout (10s) and its body within body_timeout (60s), or the client gets a 408 and the connection is closed.

A client cannot make the server allocate without bound:

LimitValueResponse when exceeded
head_limit64 KiB431 request headers too large
body_limit8 MiB413 request body too large
Content-Length that is not plain digits—400 invalid Content-Length
body ends before its Content-Length—400; the handler never sees a truncated body
Transfer-Encoding (chunked)—501; send a Content-Length instead
head or body slower than header_timeout/body_timeout10s / 60s408

Header names and values are written with any CR, LF or NUL replaced by a space, so a handler that puts client input in a header (a redirect target from the query string, say) cannot be used to inject headers or a body.

Not implemented: request pipelining, chunked request bodies, TLS (terminate it at a proxy), and multipart uploads. Binary request bodies are read as UTF-8 text (invalid bytes become U+FFFD); binary responses are fine through file.


#Structs and constants

#Struct: Res

An HTTP response.

FieldTypeMeaning
statusintstatus code
headersmapresponse headers
bodystrresponse body

Build one with the response helpers rather than by hand. If you do need the constructor, import the struct by name — a struct is not constructible through a module namespace, so api.Res(...) fails with struct 'Res' is not defined in this scope:

Oxigen
introduce api
introduce {Res} from api

fun raw(req <map>) { Res(200, {"X-A": "1"}, "hi") }

#Struct: FileRes

A file response, returned by file and static_dir.

FieldTypeMeaning
statusintstatus code
headersmapresponse headers
pathstrthe file to stream
sizeintits size in bytes, sent as Content-Length

#Struct: App

A router plus the socket it serves. Each worker thread gets its own.

FieldTypeMeaning
serverintlistening socket handle
routesmapstatic routes, keyed "METHOD /path"
dynamicarrayparameterized routes as (method, segments, handler)
keep_aliveboolwhether connections are reused
idle_timeoutintper-read timeout in ms
header_timeoutintdeadline for a request's head in ms
body_timeoutintdeadline for a request's body in ms

#Module constants

ConstantValueMeaning
head_limit65536max request head size before a 431
body_limit8388608max body size before a 413
read_size65536socket read chunk size
accept_retry_limit100consecutive accept failures before listen gives up
keep_alive_max100requests one kept-alive connection may serve

These are module-level bindings, readable as api.body_limit. Changing them means editing stdlib/api.oxi.


#Internals

Everything below is public. Oxigen has hide fun for module-private helpers (see imports.md), but these are deliberately left exposed: tests/api_test.oxi exercises render, to_res, parse_query and wants_keep_alive directly as pure functions, which is far cheaper than driving them through a socket.

You do not need any of it to write a server — it is documented so that reading a stack trace, or extending the module, is not guesswork.

#Connection handling

FunctionSignaturePurpose
app.serve_connserve_conn(conn)Serve one connection: read, route, respond, repeat while kept alive
app.dispatchdispatch(req)Find the handler, run it contained, and normalize the result to a Res
read_requestread_request(conn, header_ms, body_ms)Read one request. Returns a request map, a Res to send back when the request is unusable, or None when the peer closed
read_bodyread_body(conn, so_far, length, body_ms)Read the rest of the body, collecting chunks and joining once; a Res (400 or 408) when it ends early or arrives too slowly
content_lengthcontent_length(headers)Content-Length as an Error || Value; absent means 0, anything but plain digits is an error
wants_keep_alivewants_keep_alive(req)HTTP/1.1 keeps the connection open unless told otherwise; HTTP/1.0 is the reverse

#Routing

FunctionSignaturePurpose
compile_pathcompile_path(path)Split a route path into (is_param, name) segments once, at registration
match_segmentsmatch_segments(segments, segs)Match one compiled route against a request's segments; returns the captured params or None. A trailing * segment matches any tail
match_dynamicmatch_dynamic(dynamic, method, segs)Scan the parameterized routes; returns (handler, params) or (None, {})
allowed_methodsallowed_methods(routes, dynamic, path)Every method registered for a path
missmiss(routes, dynamic, path)The 404-or-405 answer for an unmatched request
to_resto_res(v)Normalize whatever a handler returned into a Res (a FileRes passes through)

#Wire format

FunctionSignaturePurpose
renderrender(res, live)Serialize a response as one string, sent in a single write, so the status line and body never land in separate TCP segments
render_headrender_head(status, headers, length, live)The status line and headers through the blank line, with CR/LF/NUL in headers replaced by spaces
header_safeheader_safe(text)A header name or value with CR, LF and NUL replaced by spaces
send_ressend_res(conn, res, live)Send a Res, or a FileRes (head, then the file's bytes)
reasonreason(status)Reason phrase for a status code; anything unlisted renders as "OK", which clients ignore

reason covers 200, 201, 202, 204, 301, 302, 304, 400, 401, 403, 404, 405, 408, 409, 413, 422, 429, 431, 500, 501, 503.


#Worked example

A small JSON API with path parameters, a JSON body, a deliberate failure, and an error response — the same shape as example/api_server.oxi:

Oxigen
introduce api

users <map> = {"1": "ada", "42": "grace"}

fun index(req <map>) {
    {"service": "oxigen api", "routes": ["/", "/users/:id", "/echo"]}
}

fun show_user(req <map>) {
    id <str> := req.params["id"]
    option {
        has(users, id) -> { {"id": id, "name": users[id], "query": req.query} }
        { api.fail_with(404, "no user {id}") }
    }
}

fun echo(req <map>) {
    {"you_sent": api.body_json(req), "bytes": len(req.body)}
}

fun boom(req <map>) { <fail>("this handler is broken on purpose") }

fun routes(cfg <map>) {
    app := api.app(cfg)
    app.get("/", index)
    app.get("/users/:id", show_user)
    app.post("/echo", echo)
    app.get("/boom", boom)
    app.listen()
}

main { api.serve(routes, port=8000) }
$ curl localhost:8000/users/42
{"id":"42","name":"grace","query":{}}

$ curl -X POST localhost:8000/echo -d '{"hello":"world"}'
{"you_sent":{"hello":"world"},"bytes":17}

$ curl -i localhost:8000/boom
HTTP/1.1 500 Internal Server Error
{"error":"internal server error"}

See also: net for the sockets underneath and for calling other services, json for bodies, concurrency.md for why routes are registered inside a function.