Skip to content

std/http

HTTP client and server, backed by Go’s net/http.

The module is split the way every co-located standard adapter is: the data is Nomi and only the effects are Go. Method, Status, Header, Query, Cookie, Request, Response and Error are ordinary Nomi values you could have written yourself; Client and Server are opaque handles because each one names a live OS resource. Everything a program observes about a request or a response is an immutable Nomi value.

Three things are worth knowing before reading further:

  • Headers are case-INSENSITIVE and canonicalised by Header.canonical; query parameters are case-SENSITIVE and are a separate type, Query. Sharing one type between them corrupts ?userId=7&Tag=a&tag=b.
  • A handler runs concurrently with every other handler. There is no process-wide lock.
  • A handler’s Request.context carries the request’s cancellation, so Request.canceled? reports a client disconnect or a shutdown, and a Client call made with that context is cancelled along with it.

Import with import std/http.

struct Method {
    name: String
}

An HTTP request method, held as the wire token it is sent as.

A newtype over the token rather than a closed enum, because HTTP’s method space is OPEN (RFC 9110 §9). That is a modelling decision, not a workaround. An enum carrying both Get and Custom String has two spellings for one method, and two spellings for one value is a representation redundancy: every equality, hash and match then has to decide which spelling it meant, and the answers drift. Here GET has exactly one representation, so Method.from_string("GET") and Method.get() are the same value by construction.

Rust’s http crate reaches the same shape for the same reason (struct Method with associated constants); Go uses bare strings.

Matching is on the token, not on a variant:

case Method.string(request.method) {
  "GET" -> read(request)
  "POST" -> write(request)
  _ -> Response.text(Status.method_not_allowed(), "")
}

Methods are case-sensitive: Method.new("get") is not Method.get().

Interactive Tests

assert Method.new("GET") == Method.get()
refute Method.new("get") == Method.get()
assert Hashable.hash(Method.new("GET")) == Hashable.hash(Method.get())
fn new(name: String): Method

type function on Method

A method from its wire token, verbatim.

Interactive Tests

assert Method.string(Method.new("QUERY")) == "QUERY"
assert Method.new("GET") == Method.get()
refute Method.new("Get") == Method.get()
fn from_string(name: String): Method

type function on Method

A method from its wire token, verbatim. Alias of Method.new for symmetry with Method.string.

Interactive Tests

assert Method.from_string("PATCH") == Method.patch()
assert Method.from_string("PATCH") == Method.new("PATCH")
fn string(method: Method): String

type function on Method

The wire token for a method.

Interactive Tests

assert Method.string(Method.post()) == "POST"
assert Method.string(Method.new("BREW")) == "BREW"
fn get(): Method

type function on Method

GET.

Interactive Tests

assert Method.string(Method.get()) == "GET"
assert Method.get() == Method.new("GET")
fn head(): Method

type function on Method

HEAD.

Interactive Tests

assert Method.string(Method.head()) == "HEAD"
assert Method.head() == Method.new("HEAD")
fn post(): Method

type function on Method

POST.

Interactive Tests

assert Method.string(Method.post()) == "POST"
assert Method.post() == Method.new("POST")
fn put(): Method

type function on Method

PUT.

Interactive Tests

assert Method.string(Method.put()) == "PUT"
assert Method.put() == Method.new("PUT")
fn patch(): Method

type function on Method

PATCH.

Interactive Tests

assert Method.string(Method.patch()) == "PATCH"
assert Method.patch() == Method.new("PATCH")
fn delete(): Method

type function on Method

DELETE.

Interactive Tests

assert Method.string(Method.delete()) == "DELETE"
assert Method.delete() == Method.new("DELETE")
fn connect(): Method

type function on Method

CONNECT.

Interactive Tests

assert Method.string(Method.connect()) == "CONNECT"
assert Method.connect() == Method.new("CONNECT")
fn options(): Method

type function on Method

OPTIONS.

Interactive Tests

assert Method.string(Method.options()) == "OPTIONS"
assert Method.options() == Method.new("OPTIONS")
fn trace(): Method

type function on Method

TRACE.

Interactive Tests

assert Method.string(Method.trace()) == "TRACE"
assert Method.trace() == Method.new("TRACE")
fn equal?(a: Method, b: Method): Bool

impl Equatable.equal?

Two methods are equal when their wire tokens are. Hand-written rather than derived so Equatable.equal? and == cannot drift apart: == on a struct dispatches here, and this is the same comparison it would make structurally.

Interactive Tests

assert Equatable.equal?(Method.new("GET"), Method.get())
refute Equatable.equal?(Method.new("get"), Method.get())
fn to_string(method: Method): String

impl Display.to_string

The wire token.

Interactive Tests

assert Display.to_string(Method.get()) == "GET"
fn inspect(method: Method): String

impl Debug.inspect

Interactive Tests

assert Debug.inspect(Method.get()) == "Method(GET)"
fn hash(value: Method): Int

impl Hashable.hash

struct Status {
    code: Int
}

An HTTP response status, held as the numeric code it is sent as.

