web

A small HTTP/1.1 framework over jdk.httpserver: routes are data, handlers are fn(Request) -> Result[Response, HttpError] !io, middleware are handler transformers.

use web/types.{Request, Response, HttpError, text, param}
use web/router.{route_get}
use web/server.{serve_app}
use web/middleware.{with_logging}

fn hello(req: Request) -> Result[Response, HttpError] !io = {
  let name = param(req, "name")?
  Ok(text(200, "hello, ${name}\n"))
}

pub fn main() -> Unit !io =
  serve_app(8001, [route_get("/hello/{name}", hello)], [with_logging])

TLS is terminated in front of it (nginx or similar).

Paths

Routing runs on the raw request path: it is split on / first and each segment is then percent-decoded on its own, so /files/a%2Fb reaches /files/{name} with name = "a/b". Request.path is the whole path decoded at once: a decoded %0A is a line break, so log Request.raw_path, as with_logging does.

PathWhat happens
A dot segment (/a/../b, /a/./b, %2e%2e)400 before routing, on every path. Nothing is normalized.
A doubled slash (/a//b)Kept: three segments, which do not match /a/b. A capture matches an empty segment; a literal does not.
A trailing slash (/a/)Matches the same route as /a.

param(req, name) reads a {name} capture. A trailing {name*} capture is a list of segments and is read with param_segs; param refuses it with a 500, so a caller that wants one string joins the segments itself.

A capture is not a file path. The 400 above covers a segment that is . or .., but each segment is decoded on its own, so ..%2F.. reaches the handler as the one segment ../... To turn captures into a path under some base directory, use safe_rel(segs)?: it refuses (400) any segment that is empty, . or .., or contains /, \ or NUL, and joins the rest with /. For a single capture, safe_rel([param(req, name)?])?. Never locate a file with Request.path or Request.raw_path.

Requests

Request.headers, Request.query and parse_form's result are multimaps (Map[String, List[String]]) that keep every repeated value in wire order. Header names are lowercased on both sides.

readthe first valueall of them
headerheader(req, name)headers_all(req, name)
queryquery(req, name)query_all(req, name)
formform_value(f, name)form_all(f, name)

Request.body is the bytes as they arrived. body_text(req)? is the UTF-8 view, and a malformed body is a 400 naming the byte offset. A route tagged stream-body gets its body spilled to a temp file in body_file and an empty body.

Body ceilings and guards

ServerConfig.max_body is the server's ceiling, a positive byte count (DEFAULT_MAX_BODY unless set). A route can change it or check a request before any of the body is read:

let tags = ["raw-body", "no-cors", "stream-body"]
let put = route_put("/dav/{rest*}", put_file)
  .tagged(tags)
  .body_limit(4294967296)
  .guarded(check_credentials)

Responses

Response is opaque. Build one with text, json_response, json_ok, raw, binary, streaming, redirect, attachment or error_response, add headers with with_header, and read it back with response_status, response_content_type, response_headers and response_body. A constructor refuses what HTTP cannot carry with a panic, which the server answers as a 500:

CheckRule
status200..599
header namesan HTTP token
header values and content typesASCII only: SP, HTAB and ! to ~
Transfer-Encodingnever set by a handler; the body kind decides the framing
Content-Lengthonce, and equal to the body's length when the body has content (a HEAD answer may state any length); never on a stream of unknown length

For a header value built from request input, try_with_header and try_redirect answer 400 instead of panicking. Non-ASCII text has to be encoded first:

# a Location: percent-encode the path
let r = try_redirect(302, "/files/caf%C3%A9")?
# a download name: attachment writes filename= and filename*= itself
let d = attachment("text/plain", "\u{4e2d}\u{6587}.txt", body)

streaming(status, content_type, stream) is chunked. With length: Some(n) it sends Content-Length: n instead, and a stream that ends short is logged and the connection is closed before n, so the client can tell.

An HttpError whose status is outside 200..599 is answered as the neutral 500, keeping its headers.

Server

start(cfg, routes, middleware) binds and answers a ServerHandle: join waits on it, stop ends it, handle_port reads the bound port (for port: 0). serve_app(port, routes, middleware) and serve_app_with(cfg, routes, middleware) start and join in one call. start refuses a route table it cannot dispatch (a method that is not an uppercase token, a bad pattern, a route an earlier one shadows) and a max_body that is not positive, before it binds. A handler that panics, or fails with a JVM Error, gets a 500 if nothing was sent yet, and the exchange is always closed.

CORS

with_cors answers a preflight itself: an OPTIONS that carries both Origin and Access-Control-Request-Method. Any other OPTIONS reaches the application's own route. Every other response is stamped with the Access-Control-* headers, error responses and the server's own early 400 and 413 included, unless the route is tagged no-cors.

Error wording

Every error body the framework writes itself is worded by an ErrorFormat (default_errors() is neutral English):

fieldused fordefault
detail_keythe JSON key of every error body{"error": "..."}
internal_messageits own 500"internal server error"
param_separatorbetween a parameter's name and the complaint in query_int_bounded's 422"size: Input should be ..."
body_too_largethe 413, given the ceiling in bytes"request body exceeds N bytes"
not_foundthe 404"Not Found"
method_not_allowedthe 405"Method Not Allowed"
dot_segmentthe 400 for a dot segment"path contains a dot segment"

Set the fields to change and spread the rest:

let site = ErrorFormat {
  ..default_errors(),
  detail_key: "detail",
  body_too_large: n => "upload limit is ${n} bytes",
}
let cfg = ServerConfig {
  host: "127.0.0.1", port: 8001, max_body: DEFAULT_MAX_BODY, errors: site,
}
serve_app_with(cfg, routes, middleware)

and pass the same value as fmt: where the application renders errors itself: error_response(e, fmt: site), query_int_bounded(req, "page", 1, 1, -1, fmt: site).

Changes between versions, with migration notes: CHANGELOG.md.

API

web/middleware

middleware.with_logging

fn with_logging(next: fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io

One access-log line per request: method, path, status, wall time. The path is the raw one, still percent-encoded, so a request cannot write a line break into the log.

middleware.with_body_limit

fn with_body_limit(limit: Int, next: fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io

Reject bodies over a byte budget (the bytes, not the code points) before the handler sees them. limit must be positive; anything else panics when the middleware is built. Routes tagged "raw-body" opt out, since an upload is bounded in front, by nginx. A policy check, not a memory guard: the server's own ceiling (server.DEFAULT_MAX_BODY) bounds the allocation.

middleware.with_cors

fn with_cors(origins: String, next: fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io

CORS: answer a browser preflight directly, and stamp Access-Control-* on every other answer — the error ones included, or the browser will not let the page read them. Only a real preflight is answered here; any other OPTIONS is an ordinary request and reaches the application's routes.

web/router

Route

type Route = { method: String, pattern: String, tags: List[String], handler: fn(Request) -> Result[Response, HttpError] !io, body_limit: Option[Int], guard: Option[fn(Request) -> Result[Unit, HttpError] !io] }

One route in a table: the HTTP method it answers, the path pattern it matches (literal segments plus {name} / trailing {name*} captures), the tags middleware read, and the handler to run. Build one with route_get and friends, or route_method_of for an arbitrary verb.

router.route_get

fn route_get(pattern: String, handler: fn(Request) -> Result[Response, HttpError] !io) -> Route

A GET route for pattern, with no tags, no body ceiling of its own and no guard (the same holds for the constructors below).

router.route_post

fn route_post(pattern: String, handler: fn(Request) -> Result[Response, HttpError] !io) -> Route

A POST route for pattern, with no tags.

router.route_put

fn route_put(pattern: String, handler: fn(Request) -> Result[Response, HttpError] !io) -> Route

A PUT route for pattern, with no tags.

router.route_delete

fn route_delete(pattern: String, handler: fn(Request) -> Result[Response, HttpError] !io) -> Route

A DELETE route for pattern, with no tags.

router.route_method_of

fn route_method_of(verb: String, pattern: String, handler: fn(Request) -> Result[Response, HttpError] !io) -> Route

A route for any HTTP verb (OPTIONS/PROPFIND/MKCOL/MOVE/COPY/LOCK/... for WebDAV), with no tags. The verb is compared uppercase-exact, same as the sugar constructors above.

router.tagged

fn tagged(r: Route, tags: List[String]) -> Route

Attach tags to a route (records are immutable — returns a new Route). Tags accumulate, so tagged(tagged(r, ["a"]), ["b"]) carries both.

router.body_limit

fn body_limit(r: Route, n: Int) -> Route

Give a route its own body ceiling, n bytes (a positive count; anything else panics, as start does for max_body). On a route read into memory it replaces the server's max_body, in either direction; on a "raw-body" route it bounds what was otherwise unbounded; on a "stream-body" route the spill is counted and stopped one byte past n. Over the ceiling is a 413, and a spilled body's temp file is deleted.

router.guarded

fn guarded(r: Route, g: fn(Request) -> Result[Unit, HttpError] !io) -> Route

Run g before this route's body is read. It sees the Request without the body (captures, query and headers included); Err(e) is answered as e, through the middleware chain, with no byte of the body read and no temp file made. A later guarded replaces an earlier one: one route, one guard, and a guard that wants two checks makes both.

web/server

ServerConfig

type ServerConfig = { host: String, port: Int, max_body: Int, errors: ErrorFormat }

Where the server listens, the in-memory body ceiling its requests get (a positive byte count; start panics on anything else, since there is no "unbounded" — routes that need more opt out with the "raw-body" or "stream-body" tag), and the wording of the errors it renders itself. port 0 asks the OS for a free port; handle_port reports the one actually bound. default_errors() is the neutral wording — an application whose clients expect another error shape states it here (web/types.ErrorFormat).

ServerHandle

type ServerHandle

A running server, as start returns it: join blocks on it, stop ends it, and handle_port reads the port it actually bound (the point of port 0).

server.DEFAULT_MAX_BODY

const DEFAULT_MAX_BODY: Int

The default ceiling on a request body read into memory, in bytes (8 MiB).

server.parse_form

fn parse_form(body: String) -> Map[String, List[String]] !io

An application/x-www-form-urlencoded body as a multimap: every value of a repeated name survives in wire order, the same shape Request.query and Request.headers have. The argument is the body's UTF-8 view — a handler gets it with body_text(req)?. Read one value with form_value, all of them with form_all. It used to be Map[String, String], where a multi-select (the reason HTML forms repeat a name at all) silently kept only its last option.

server.form_value

fn form_value(form: Map[String, List[String]], name: String) -> Option[String]

One form field, or None when absent. Of a repeated name this is the first value, matching web/types.query and web/types.header.

server.form_all

fn form_all(form: Map[String, List[String]], name: String) -> List[String]

Every value of a repeated form field, in wire order; [] when absent.

server.handle_port

fn handle_port(h: ServerHandle) -> Int

The port the server actually bound — with port 0 in the config, the one the OS picked.

server.start

fn start(cfg: ServerConfig, routes: List[Route], middleware: List[fn(fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io]) -> ServerHandle !io

Bind cfg.host:cfg.port, serve requests on virtual threads, and return at once with the handle — the caller decides what the program does next (join to block, stop from wherever shutdown is decided). A broken route table panics here, before the socket is bound.

server.join

fn join(h: ServerHandle) -> Unit !io

Block until stop() releases this handle. A server's main() usually ends here — returning would let the JVM exit out from under the request threads.

server.stop

fn stop(h: ServerHandle, grace_secs: Int) -> Unit !io

Stop the server: the listening socket closes at once, in-flight exchanges get up to grace_secs to finish (the JDK's stop blocks for that), the request executor is shut down, and then every join() blocked on this handle returns. Every one of those happens even if the JDK's own stop throws.

server.serve_app

fn serve_app(port: Int, routes: List[Route], middleware: List[fn(fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io]) -> Unit !io

The old shape, kept: start + join on 127.0.0.1, default body ceiling, default error wording. Most programs want exactly this.

server.serve_app_with

fn serve_app_with(cfg: ServerConfig, routes: List[Route], middleware: List[fn(fn(Request) -> Result[Response, HttpError] !io) -> fn(Request) -> Result[Response, HttpError] !io]) -> Unit !io

start + announce + join under a config the caller owns — the entry point for a program that configures anything (host, error wording) and still wants main() to end by blocking.

web/types

impls registered by this module (visible program-wide, no use needed): Show[RouteMeta], Show[Request], Show[HttpError]

RouteMeta

type RouteMeta = { method: String, pattern: String, tags: List[String], body_limit: Option[Int] }

The matched route's metadata, handed to middleware so they can dispatch on tags instead of hardcoding paths. None on a Request means no route matched (404/405) — middleware treats an absent route as "no tags". body_limit is the route's own body ceiling (see router.body_limit), None when the route inherits the server's.

Request

type Request = { method: String, path: String, raw_path: String, params: Map[String, List[String]], query: Map[String, List[String]], headers: Map[String, List[String]], body: Bytes, body_file: Option[String], route: Option[RouteMeta] }

One request, as the server parsed it off the wire. Read it with the accessors below (param, query, header) rather than by hand: they carry the lookup rules (case-insensitive names, first value of a repeated one).

ResponseBody

type ResponseBody = Empty | Text(value: String) | Binary(value: Bytes) | Stream(value: InputStream, length: Option[Int])

What a Response carries: nothing, text, bytes, or a live upstream stream. The kind also decides the framing — Text and Binary get an exact Content-Length, a Stream an exact one when length is Some and chunked framing when it is None.

Response

type Response

One response: status, content type, extra headers in emission order, and a body kind. Build it with the constructors below (text, json_ok, raw, binary, streaming), add headers with with_header, and read it with response_status, response_content_type, response_headers and response_body. It is opaque: there is no literal, so there is no way to build one that skipped the checks those constructors make.

HttpError

type HttpError = { status: Int, message: String, code: Option[String], headers: List[(String, String)] }

The Err branch of a handler: the status the server will render, the message it will carry, an optional machine-readable code, and headers that ride onto the rendered response (a 401's WWW-Authenticate challenge).

Handler

type Handler

What every route's function is: a request in, a response or an HttpError out. Aliases are transparent — this is documentation, not a new type.

Guard

type Guard

A pre-body check (see router.guarded): Ok(()) lets the body be read and the handler run; Err(e) answers e without reading a byte of the body.

Middleware

type Middleware

A handler transformer. serve_app composes a list of them with the first element outermost.

ErrorFormat

type ErrorFormat = { detail_key: String, internal_message: String, param_separator: String, body_too_large: fn(Int) -> String, not_found: String, method_not_allowed: String, dot_segment: String }

How the framework words the errors it renders itself. detail_key is the JSON object key its error body uses ({"error": message}); internal_message is the body of the 500 it renders when a handler panics; param_separator goes between a parameter's name and the complaint in a 422 from query_int_bounded. body_too_large words the 413 for a request body over its ceiling and is given that ceiling in bytes; not_found and method_not_allowed are the 404 and 405 bodies for a request no route takes; dot_segment is the 400 for a path with a "." or ".." segment.

types.response_status

fn response_status(r: Response) -> Int

The status the response will be sent with (200..599).

types.response_content_type

fn response_content_type(r: Response) -> String

The value the server sends as Content-Type.

types.response_headers

fn response_headers(r: Response) -> List[(String, String)]

The extra headers, in the order with_header added them.

types.response_body

fn response_body(r: Response) -> ResponseBody

The body the server will send, by kind.

types.http_error

fn http_error(status: Int, message: String) -> HttpError

An HttpError with no code and no headers — the shape almost every call site wants.

types.as_http

fn as_http[T](r: Result[T, String], status: Int) -> Result[T, HttpError]

Lift a repository/infrastructure Result[T, String] into a handler's Result[T, HttpError] under one status, so ? can cross the layer.

types.as_http_with

fn as_http_with[T](r: Result[T, String], f: fn(String) -> HttpError) -> Result[T, HttpError]

The same lift with the status chosen from the message, for repository errors that are client errors in disguise (a duplicate slug is a 409).

types.text

fn text(status: Int, body: String) -> Response

A text/plain; charset=utf-8 response. Every constructor refuses a status outside 200..599 with a panic: a final response is never 1xx here, since nothing in this framework sends an interim one.

types.json_response

fn json_response(status: Int, j: Json) -> Response

A rendered JSON response (application/json; charset=utf-8) at any status in 200..599.

types.json_ok

fn json_ok(j: Json) -> Response

json_response at 200 — the ordinary success shape of a JSON API.

types.raw

fn raw(status: Int, content_type: String, body: String) -> Response

A raw pre-rendered text body under any content type — the escape hatch for a wire format this module has no constructor for (XML, HTML, WebDAV). A content type that cannot travel as a header value panics, as with_header does.

types.binary

fn binary(status: Int, content_type: String, raw: Bytes) -> Response

A binary body: raw Bytes written verbatim, with an exact Content-Length.

types.streaming

fn streaming(status: Int, content_type: String, stream: InputStream, length: Option[Int] = None) -> Response

A streaming body: a live InputStream pumped to the socket in constant memory. The server closes the stream after pumping, including when the client disconnects mid-download.

With length absent the body is chunked, and that is a limit, not just a framing choice: an upstream that ends early with a clean EOF is indistinguishable here from one that delivered everything, so a truncated object is served as a complete one. A failed read is caught and logged (web/server.pump_stream); a short but orderly one cannot be, since telling them apart needs a length to compare against.

With length: Some(n) the body is framed with an exact Content-Length instead, so the client knows how much is coming (a progress bar, a range it can resume) and can tell a short body from a complete one. The server counts what it pumped: an upstream that ends early is logged as a truncation, and the connection ends short of the promised length, which is how the client learns of it; one that runs long is cut off by the JDK's fixed-length stream and logged as a failed transfer. A negative length panics.

types.valid_header_value

fn valid_header_value(v: String) -> Bool

Whether v can travel as an HTTP header field value: SP, HTAB and visible ASCII (! to ~). CR, LF, the other C0 controls, DEL and every non-ASCII character are refused; encode non-ASCII text before it becomes a header.

types.valid_header_name

fn valid_header_name(n: String) -> Bool

Whether n is a header field name: a non-empty RFC 9110 §5.1 token. A name carrying its own :, a space or a control would inject a field of its own.

types.escape_field

fn escape_field(s: String) -> String

Render a header string that was refused, for a report that is read somewhere else. What is escaped is exactly what valid_header_value refuses, so this only ever rewrites a character the caller has already been told cannot travel; SP and HTAB are field-content and stay as they are.

Naming a rejected string is not the same as passing it through. CR and LF are why the string was refused in the first place, and the report goes on to be a line in a log (with_header panics, and the server writes request panicked: <message>) or a 400 body: writing them verbatim is the same injection one layer down, against whoever reads the report rather than against the client. The other C0 controls are no better, an ESC reaching the terminal that is tailing the log being the same trick in the terminal's language. Since 6.1 a non-ASCII character is refused too, and is written here as \u{...} like the controls.

types.with_header

fn with_header(r: Response, name: String, value: String) -> Response

Append one response header. Records are immutable, so this returns a new Response.

A name that is not a token, or a value carrying a character that cannot travel in a field value, panics: this is the constructor for a header the program builds out of its own strings, and one it cannot send is a bug in the caller. (Per-request panic isolation turns it into a 500, not a dropped connection.) For a name or value derived from request input, either check it first with valid_header_name/valid_header_value or use try_with_header, which answers 400 instead.

The framing headers are the server's: Transfer-Encoding is always refused, and Content-Length must be a decimal byte count equal to the body's length (any count on a body with no content, which is how a HEAD answer states the length of what it does not carry; never on a stream of unknown length), once.

types.try_with_header

fn try_with_header(r: Response, name: String, value: String) -> Result[Response, HttpError]

with_header for a name or value derived from request input: an illegal one is a 400 the handler can propagate with ?, not a panic and never a silent edit of the value.

types.redirect

fn redirect(status: Int, location: String) -> Response

A redirect: status (301/302/303/307) plus a Location header, empty body. A Location that cannot travel as a field value panics (see with_header); try_redirect is the form for a target built from request input.

types.try_redirect

fn try_redirect(status: Int, location: String) -> Result[Response, HttpError]

redirect for a target built from request input: an illegal Location is a 400 rather than a panic.

types.attachment

fn attachment(content_type: String, filename: String, body: String) -> Response

Force a download with a given filename: Content-Disposition with both filename= (quoted ASCII) and filename*= (RFC 5987 UTF-8), so a name with quotes or non-ASCII characters arrives intact and cannot inject parameters.

types.default_errors

fn default_errors() -> ErrorFormat

The neutral defaults: {"error": ...} bodies, "internal server error" for a panic, "name: Input should be ..." for a rejected query parameter, and the framework's own refusals in plain English ("request body exceeds N bytes", "Not Found", "Method Not Allowed", "path contains a dot segment").

types.error_response

fn error_response(e: HttpError, fmt: ErrorFormat = default_errors()) -> Response

The server's rendering of the Err branch and of its own 404/405, worded by fmt (the neutral default_errors() unless the application configured something else). The error's own headers ride along (through the with_header guard), so a 401 can carry its WWW-Authenticate challenge. An error whose status is outside 200..599 renders as a 500 with fmt.internal_message.

types.param

fn param(req: Request, name: String) -> Result[String, HttpError]

A path parameter captured by a {name} pattern segment. Missing means the route table and the handler disagree, which is a programming error, hence

  1. So does a capture that matched anything but exactly one segment: that is

a trailing {name*}, whose value is a path and must be read with param_segs, since joining it here is what LIB-14 was about.

types.param_segs

fn param_segs(req: Request, name: String) -> List[String]

A trailing {name*} capture, as the path segments it matched: each decoded on its own, none of them joined. [] when the capture matched nothing (the bare prefix) and also when the name is absent. Joining these with "/" is the old behavior and is the caller's decision now: a segment may legitimately contain a "/" (it arrived as %2F), and only the caller knows whether that distinction matters to it.

types.safe_rel

fn safe_rel(segs: List[String]) -> Result[String, HttpError]

Captured path segments as one relative file path, or a 400. Each segment must be non-empty and must not be . or .. or contain /, \ or NUL; the segments are then joined with /. [] is "", the base directory itself.

The server refuses a request whose segment is . or .., but a segment is decoded on its own, so ..%2F.. arrives as the one segment ../.., which no whole-segment check sees. Joining captures into a file path is where that segment becomes a traversal, so this is the way to do it.

types.param_int

fn param_int(req: Request, name: String) -> Result[Int, HttpError]

param parsed as an integer; a non-numeric capture is a 400.

types.opt_param_int

fn opt_param_int(req: Request, name: String) -> Option[Int]

A path parameter as an Option rather than a Result, for handlers that word their own error instead of taking the one param_int picks.

types.query

fn query(req: Request, name: String) -> Option[String]

One query-string parameter, exactly as it was decoded; None when absent. Of a repeated name this is the first value, the same rule as header.

types.query_all

fn query_all(req: Request, name: String) -> List[String]

Every value of a repeated query parameter, in wire order; [] when the name is absent. ?tag=a&tag=b is how a client spells a set, and until the query map became a multimap only the last one survived.

types.query_int_bounded

fn query_int_bounded(req: Request, name: String, default: Int, lo: Int, hi: Int, fmt: ErrorFormat = default_errors()) -> Result[Int, HttpError]

Strict pagination reader in the shape of FastAPI's Query(ge=lo, le=hi): an absent param yields the default; a present one that is non-integer or out of [lo, hi] is a 422. hi < 0 means no upper bound (FastAPI's page is ge=1 with no le). fmt.param_separator joins the parameter's name to the complaint — FastAPI's own error handler uses a fullwidth colon there, which is why the separator is configurable rather than written in. Without fmt the wording is default_errors() ("size: Input should be less than or equal to 100").

types.header

fn header(req: Request, name: String) -> Option[String]

A request header, looked up case-insensitively (the name is lowercased here; the server lowercases keys when building the map). Of a repeated name this is the first value.

types.headers_all

fn headers_all(req: Request, name: String) -> List[String]

Every value of a repeated header, in wire order; [] when the name is absent. Same case-insensitive lookup as header.

types.body_text

fn body_text(req: Request) -> Result[String, HttpError]

The body decoded as UTF-8, or a 400 naming the byte offset of the first malformed sequence — never a silent U+FFFD rewrite. A caller that truly wants the lossy view still has bytes.decode_utf8_lossy(req.body).