Skip to content

web

The web package defines how a Kiwi web application talks to the server that runs it, in the spirit of Ruby’s Rack. Because every app follows the same small contract, apps, middleware and servers can be combined freely: a router from one place, a logger from another, mounted side by side under different paths.

The contract has three parts:

  • An app is anything you can call with an environment hashmap: a lambda, a named function, or an instance of a struct that defines call (see callable instances).
  • The app returns a response: a list [status, headers, body].
  • Middleware is a function that takes an app and returns a new app that wraps it.
app = with (env) do
[200, {"content-type": "text/plain"}, "Hello from ${env["path_info"]}"]
end
r = web::request(app, "GET", "/kiwi") # call the app directly, no network
println r.status() # 200
println r.body() # Hello from /kiwi

web::request is the test client: it builds an environment, calls the app and collects the response, so apps can be tested without a server. To serve the same app over HTTP, pass it to web::serve(app, {"port": 8080}); see Serving.

Each request gets its own hashmap:

KeyValue
methodThe HTTP method, uppercase.
script_nameThe path the app is mounted at, or "" at the root.
path_infoThe rest of the path, percent-decoded, starting with / (or "" when the request is for the mount point itself).
query_stringThe raw query string, without ?.
headersRequest headers, with lowercase names.
bodyThe request body, as bytes.
remote_addr, scheme, protocolThe client’s address, "http" or "https", and the protocol, such as "HTTP/1.1".
server_name, server_portWhere the server listens.

Middleware may add its own keys, prefixed with web. by convention: web::request_id() adds web.request_id. web::Request wraps an environment with parsing helpers, and caches what it parses in the environment so every layer shares it:

app = with (env) do
req = web::Request.new(env)
web::json_response({
"path": req.path(),
"q": req.query_param("q"),
"tags": req.query_all("tag"),
"theme": req.cookie("theme"),
"body": req.json()
})
end
r = web::request(app, "POST", "/search?q=kiwi&tag=a&tag=b", {
"headers": {"Cookie": "theme=dark"},
"body": {"page": 2}
})
println r.json() # {"path": "/search", "q": "kiwi", "tags": ["a", "b"], "theme": "dark", "body": {"page": 2}}

A response is [status, headers, body]. Header names are lowercase. A header value is a string, or a list of strings to send the header more than once (as set-cookie needs). The body can be:

BodySent as
a string or bytesthe whole body
a list of strings and bytesthe pieces joined
a generatoreach yielded chunk, as it is produced
a lambdawhatever it passes to the write function it receives
nullan empty body

text_response, html_response, json_response, redirect and status_response build common responses; cookie_header and add_header help with headers.

println web::json_response({"ok": true}, 201) # [201, {"content-type": "application/json"}, "{"ok": true}"]
println web::redirect("/login") # [302, {"location": "/login"}, ""]
println web::status_response(404) # [404, {"content-type": "text/plain; charset=utf-8"}, "Not Found"]

A generator or lambda body is streamed: the server sends the headers at once, then each chunk as soon as it is produced. This suits large downloads and server-sent events. A lambda body receives write, which returns false once the client has gone away, so the producer knows to stop:

fn countdown(n)
while n > 0 do
yield "data: ${n}\n\n"
n -= 1
end
end
events = with (env) do
[200, {"content-type": "text/event-stream"}, countdown(3)]
end
ticker = with (env) do
[200, {"content-type": "text/plain"}, with (write) do
i = 0
while i < 3 && write("tick ${i}\n") do
i += 1
end
end]
end
println web::request(events, "GET", "/").body().size() # 27
println web::request(ticker, "GET", "/").body() # tick 0, tick 1 and tick 2, one per line

If the app throws while streaming, the server resets the connection, so the client sees an incomplete response rather than a complete one with missing data. A streamed response holds one of the server’s workers until it finishes, so raise workers for long-lived streams.

web::builder() composes middleware and apps. use adds a middleware, run sets the app at the end of the chain, and map mounts an app under a path prefix. Middleware runs in the order it was added, so the first use sees each request first. A builder is itself an app.

api = web::builder()
.use(web::request_id())
.run(with (env) do
web::json_response({"mounted_at": env["script_name"], "path": env["path_info"]})
end)
app = web::builder()
.use(web::cors())
.map("/api", api)
.run(with (env) do web::text_response("home") end)
println web::request(app, "GET", "/api/users").json() # {"mounted_at": "/api", "path": "/users"}
println web::request(app, "GET", "/").body() # home

A mounted app sees its prefix moved from path_info to script_name, so it can be written as if it were at the root. The longest matching prefix wins, and prefixes only match whole path segments: /api doesn’t match /apix. Without run, a request that no mapping claims gets 404.

An httpserver server is an app too: server.to_app() returns it, ready to mount.