A newtype over the code for the same modelling reason as Method: an HTTP status IS a three-digit integer, the registry is extensible, and the named statuses are conveniences over the number rather than a second kind of value. So Status.from_code(200) == Status.ok() holds by construction, and there is no second spelling for anyone to compare unequally.

Rust’s http crate is struct StatusCode(NonZeroU16) with associated constants for the same reason; Go uses bare ints.

Matching is on the code, not on a variant:

case response.status.code {
  200 -> parse(response)
  404 -> Maybe.None
  _ -> retry(response)
}

A single status compares directly: if response.status == Status.ok().

Interactive Tests

assert Status.from_code(200) == Status.ok()
assert Hashable.hash(Status.from_code(200)) == Hashable.hash(Status.ok())
assert Status.from_code(299).code == 299
fn from_code(code: Int): Status

type function on Status

A status from its numeric code. Any code is representable, including extension codes no constructor below names.

Interactive Tests

assert Status.from_code(404) == Status.not_found()
assert Status.from_code(599).code == 599
assert Status.text(Status.from_code(599)) == ""
fn text(status: Status): String

type function on Status

The reason phrase for a status, or "" for a code with no registered phrase. Matches Go’s http.StatusText.

Interactive Tests

assert Status.text(Status.ok()) == "OK"
assert Status.text(Status.not_found()) == "Not Found"
assert Status.text(Status.teapot()) == "I'm a teapot"
assert Status.text(Status.from_code(299)) == ""
fn continue_(): Status

type function on Status

100 Continue.

Interactive Tests

assert Status.continue_() == Status.from_code(100)
assert Status.text(Status.continue_()) == "Continue"
fn switching_protocols(): Status

type function on Status

101 Switching Protocols.

fn processing(): Status

type function on Status

102 Processing.

fn early_hints(): Status

type function on Status

103 Early Hints.

fn ok(): Status

type function on Status

200 OK.

Interactive Tests

assert Status.ok() == Status.from_code(200)
assert Status.text(Status.ok()) == "OK"
fn created(): Status

type function on Status

201 Created.

Interactive Tests

assert Status.created() == Status.from_code(201)
assert Status.text(Status.created()) == "Created"
fn accepted(): Status

type function on Status

202 Accepted.

fn non_authoritative_info(): Status

type function on Status

203 Non-Authoritative Information.

fn no_content(): Status

type function on Status

204 No Content.

Interactive Tests

assert Status.no_content() == Status.from_code(204)
assert Status.text(Status.no_content()) == "No Content"
fn reset_content(): Status

type function on Status

205 Reset Content.

fn partial_content(): Status

type function on Status

206 Partial Content.

fn multi_status(): Status

type function on Status

207 Multi-Status.

fn already_reported(): Status

type function on Status

208 Already Reported.

fn im_used(): Status

type function on Status

226 IM Used.

fn multiple_choices(): Status

type function on Status

300 Multiple Choices.

fn moved_permanently(): Status

type function on Status

301 Moved Permanently.

Interactive Tests

assert Status.moved_permanently() == Status.from_code(301)
assert Status.text(Status.moved_permanently()) == "Moved Permanently"
fn found(): Status

type function on Status

302 Found.

fn see_other(): Status

type function on Status

303 See Other.

fn not_modified(): Status

type function on Status

304 Not Modified.

Interactive Tests

assert Status.not_modified() == Status.from_code(304)
assert Status.text(Status.not_modified()) == "Not Modified"
fn use_proxy(): Status

type function on Status

305 Use Proxy.

fn temporary_redirect(): Status

type function on Status

307 Temporary Redirect.

fn permanent_redirect(): Status

type function on Status

308 Permanent Redirect.

fn bad_request(): Status

type function on Status

400 Bad Request.

Interactive Tests

assert Status.bad_request() == Status.from_code(400)
assert Status.text(Status.bad_request()) == "Bad Request"
fn unauthorized(): Status

type function on Status

401 Unauthorized.

Interactive Tests

assert Status.unauthorized() == Status.from_code(401)
assert Status.text(Status.unauthorized()) == "Unauthorized"
fn payment_required(): Status

type function on Status

402 Payment Required.

fn forbidden(): Status

type function on Status

403 Forbidden.

Interactive Tests

assert Status.forbidden() == Status.from_code(403)
assert Status.text(Status.forbidden()) == "Forbidden"
fn not_found(): Status

type function on Status

404 Not Found.

Interactive Tests

assert Status.not_found() == Status.from_code(404)
assert Status.text(Status.not_found()) == "Not Found"
fn method_not_allowed(): Status

type function on Status

405 Method Not Allowed.

Interactive Tests

assert Status.method_not_allowed() == Status.from_code(405)
assert Status.text(Status.method_not_allowed()) == "Method Not Allowed"
fn not_acceptable(): Status

type function on Status

406 Not Acceptable.

fn proxy_auth_required(): Status

type function on Status

407 Proxy Authentication Required.

fn request_timeout(): Status

type function on Status

408 Request Timeout.

fn conflict(): Status

type function on Status

409 Conflict.

Interactive Tests

assert Status.conflict() == Status.from_code(409)
assert Status.text(Status.conflict()) == "Conflict"
fn gone(): Status

type function on Status

410 Gone.

fn length_required(): Status

type function on Status

411 Length Required.

fn precondition_failed(): Status

type function on Status

412 Precondition Failed.

fn request_entity_too_large(): Status

type function on Status

413 Request Entity Too Large.

fn request_uri_too_long(): Status

type function on Status

414 Request URI Too Long.

fn unsupported_media_type(): Status

type function on Status

415 Unsupported Media Type.

fn requested_range_not_satisfiable(): Status

type function on Status

416 Requested Range Not Satisfiable.

fn expectation_failed(): Status

type function on Status

417 Expectation Failed.

fn teapot(): Status

type function on Status

418 I'm a teapot.

Interactive Tests

assert Status.teapot() == Status.from_code(418)
assert Status.text(Status.teapot()) == "I'm a teapot"
fn misdirected_request(): Status

type function on Status

421 Misdirected Request.

fn unprocessable_entity(): Status

type function on Status

422 Unprocessable Entity.

Interactive Tests

assert Status.unprocessable_entity() == Status.from_code(422)
assert Status.text(Status.unprocessable_entity()) == "Unprocessable Entity"
fn locked(): Status

type function on Status

423 Locked.

fn failed_dependency(): Status

type function on Status

424 Failed Dependency.

fn too_early(): Status

type function on Status

425 Too Early.

fn upgrade_required(): Status

type function on Status

426 Upgrade Required.

fn precondition_required(): Status

type function on Status

428 Precondition Required.

fn too_many_requests(): Status

type function on Status

429 Too Many Requests.

Interactive Tests

assert Status.too_many_requests() == Status.from_code(429)
assert Status.text(Status.too_many_requests()) == "Too Many Requests"
fn request_header_fields_too_large(): Status

type function on Status

431 Request Header Fields Too Large.

fn unavailable_for_legal_reasons(): Status

type function on Status

451 Unavailable For Legal Reasons.

fn internal_server_error(): Status

type function on Status

500 Internal Server Error.

Interactive Tests

assert Status.internal_server_error() == Status.from_code(500)
assert Status.text(Status.internal_server_error()) == "Internal Server Error"
fn not_implemented(): Status

type function on Status

501 Not Implemented.

Interactive Tests

assert Status.not_implemented() == Status.from_code(501)
assert Status.text(Status.not_implemented()) == "Not Implemented"
fn bad_gateway(): Status

type function on Status

502 Bad Gateway.

fn service_unavailable(): Status

type function on Status

503 Service Unavailable.

Interactive Tests

assert Status.service_unavailable() == Status.from_code(503)
assert Status.text(Status.service_unavailable()) == "Service Unavailable"
fn gateway_timeout(): Status

type function on Status

504 Gateway Timeout.

Interactive Tests

assert Status.gateway_timeout() == Status.from_code(504)
assert Status.text(Status.gateway_timeout()) == "Gateway Timeout"
fn http_version_not_supported(): Status

type function on Status

505 HTTP Version Not Supported.

fn variant_also_negotiates(): Status

type function on Status

506 Variant Also Negotiates.

fn insufficient_storage(): Status

type function on Status

507 Insufficient Storage.

fn loop_detected(): Status

type function on Status

508 Loop Detected.

fn not_extended(): Status

type function on Status

510 Not Extended.

fn network_authentication_required(): Status

type function on Status

511 Network Authentication Required.

fn equal?(a: Status, b: Status): Bool

impl Equatable.equal?

Two statuses are equal when their codes are. Hand-written for the same reason as Method’s: == on a struct dispatches here, so the explicit and the operator forms cannot disagree.

Interactive Tests

assert Equatable.equal?(Status.from_code(200), Status.ok())
refute Equatable.equal?(Status.ok(), Status.created())
fn to_string(status: Status): String

impl Display.to_string

The numeric code.

Interactive Tests

assert Display.to_string(Status.not_found()) == "404"
fn inspect(status: Status): String

impl Debug.inspect

Interactive Tests

assert Debug.inspect(Status.ok()) == "Status(200 OK)"
assert Debug.inspect(Status.from_code(299)) == "Status(299 )"
fn hash(value: Status): Int

impl Hashable.hash

enum Error {
    InvalidRequest { reason: String }
    Transport { reason: String }
    Body { reason: String }
    ServerFailure { reason: String }
}

Distinguishable failures returned by the HTTP adapter.

Every variant has a construction path: InvalidRequest when a method or URL cannot form a request, Transport when the round trip fails, Body when a body cannot be read or decoded, and ServerFailure when binding, serving or shutting down fails.

Interactive Tests

assert Display.to_string(
  Error.Transport{reason: "refused"}
) == "HTTP transport error: refused"
refute Error.Body{reason: "eof"} == Error.Transport{reason: "eof"}
fn to_string(e: Error): String

impl Display.to_string

Interactive Tests