Middleware takes the next app and returns a new one. It can change the environment on the way in, the response on the way out, or answer without calling the app at all:

fn require_token(token)
with (app) do
with (env) do
return web::status_response(401) when env["headers"].get("authorization") != "Bearer ${token}"
response = app(env)
response[1]["x-checked"] = "yes"
response
end
end
end
app = web::builder()
.use(require_token("s3cret"))
.run(with (env) do web::text_response("secret stuff") end)
println web::request(app, "GET", "/").status() # 401
println web::request(app, "GET", "/", {"headers": {"Authorization": "Bearer s3cret"}}).body() # secret stuff
MiddlewareDoes
logger(format)Logs each request to stderr: "combined" or "simple".
static_files(prefix, dir, options)Serves files for GET and HEAD under a prefix; index and cache_control options. Requests that would leave the directory are passed on.
cors(origin, options)Adds CORS headers and answers preflight requests with 204.
request_id(header)Gives each request an id in env["web.request_id"] and a response header, keeping a well-formed one from the client.
rescue(handler)Turns an error thrown by the app into a response: handler(err, env).
lint()Checks requests and responses against the contract and throws a descriptive error. Use it in development and tests.
app = web::builder()
.use(web::lint())
.run(with (env) do [200, {"Content-Type": "text/plain"}, "hi"] end)
try
web::request(app, "GET", "/")
catch e
println e["message"] # web::lint: header names must be lowercase, got "Content-Type"
end

The server itself checks every response with web::check_response and answers a malformed one with 500, logging the reason, so a mistake never leaves a client waiting.

web::serve(app, options) runs an app on an HTTP server and blocks until it stops. web::Server.new(app, options).start() serves on a background task instead, and stop() ends it. The server is Kestrel; each request runs on its own task with its own environment.

app = web::builder()
.use(web::logger("simple"))
.run(with (env) do web::text_response("Hello over HTTP") end)
server = web::Server.new(app, {"port": 18091}).start()
task::sleep(200)
println http::get("http://localhost:18091/").body # Hello over HTTP
server.stop()
OptionDefaultMeaning
host"localhost"The address to bind: localhost, an IP address, or * for every interface.
port8080The port.
workers64Requests whose app is running at once. More wait up to five seconds for a slot, then get 503.
max_body_size8 MiBThe largest request body; larger ones get 413.
request_timeout30000Milliseconds the app has to return a response; after that the client gets 504. Streaming bodies can then run as long as they need.

The server also answers malformed requests with 400, clients that take more than ten seconds to send their headers with 408, and requests still waiting when stop() is called with 503. The server is unavailable in safe mode; the test client and everything else in the package still work.

Requests run in parallel, so state shared between them (a hashmap of sessions, a counter) needs care: each hashmap or list operation is safe on its own, but a read followed by a write, such as hits += 1, can lose updates. Guard such updates with a task::Mutex.

NameValueDescription
web::VERSION"1.0"
web::mime_type(ext)

Returns the MIME type for a file extension, with or without its leading dot.

ParameterTypeDefaultDescription
extstringThe extension, such as "png" or ".png".

Returns string — The MIME type, or "application/octet-stream" when unknown.

web::status_text(code)

Returns the reason phrase for an HTTP status code.

ParameterTypeDefaultDescription
codeintegerThe status code.

Returns string — The phrase, such as "Not Found", or "" when unknown.

web::parse_query(text)

Parses application/x-www-form-urlencoded text, as found in query strings and form bodies. A + decodes to a space. When a key repeats, the last value wins.

ParameterTypeDefaultDescription
textstringThe encoded text, without a leading ?.

Returns hashmap — The decoded keys and values.

web::parse_query_pairs(text)

Parses application/x-www-form-urlencoded text into a list of [key, value] pairs, keeping every value of a repeated key in order.

ParameterTypeDefaultDescription
textstringThe encoded text, without a leading ?.

Returns list — The decoded pairs.

web::parse_cookies(header)

Parses a Cookie request header into a hashmap of names to values.

ParameterTypeDefaultDescription
headerstringThe header value, such as "a=1; b=2".

Returns hashmap — The cookies.

web::cookie_header(name, value, options = {})

Builds a Set-Cookie header value.

ParameterTypeDefaultDescription
namestringThe cookie name.
valuestringThe cookie value.
optionshashmap{}Any of path (default "/"), max_age (seconds), domain, http_only (default true), secure (default false) and same_site (default "Lax").

Returns string — The header value.

web::add_header(headers, name, value)

Adds a header to a response's headers, keeping any value already there: a second value turns the entry into a list, which sends one header line per value (as set-cookie needs).

ParameterTypeDefaultDescription
headershashmapThe response headers to change.
namestringThe header name. Use lowercase.
valuestringThe value to add.