assert Display.to_string(
  Error.InvalidRequest{reason: "bad URL"}
) == "invalid HTTP request: bad URL"
assert Display.to_string(
  Error.Body{reason: "not UTF-8"}
) == "HTTP body error: not UTF-8"
assert Display.to_string(
  Error.ServerFailure{reason: "bind failed"}
) == "HTTP server error: bind failed"
fn inspect(value: Error): String

impl Debug.inspect

fn equal?(a: Error, b: Error): Bool

impl Equatable.equal?

struct Header

opaque — construction surface is private to its defining module

HTTP header fields: an ordered list of (name, value) pairs where a name may repeat.

Field names are case-insensitive (RFC 9110 §5.1), so Header stores and compares them in the single canonical form Header.canonical produces. The canonicalisation is Nomi’s own and explicit — it is not Go’s http.Header, which is what previously leaked name-mangling into case-sensitive query parameters.

get distinguishes absent from present-but-empty: a field set to "" answers Maybe.Some("") and has? answers True.

Interactive Tests

headers = Header.from_list([("content-type", "text/plain"), ("Tag", "a")])
assert Header.get(headers, "CONTENT-TYPE") == Maybe.Some("text/plain")
assert Header.names(headers) == ["Content-Type", "Tag"]
assert Header.has?(headers, "tag")
refute Query.has?(Query.from_list([("Tag", "a")]), "tag")
fn empty(): Header

type function on Header

Headers with no fields.

Interactive Tests

refute Header.has?(Header.empty(), "Content-Type")
assert Header.get(Header.empty(), "Content-Type") == Maybe.None
assert Header.names(Header.empty()) == []
fn from_list(entries: List<(String, String)>): Header

type function on Header

Headers from (name, value) pairs. Names are canonicalised; repeated names keep every value, in the order given.

Interactive Tests

headers = Header.from_list([("set-cookie", "a=1"), ("SET-COOKIE", "b=2")])
assert Header.to_list(headers) == [("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")]
assert Header.values(headers, "Set-Cookie") == ["a=1", "b=2"]
fn to_list(headers: Header): List<(String, String)>

type function on Header

Every field as a (canonical name, value) pair, in order.

Interactive Tests

headers = Header.set(Header.empty(), "content-length", "0")
assert Header.to_list(headers) == [("Content-Length", "0")]
fn canonical(name: String): String

type function on Header

The canonical form of a field name: content-type becomes Content-Type. Each --separated token is title-cased, matching Go’s textproto.CanonicalMIMEHeaderKey.

Interactive Tests

assert Header.canonical("content-type") == "Content-Type"
assert Header.canonical("X-NOMI-TRACE") == "X-Nomi-Trace"
assert Header.canonical("Content-Type") == "Content-Type"
assert Header.canonical("") == ""
fn get(headers: Header, name: String): Maybe<String>

type function on Header

The first value for name, or Maybe.None when the field is absent. A field whose value is "" is present, so this answers Maybe.Some("") rather than Maybe.None.

Interactive Tests

headers = Header.set(Header.empty(), "X-Trace", "")
assert Header.get(headers, "x-trace") == Maybe.Some("")
assert Header.has?(headers, "x-trace")
assert Header.get(headers, "X-Other") == Maybe.None
refute Header.has?(headers, "X-Other")
fn values(headers: Header, name: String): List<String>

type function on Header

Every value for name, in order.

Interactive Tests

headers =
  Header.empty()
  |> Header.add("accept", "text/html")
  |> Header.add("ACCEPT", "application/json")
assert Header.values(headers, "Accept") == ["text/html", "application/json"]
assert Header.values(headers, "Host") == []
fn names(headers: Header): List<String>

type function on Header

Every field name present, canonicalised, each once, in first-appearance order.

Interactive Tests

headers =
  Header.empty()
  |> Header.add("accept", "text/html")
  |> Header.add("ACCEPT", "application/json")
  |> Header.add("host", "example.com")
assert Header.names(headers) == ["Accept", "Host"]
fn set(headers: Header, name: String, value: String): Header

type function on Header

Headers where name has exactly value, replacing any existing values for that name and appending if it was absent.

Interactive Tests

headers =
  Header.empty()
  |> Header.add("accept", "text/html")
  |> Header.add("accept", "application/json")
  |> Header.set("ACCEPT", "*/*")
assert Header.values(headers, "Accept") == ["*/*"]
fn add(headers: Header, name: String, value: String): Header

type function on Header

Headers with value appended to name, keeping any existing values.

Interactive Tests

headers =
  Header.empty()
  |> Header.set("accept", "text/html")
  |> Header.add("accept", "application/json")
assert Header.values(headers, "Accept") == ["text/html", "application/json"]
fn delete(headers: Header, name: String): Header

type function on Header

Headers without any field named name.

Interactive Tests

headers = Header.set(Header.empty(), "X-Trace", "abc")
refute Header.has?(Header.delete(headers, "x-trace"), "X-Trace")
assert Header.has?(Header.delete(headers, "X-Other"), "X-Trace")
fn has?(headers: Header, name: String): Bool

type function on Header

True when a field named name is present, whatever its value.

Interactive Tests

headers = Header.set(Header.empty(), "X-Trace", "")
assert Header.has?(headers, "x-TRACE")
refute Header.has?(headers, "X-Missing")
fn inspect(value: Header): String

impl Debug.inspect

struct Query

opaque — construction surface is private to its defining module

URL query parameters: an ordered list of (name, value) pairs where a name may repeat.

Query parameters are case-SENSITIVE and their order is observable, which is why they are their own type rather than a Header. ?userId=7&Tag=a&tag=b round-trips as [("userId", "7"), ("Tag", "a"), ("tag", "b")]; passing it through a case-insensitive header map merges Tag with tag and rewrites userId, silently corrupting the data.

Interactive Tests

query = Query.parse("?userId=7&Tag=a&tag=b")
assert Query.to_list(query) == [("userId", "7"), ("Tag", "a"), ("tag", "b")]
assert Query.get(query, "Tag") == Maybe.Some("a")
assert Query.get(query, "tag") == Maybe.Some("b")
refute Query.has?(query, "userid")
assert Header.has?(Header.from_list([("userId", "7")]), "userid")
fn empty(): Query

type function on Query

Query parameters with no entries.

Interactive Tests

refute Query.has?(Query.empty(), "q")
assert Query.encode(Query.empty()) == ""
assert Query.to_list(Query.empty()) == []
fn parse(raw: String): Query

type function on Query

Parse a raw query string, percent-decoding each name and value and keeping the wire order and the wire spelling. A leading ? is tolerated.

Interactive Tests

assert Query.to_list(Query.parse("?q=a+b")) == [("q", "a b")]
assert Query.to_list(Query.parse("q=a%20b")) == [("q", "a b")]
assert Query.to_list(Query.parse("")) == []
assert Query.to_list(Query.parse("flag")) == [("flag", "")]
fn from_list(entries: List<(String, String)>): Query

type function on Query

Query parameters from (name, value) pairs, verbatim.

Interactive Tests

query = Query.from_list([("Tag", "a"), ("tag", "b")])
assert Query.to_list(query) == [("Tag", "a"), ("tag", "b")]
assert Query.names(query) == ["Tag", "tag"]
fn to_list(query: Query): List<(String, String)>

type function on Query

Every parameter as a (name, value) pair, in order.

Interactive Tests

assert Query.to_list(Query.from_list([("q", "1")])) == [("q", "1")]
fn get(query: Query, name: String): Maybe<String>

type function on Query

The first value for name, or Maybe.None when absent. A parameter present with an empty value answers Maybe.Some("").

Interactive Tests

query = Query.parse("a=&b=2")
assert Query.get(query, "a") == Maybe.Some("")
assert Query.get(query, "b") == Maybe.Some("2")
assert Query.get(query, "A") == Maybe.None
assert Query.get(query, "c") == Maybe.None
fn values(query: Query, name: String): List<String>

type function on Query

Every value for name, in order.

Interactive Tests

query = Query.parse("tag=a&Tag=b&tag=c")
assert Query.values(query, "tag") == ["a", "c"]
assert Query.values(query, "Tag") == ["b"]
fn names(query: Query): List<String>

type function on Query

Every parameter name present, each once, in first-appearance order.

Interactive Tests

assert Query.names(Query.parse("a=1&b=2&a=3")) == ["a", "b"]
fn has?(query: Query, name: String): Bool

type function on Query

True when a parameter named name is present, whatever its value.

Interactive Tests

assert Query.has?(Query.parse("flag"), "flag")
refute Query.has?(Query.parse("flag"), "FLAG")
fn encode(query: Query): String

type function on Query

Render as a raw query string, percent-encoding each name and value and preserving order.

Interactive Tests

assert Query.encode(Query.from_list([("q", "a b"), ("q", "c")])) == "q=a+b&q=c"
assert Query.encode(Query.parse("?userId=7&Tag=a")) == "userId=7&Tag=a"
fn to_string(query: Query): String

impl Display.to_string

The encoded query string.

Interactive Tests

assert Display.to_string(Query.from_list([("q", "a b")])) == "q=a+b"
fn inspect(value: Query): String

impl Debug.inspect

enum SameSite {
    Unset
    Lax
    Strict
    None
}

The SameSite attribute of a cookie. Unset omits the attribute.

Interactive Tests

assert Display.to_string(SameSite.Unset) == ""
assert Display.to_string(SameSite.Lax) == "Lax"
assert Display.to_string(SameSite.Strict) == "Strict"
assert Display.to_string(SameSite.None) == "None"
fn to_string(same_site: SameSite): String

impl Display.to_string

The wire spelling, or "" for Unset.

Interactive Tests

assert Display.to_string(SameSite.Unset) == ""
assert Display.to_string(SameSite.Strict) == "Strict"
fn inspect(value: SameSite): String

impl Debug.inspect

fn equal?(a: SameSite, b: SameSite): Bool

impl Equatable.equal?

struct Cookie {
    name: String
    value: String
    path: String
    domain: String
    max_age: Maybe<Int>
    secure: Bool
    http_only: Bool
    same_site: SameSite
}