Returns hashmap — headers, for chaining.

web::text_response(body, status = 200, headers = {})

Builds a plain-text response.

ParameterTypeDefaultDescription
bodystringThe text.
statusinteger200The status code (default 200).
headershashmap{}Extra headers (default {}).

Returns list — [status, headers, body].

web::html_response(body, status = 200, headers = {})

Builds an HTML response.

ParameterTypeDefaultDescription
bodystringThe HTML.
statusinteger200The status code (default 200).
headershashmap{}Extra headers (default {}).

Returns list — [status, headers, body].

web::json_response(value, status = 200, headers = {})

Builds a JSON response.

ParameterTypeDefaultDescription
valueanyThe value to serialize.
statusinteger200The status code (default 200).
headershashmap{}Extra headers (default {}).

Returns list — [status, headers, body].

web::redirect(location, status = 302)

Builds a redirect response.

ParameterTypeDefaultDescription
locationstringWhere to send the client.
statusinteger302The status code (default 302; use 301, 303, 307 or 308 as needed).

Returns list — [status, headers, body].

web::status_response(status, message = null)

Builds a plain-text error response whose body is the status's reason phrase.

ParameterTypeDefaultDescription
statusintegerThe status code.
messagestringnullThe body (default: the reason phrase, such as "Not Found").

Returns list — [status, headers, body].

web::builder()

Creates a Builder.

Returns Builder

web::logger(format = "combined")

Logs each request to stderr after it is answered.

ParameterTypeDefaultDescription
formatstring"combined""combined" (client, time, request line, status, duration) or "simple" (time, request line, status, duration).

Returns lambda — The middleware.

web::static_files(prefix, dir, options = {})

Serves files from a directory for GET and HEAD requests under a path prefix, passing everything else through. Requests that would leave the directory are passed through as well.

ParameterTypeDefaultDescription
prefixstringThe URL prefix, such as "/assets".
dirstringThe directory to serve.
optionshashmap{}index names a file to serve for a directory (such as "index.html"); cache_control sets that header on every file served.

Returns lambda — The middleware.

web::cors(origin = "*", options = {})

Adds CORS headers to every response and answers preflight (OPTIONS) requests with 204.

ParameterTypeDefaultDescription
originstring"*"The allowed origin (default "*").
optionshashmap{}methods and headers override the allowed methods and request headers; max_age sets how long a preflight is cached, in seconds.

Returns lambda — The middleware.

web::request_id(header = "x-request-id")

Gives each request an identifier, stored as env["web.request_id"] and sent back in a response header. A well-formed identifier sent by the client (or a proxy) is kept.

ParameterTypeDefaultDescription
headerstring"x-request-id"The header to read and write (default "x-request-id").

Returns lambda — The middleware.

web::rescue(handler)

Catches errors thrown by the app and turns them into a response. Errors thrown while a streaming body is producing chunks happen after the response has started, so they are not caught here.

ParameterTypeDefaultDescription
handlerlambdaCalled as handler(err, env); returns a response.

Returns lambda — The middleware.

web::lint()

Checks that requests and responses follow the web interface, and throws a descriptive error when they don't. Use it in development and tests, between the app and everything else.

Returns lambda — The middleware.

web::check_response(response)

Checks a value against the response format: [status, headers, body] with an integer status from 100 to 999, a hashmap of lowercase header names to strings (or lists of strings), and a body that is a string, bytes, a list of strings or bytes, a generator, or a lambda. Status 1xx, 204 and 304 responses must have an empty body.

ParameterTypeDefaultDescription
responseanyThe value an app returned.

Returns string — A description of the first problem, or null when it is valid.

web::serve(app, options = {})

Runs an app on an HTTP server and blocks until it stops.

ParameterTypeDefaultDescription
appanyThe app.
optionshashmap{}The server options; see Server.new.

Returns Server — The stopped server.

web::mock_env(method, path, options = {})

Builds an environment for calling an app directly, without a server.

ParameterTypeDefaultDescription
methodstringThe HTTP method.
pathstringThe path, optionally with a query string ("/search?q=kiwi").
optionshashmap{}headers (a hashmap), body (a string, bytes, or a hashmap sent as JSON), remote_addr and script_name.

Returns hashmap — The environment.

web::request(app, method, path, options = {})

Calls an app with a mock request and collects its response, streaming bodies included. Errors thrown by the app propagate, so tests see them.

ParameterTypeDefaultDescription
appanyThe app.
methodstringThe HTTP method.
pathstringThe path, optionally with a query string.
optionshashmap{}As for mock_env.

Returns MockResponse — The response.

A read-only view of an environment with parsing helpers. Parsed values are cached in the environment, so every Request made from the same environment shares them.

Request.new(env)