An HTTP cookie, with the attributes a server actually needs to set one.

path and domain are omitted from the wire form when ""; max_age is omitted when Maybe.None. Expires is deliberately absent: RFC 6265 §4.1.2.2 prefers Max-Age, and rendering Expires needs IMF-fixdate formatting, which would pull std/calendar into std/http for a strictly weaker attribute.

Interactive Tests

assert Display.to_string(Cookie.new("session", "abc")) == "session=abc"
cookie = Struct.update(Cookie.new("session", "abc"), {
  path: "/",
  domain: "example.com",
  max_age: Maybe.Some(3600),
  secure: True,
  http_only: True,
  same_site: SameSite.Lax,
})
assert Display.to_string(
  cookie
) == "session=abc; Path=/; Domain=example.com; Max-Age=3600; Secure; HttpOnly; SameSite=Lax"
assert Cookie.parse(Display.to_string(cookie)) == Maybe.Some(cookie)
fn new(name: String, value: String): Cookie

type function on Cookie

A cookie with a name and a value and no attributes. Display renders it as exactly name=value.

Interactive Tests

assert Display.to_string(Cookie.new("session", "abc")) == "session=abc"
assert Cookie.new("session", "abc").max_age == Maybe.None
assert Cookie.new("session", "abc").same_site == SameSite.Unset
refute Cookie.new("session", "abc").secure
fn parse(raw: String): Maybe<Cookie>

type function on Cookie

Parse one Set-Cookie field value. Unknown attributes are ignored; a field with no name is rejected.

Interactive Tests

assert Cookie.parse("session=abc") == Maybe.Some(Cookie.new("session", "abc"))
assert Cookie.parse("a=1; Frobnicate=yes") == Maybe.Some(Cookie.new("a", "1"))
assert Cookie.parse("=nope") == Maybe.None
assert Cookie.parse(
  "session=abc; Path=/; Domain=example.com; Max-Age=3600; Secure; HttpOnly; SameSite=Lax"
) == Maybe.Some(Struct.update(Cookie.new("session", "abc"), {
    path: "/",
    domain: "example.com",
    max_age: Maybe.Some(3600),
    secure: True,
    http_only: True,
    same_site: SameSite.Lax,
  }))
fn to_string(cookie: Cookie): String

impl Display.to_string

The Set-Cookie field value: name=value followed by every attribute that is set, separated by ; .

Interactive Tests

assert Display.to_string(Cookie.new("session", "abc")) == "session=abc"
cookie = Struct.update(
  Cookie.new("session", "abc"),
  {path: "/", http_only: True},
)
assert Display.to_string(cookie) == "session=abc; Path=/; HttpOnly"
fn inspect(cookie: Cookie): String

impl Debug.inspect

Interactive Tests

assert Debug.inspect(Cookie.new("session", "abc")) == "Cookie(session=abc)"
fn equal?(a: Cookie, b: Cookie): Bool

impl Equatable.equal?

type Cancellation Int

opaque — construction surface is private to its defining module

The cancellation signal of one in-flight server request.

Opaque, and cheap to copy: it addresses a signal the Go half owns rather than holding one. Cancellation.fired? answers True once the peer that sent the request has disconnected, or Server.shutdown has begun.

It is a value rather than a Context entry because Nomi’s Context carries deadlines and values, not cancellation (see Request), and it is a distinct type rather than a third host handle because the module’s design is that only genuinely-effectful things — Client and Server — are host handles.

Interactive Tests

refute Cancellation.fired?(Cancellation.never())
fn never(): Cancellation

type function on Cancellation

The signal a client-built request carries: never fired, because nobody can hang up on a request you are about to send.

Interactive Tests

refute Cancellation.fired?(Cancellation.never())
fn fired?(cancellation: Cancellation): Bool

type function on Cancellation

True once the request this signal belongs to has been cancelled.

Interactive Tests

refute Cancellation.fired?(Cancellation.never())
refute Request.canceled?(Request.new(Method.get(), "/"))
fn inspect(value: Cancellation): String

impl Debug.inspect

struct Request {
    method: Method
    url: String
    headers: Header
    body: Bytes
    context: Context
    cancellation: Cancellation
}

An HTTP request as an immutable value.

context carries the request’s DEADLINE, and cancellation carries its cancellation: for a request a server handler receives, cancellation fires when the client disconnects or the server begins shutting down, and an outbound Client call made with that request is cancelled along with it, so an abandoned request stops doing work on the caller’s behalf.

The two are separate fields because Nomi’s Context is deadline-and-values by design — value.ContextVal’s own contract is that “deadlines are the only state a Context carries”, and a task being stopped “travels on the Go context”. Cancellation is that Go-side signal, named as a Nomi value.

Cookies live in the headers, not in a second field: a request’s cookies ARE its Cookie field, and keeping a parallel list is two sources of truth for one thing. Use Request.cookie and Request.add_cookie.

Interactive Tests

request = Request.new(Method.get(), "/items?tag=a&tag=b")
assert Request.path(request) == "/items"
assert Query.values(Request.query(request), "tag") == ["a", "b"]
refute Request.canceled?(request)
fn new(method: Method, url: String, headers: Header, body: Bytes, context: Context): Request

type function on Request

Build a request. The URL is not validated here — validation belongs to the send, which is where a bad URL becomes Error.InvalidRequest, rather than to a throwaway parse whose verdict may differ.

Interactive Tests

request = Request.new(Method.post(), "https://example.com/items?q=1")
assert request.method == Method.post()
assert Request.path(request) == "/items"
assert Request.header(request, "Content-Type") == Maybe.None
assert Context.deadline(request.context) == Maybe.None
refute Request.canceled?(request)
fn with_context(request: Request, context: Context): Request

type function on Request

The same request under a different context.

Interactive Tests

deadline = Context.with_deadline(Context.root(), Instant.from_seconds(1))
request = Request.with_context(Request.new(Method.get(), "/"), deadline)
assert Context.deadline(request.context) == Maybe.Some(Instant.from_seconds(1))
fn path(request: Request): String

type function on Request

The URL’s path, with no query string and no fragment.

Interactive Tests

assert Request.path(Request.new(Method.get(), "/a/b?q=1#frag")) == "/a/b"
assert Request.path(
  Request.new(Method.get(), "https://example.com/a?q=1")
) == "/a"
fn query(request: Request): Query

type function on Request

The URL’s query parameters, case-sensitively.

Interactive Tests

request = Request.new(Method.get(), "/items?userId=7&Tag=a&tag=b")
assert Query.to_list(Request.query(request)) == [
  ("userId", "7"),
  ("Tag", "a"),
  ("tag", "b"),
]
assert Query.get(Request.query(request), "tag") == Maybe.Some("b")
refute Query.has?(Request.query(request), "userid")
fn header(request: Request, name: String): Maybe<String>

type function on Request

The first value of a header field, or Maybe.None when absent. A field present with an empty value answers Maybe.Some("").

Interactive Tests

request = Request.set_header(Request.new(Method.get(), "/"), "X-Trace", "")
assert Request.header(request, "x-trace") == Maybe.Some("")
assert Request.header(request, "X-Other") == Maybe.None
fn cookie(request: Request, name: String): Maybe<Cookie>

type function on Request

The cookie named name from the request’s Cookie field. Request cookies carry no attributes, so only name and value are set.

Interactive Tests

request = Request.add_cookie(
  Request.new(Method.get(), "/"),
  Cookie.new("a", "1"),
)
assert Request.cookie(request, "a") == Maybe.Some(Cookie.new("a", "1"))
assert Request.cookie(request, "z") == Maybe.None
assert Request.cookie(Request.new(Method.get(), "/"), "a") == Maybe.None
fn set_header(request: Request, name: String, value: String): Request

type function on Request

The same request with a header field set to exactly value.

Interactive Tests

request =
  Request.new(Method.get(), "/")
  |> Request.add_header("accept", "text/html")
  |> Request.set_header("ACCEPT", "*/*")
assert Header.values(request.headers, "Accept") == ["*/*"]
fn add_header(request: Request, name: String, value: String): Request

type function on Request

The same request with value appended to a header field.

Interactive Tests

request =
  Request.new(Method.get(), "/")
  |> Request.add_header("accept", "text/html")
  |> Request.add_header("accept", "application/json")
assert Header.values(request.headers, "Accept") == [
  "text/html",
  "application/json",
]
fn add_cookie(request: Request, cookie: Cookie): Request

type function on Request

The same request with a cookie appended to its Cookie field.

Interactive Tests

request =
  Request.new(Method.get(), "/")
  |> Request.add_cookie(Cookie.new("a", "1"))
  |> Request.add_cookie(Cookie.new("b", "2"))
assert Request.header(request, "Cookie") == Maybe.Some("a=1; b=2")
assert Request.cookie(request, "b") == Maybe.Some(Cookie.new("b", "2"))
fn canceled?(request: Request): Bool

type function on Request

True once the peer that sent this request has gone away, or the server it arrived at has begun shutting down.

Always False for a request a client built: nobody can hang up on it.

Interactive Tests

refute Request.canceled?(Request.new(Method.get(), "/"))
fn inspect(value: Request): String

impl Debug.inspect

struct Response {
    status: Status
    headers: Header
    body: Bytes
}

An HTTP response as an immutable value.

This is the type a client receives AND the type a server handler returns. There is no separate handler-response type: they were near-identical, and a handler that cannot produce the value a client observes cannot be tested against one.

Interactive Tests

response = Response.text(Status.ok(), "hi")
assert response.status == Status.from_code(200)
assert Response.body_text(response) == Ok("hi")
assert Response.status_text(response) == "OK"
fn new(status: Status, headers: Header, body: Bytes): Response

type function on Response

Build a response.

Interactive Tests

response = Response.new(Status.no_content())
assert response.status == Status.from_code(204)
assert Response.body_text(response) == Ok("")
assert Response.header(response, "Content-Type") == Maybe.None
fn text(status: Status, body: String): Response

type function on Response

A text/plain; charset=utf-8 response carrying body.