Wraps an environment.

ParameterTypeDefaultDescription
envhashmapThe environment passed to the app.
env()

Returns the environment.

Returns hashmap

method()

Returns the HTTP method, uppercase.

Returns string

path()

Returns the full path: script_name followed by path_info.

Returns string

path_info()

Returns the part of the path after the point where the app is mounted.

Returns string

script_name()

Returns the path where the app is mounted, or "" at the root.

Returns string

query_string()

Returns the raw query string, without the ?.

Returns string

query()

Returns the query string parsed into a hashmap. When a key repeats, the last value wins; use query_all for every value.

Returns hashmap

query_param(name, fallback = null)

Returns one query parameter.

ParameterTypeDefaultDescription
namestringThe parameter name.
fallbackanynullThe value when it is absent (default null).

Returns any

query_all(name)

Returns every value of a query parameter, in order.

ParameterTypeDefaultDescription
namestringThe parameter name.

Returns list

headers()

Returns the request headers. Names are lowercase.

Returns hashmap

header(name)

Returns a request header, or null. The name is case-insensitive.

ParameterTypeDefaultDescription
namestringThe header name.

Returns string

content_type()

Returns the Content-Type header, or "".

Returns string

is_json()

Checks whether the body is JSON.

Returns boolean

is_form()

Checks whether the body is a URL-encoded form.

Returns boolean

body_bytes()

Returns the body as bytes.

Returns bytes

body()

Returns the body decoded as UTF-8 text.

Returns string

json()

Returns the body parsed as JSON, or null when it is empty.

Returns any

form()

Returns the fields of a URL-encoded form body.

Returns hashmap

cookies()

Returns the request cookies.

Returns hashmap

cookie(name)

Returns a cookie's value, or null.

ParameterTypeDefaultDescription
namestringThe cookie name.

Returns string

remote_addr()

Returns the client's IP address.

Returns string

scheme()

Returns "http" or "https".

Returns string

host()

Returns the host the client asked for: the Host header, or the server name and port.

Returns string

url()

Returns the full URL of the request.

Returns string

get(key, fallback = null)

Returns any environment value, such as one a middleware added.

ParameterTypeDefaultDescription
keystringThe key, such as "web.request_id".
fallbackanynullThe value when it is absent (default null).

Returns any

Composes middleware and apps into one app. Middleware runs in the order it was added, so the first use sees each request first. A builder is itself an app.

Builder.new()
use(middleware)

Adds a middleware: a function that takes the next app and returns a new app.

ParameterTypeDefaultDescription
middlewarelambdaThe middleware.

Returns Builder — self

run(app)

Sets the app that handles requests no mapping claims.

ParameterTypeDefaultDescription
appanyThe app.

Returns Builder — self

map(prefix, app)

Mounts an app under a path prefix. The mounted app sees the prefix moved from path_info to script_name. The longest matching prefix wins, and a prefix only matches whole path segments.

ParameterTypeDefaultDescription
prefixstringThe prefix, such as "/api".
appanyThe app to mount (another builder works too).

Returns Builder — self

to_app()

Builds the composed app. Calling the builder directly does this once, on the first request.

Returns lambda — The app.

call(env)

Handles a request, which makes a builder usable as an app.

ParameterTypeDefaultDescription
envhashmapThe environment.

Returns list — [status, headers, body].

Runs an app on an HTTP server (Kestrel). Each request gets its own task and its own environment.

Server.new(app, options = {})

Creates a server for an app. Nothing listens until start.

ParameterTypeDefaultDescription
appanyThe app.
optionshashmap{}host (default "localhost"), port (default 8080), workers (handlers running at once, default 64), max_body_size (bytes, default 8 MiB) and request_timeout (milliseconds to start a response, default 30000).
start(block = false)

Starts listening.

ParameterTypeDefaultDescription
blockbooleanfalseIf true, serves on the calling task until stop(); otherwise serves on a background task and returns at once.

Returns Server — self

stop()

Stops listening. Requests still waiting on the app are answered with 503.

Returns Server — self

running()

Checks whether the server is listening.

Returns boolean

id()

Returns the server's internal identifier.

Returns integer

poll_ms()

Returns the accept-poll interval in milliseconds.

Returns integer

The response collected by web::request.

MockResponse.new(status, headers, body)
ParameterTypeDefaultDescription
statusany
headersany
bodyany
status()

Returns the status code.

Returns integer

headers()

Returns the response headers.

Returns hashmap

header(name)

Returns a response header (case-insensitive), or null. A header sent more than once is a list.

ParameterTypeDefaultDescription
namestringThe header name.

Returns any

body()

Returns the body as text.

Returns string

body_bytes()

Returns the body as bytes.

Returns bytes

json()

Returns the body parsed as JSON.

Returns any