Interactive Tests

response = Response.text(Status.not_found(), "nope")
assert Response.body_text(response) == Ok("nope")
assert Response.header(response, "content-type") == Maybe.Some(
  "text/plain; charset=utf-8"
)
fn status_text(response: Response): String

type function on Response

The reason phrase for the response’s status.

Interactive Tests

assert Response.status_text(Response.new(Status.teapot())) == "I'm a teapot"
assert Response.status_text(Response.new(Status.from_code(299))) == ""
fn header(response: Response, name: String): Maybe<String>

type function on Response

The first value of a header field, or Maybe.None when absent. A field present with an empty value answers Maybe.Some("").

Interactive Tests

response = Response.with_header(Response.new(Status.ok()), "X-Trace", "")
assert Response.header(response, "x-trace") == Maybe.Some("")
assert Response.header(response, "X-Other") == Maybe.None
fn body_text(response: Response): Result<String, Error>

type function on Response

The body decoded as UTF-8 text.

Interactive Tests

assert Response.body_text(Response.text(Status.ok(), "hi")) == Ok("hi")
assert Response.body_text(Response.new(Status.no_content())) == Ok("")
fn cookies(response: Response): List<Cookie>

type function on Response

Every cookie the response sets, parsed from its Set-Cookie fields. Fields that do not parse are skipped.

Interactive Tests

response =
  Response.new(Status.ok())
  |> Response.with_cookie(Cookie.new("a", "1"))
  |> Response.with_cookie(Cookie.new("b", "2"))
assert Response.cookies(response) == [
  Cookie.new("a", "1"),
  Cookie.new("b", "2"),
]
assert Response.cookies(Response.new(Status.ok())) == []
fn with_header(response: Response, name: String, value: String): Response

type function on Response

The same response with a header field set to exactly value.

Interactive Tests

response =
  Response.new(Status.ok())
  |> Response.with_header("x-trace", "abc")
  |> Response.with_header("X-TRACE", "def")
assert Header.values(response.headers, "X-Trace") == ["def"]
fn with_cookie(response: Response, cookie: Cookie): Response

type function on Response

The same response with one more Set-Cookie field.

Interactive Tests

cookie = Struct.update(
  Cookie.new("session", "abc"),
  {path: "/", http_only: True},
)
response = Response.with_cookie(Response.new(Status.ok()), cookie)
assert Response.header(response, "Set-Cookie") == Maybe.Some(
  "session=abc; Path=/; HttpOnly"
)
assert Response.cookies(response) == [cookie]
fn inspect(value: Response): String

impl Debug.inspect

type Client

opaque — construction surface is private to its defining module

An HTTP client. One of the module’s two host handles, because it owns connection pooling and sockets.

fn default(): Client

type function on Client

A client with no client-level timeout. Bound individual calls with a Context deadline instead — that is the bound that composes with the deadline a caller was already under.

fn with_timeout(timeout: Duration): Client

type function on Client

A client whose every call is bounded by timeout, covering connection setup, the round trip, and reading the body.

fn send(client: Client, request: Request): Result<Response, Error>

type function on Client

Send a request and buffer the whole response.

The deadline and cancellation come from request.context, which is the field that exists to carry them; use Request.with_context to change them.

fn get(client: Client, context: Context, url: String): Result<Response, Error>

type function on Client

GET url under context.

fn post(client: Client, context: Context, url: String, content_type: String, body: Bytes): Result<Response, Error>

type function on Client

POST url under context, with an explicit content type.

The content type is a parameter rather than a hardcoded application/octet-stream: a client that cannot say what it is sending cannot post a form or a JSON document.

fn inspect(value: Client): String

impl Debug.inspect

type Server

opaque — construction surface is private to its defining module

A listening HTTP server. The module’s other host handle: it owns the listening socket and the accept loop.

fn serve(addr: String, handler: (Request) -> Response): Result<Server, Error>

type function on Server

Bind addr, start accepting, and return immediately.

Handlers run concurrently — one goroutine per request, with no serialising lock. Use Server.wait to block until the server stops, or Server.run to do both in one call.

A handler returns a Response, the same type a client receives. It has no failure channel because HTTP already has one: answer Status.internal_server_error().

fn run(addr: String, handler: (Request) -> Response): Result<Unit, Error>

type function on Server

Bind addr and serve until the server stops. Blocks.

fn addr(server: Server): String

type function on Server

The address actually bound, which differs from the requested one whenever the port was 0.

fn wait(server: Server): Result<Unit, Error>

type function on Server

Block until the server has stopped serving.

Callable from several places at once; every caller sees the same outcome. A server stopped by Server.shutdown finishes with Ok.

fn shutdown(server: Server, context: Context): Result<Unit, Error>

type function on Server

Cancel every in-flight request’s context, then stop accepting and drain until context’s deadline.

Cancelling first is deliberate: Go’s own Server.Shutdown leaves request contexts alive, so a handler cannot tell the process is going away. This module promises that a handler’s context cancels on shutdown, so Request.canceled? becomes True before the drain.

fn inspect(value: Server): String

impl Debug.inspect