mttp

    HTTP for MoonBit apps that outgrow the built-in client: the module an app starts with, holding a ready client (retries with a budget, deadlines, redirects, cookies, decompression) and a server, on moonbitlang/core/async for native, wasm and js. Typed endpoints and OpenAPI come from gaato/mttp_core, which libraries depend on instead.

    http
    client
    server
    retry
    sse
    async
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    188

    #gaato/mttp

    HTTP for MoonBit apps that need more than the standard library's client: an API client with retries under a budget, deadlines, redirects, cookies, decompression and typed failures, plus an HTTP/1.1 server, on moonbitlang/core/async for native, wasm and js. It is the module an app starts with; an app that calls APIs and serves plain handlers needs no other.

    Its root re-exports what such an app uses from gaato/mttp_core, the part of the family without I/O: the messages (Request, Response, Body, Headers, Method, Url), Handler, request attributes such as remote_addr and background, streamed bodies, Backend, errors, and the client API (Client, get, as_json and the rest). Add gaato/mttp_core to the app as well when it uses the parts this module does not re-export: typed endpoints, the router and OpenAPI documents, multipart forms, server-sent events, rate limits such as WindowLimiter, or the test doubles of gaato/mttp_core/testing.

    Writing a library, such as an SDK for some API? Depend on gaato/mttp_core only: it has the same types and client without any I/O, so it builds on every target, wasm-gc included, and the app chooses how requests are sent.

    Coming from gaato/mttp_async? It is this module, renamed in 0.3; up to 0.2, gaato/mttp was the part without I/O, now gaato/mttp_core. The changelog has a table of what to replace.

    #Install

    moon add gaato/mttp

    #Call an API

    ///|
    pub async fn show_user() -> Unit {
    let api = @mttp.client(base_url="https://api.example.com")
    defer api.close()
    let user = @mttp.get("/users/1").response(@mttp.as_json_value())
    match api.send(user).body {
    Ok(json) => println(json.stringify())
    Err(failure) => println(failure)
    }
    }

    Start it from fn main with run; the app imports neither moonbitlang/core/async nor its experimental opt-in:

    ///|
    fn main {
    @mttp.run(show_user)
    }

    get, post and request build a request; as_json, as_json_value, as_string, as_bytes, as_lines and as_sse say how to read the reply. An HTTP failure, such as a 404 or a body that does not decode, is a value in reply.body. A network failure is raised as a TransportError that says in which phase it happened and whether the request was sent; catch it where the app can do something about it:

    ///|
    /// Fetches a user and prints it, the status that stopped it, or the network
    /// failure.
    pub async fn print_user(id : Int32) -> Unit {
    let api = @mttp.client(base_url="https://api.example.com")
    defer api.close()
    let reply = api.send(
    @mttp.get("/users/\{id}").response(@mttp.as_json_value()),
    ) catch {
    error => {
    println("network: \{error}")
    return
    }
    }
    match reply.body {
    Ok(user) => println(user.stringify())
    Err(failure) => println("HTTP: \{failure}")
    }
    }

    #What client() does by default

    DefaultChange it with
    RetriesUp to two, for idempotent requests on 408, 409, 429, 500, 502, 503 and 504 and on network failures, and for any request that was never sent; Retry-After is honoured, and a server that asks to wait more than 60 s is not retried; backoff is exponential with jitterretry=RetryPolicy::none() or another policy
    Retry budgetRetries stay within 20 % of the requests of the last 10 s, plus 10 per second, so a failing server is not floodedretry_budget=RetryBudget::new(..) or RetryBudget::unlimited()
    Deadlines30 s per attempt, 120 s for the whole call, retries includedtimeout_ms=, total_ms=
    RedirectsUp to 10 hops, never from HTTPS to HTTP, credentials dropped across originsredirect=, redirect=RedirectConfig::none() to turn them off, or .follow_redirects(false) on a request
    CookiesNone keptcookies= a CookieJar
    Decompressiongzip, deflate, br and zstdper request, by setting Accept-Encoding
    Body sizeBuffered bodies up to 64 MiB after decodingmax_body_bytes=
    TLSThe system's rootstrust=PemFile(..)
    Network, clock and timersThe real onesbackend=, clock=, timer=, for tests (see Tests)

    #Cookies

    A Client keeps no cookies unless it is given a jar. With cookies=, every response's Set-Cookie is stored, and matching cookies are sent with each request, on every redirect hop too, so a login that answers with a redirect and a cookie works as in a browser:

    ///|
    /// Logs in, then reads a page that needs the session cookie.
    pub async fn account_page(token : String) -> String? {
    let jar = @mttp.CookieJar::new(@mttp.clock())
    let site = @mttp.client(base_url="https://shop.example", cookies=jar)
    defer site.close()
    ignore(site.send(@mttp.post("/login").body_json({ "token": token })))
    site.send(@mttp.get("/account")).body.to_option()
    }

    The jar belongs to the caller: several Clients may share it, and it outlives them. A Client built with Client::new takes one the same way, cookies=. The jar does not apply browsers' SameSite rules.

    #With an API SDK

    An SDK built on gaato/mttp_core (such as gaato/openai or gaato/anthropic) creates its own Client with the provider's base URL, authentication and retry rules. Give it the I/O with backend(), clock() and timer(); the timer lets it enforce its deadlines. backend() is the network alone, HTTP/1.1 with decompression; the SDK's Client follows redirects itself, as every Client does:

    ///|
    let claude = @anthropic.Anthropic::new(
    api_key~,
    @mttp.backend(),
    @mttp.clock(),
    timer=@mttp.timer(),
    )

    The app's moon.mod lists the SDK and gaato/mttp.

    #Serve

    gaato/mttp/server serves any async (Request) -> Response handler on native and wasm, with bounded connections, headers and bodies and a graceful shutdown:

    @server.serve(request => @mttp.Response::new(200), addr="127.0.0.1:8080")

    A request body arrives as a stream. Body::read_all(max~) reads it into memory and refuses more than max bytes:

    ///|
    /// Echoes a request body of up to 64 KiB; a larger one gets 413.
    pub async fn echo(request : @mttp.Request) -> @mttp.Response noraise {
    let body = request.body.read_all(max=65536) catch {
    TransportError(kind=TooLarge(..), ..) => return @mttp.Response::new(413)
    _ => return @mttp.Response::new(400)
    }
    @mttp.Response::new(200, body=Bytes(body))
    }

    The server binds with SO_REUSEADDR, as Go's and Node's servers do, so a restarted process can listen again while the old one's connections wait out TIME-WAIT. On Linux a second server still cannot take a port that is being listened on; on macOS and the BSDs a bind to a specific address can coexist with an earlier wildcard bind of the same port.

    An app with forms and a cookie session adds the router's same_origin middleware, which refuses state-changing requests from other sites, and sets its cookies SameSite. On JavaScript hosts, gaato/mttp/fetch turns the same handler into a fetch export for Cloudflare Workers, Deno, Bun, Node, Fastly, Vercel and AWS Lambda. gaato/mttp/files serves a directory's files, such as stylesheets and scripts, as one more handler, alone or behind a Router's 404s.

    #A complete app

    A typed endpoint validates its request and documents itself for OpenAPI; a Router serves a set of them as one handler. They come from gaato/mttp_core, so the app's moon.mod lists both modules (moon add gaato/mttp_core), and its moon.pkg imports what it uses:

    import { "gaato/mttp", "gaato/mttp/server", "gaato/mttp_core/endpoint", "gaato/mttp_core/router", "gaato/mttp_core/schema", } pkgtype(kind: "executable")

    ///|
    /// GET /hello/{name} answers with a greeting.
    let hello : @endpoint.ServerEndpoint = @endpoint.endpoint
    .get("/hello/{name}")
    .input(@endpoint.path("name", @schema.string))
    .output(@endpoint.text_out())
    .serve_public(name => Ok("hello, \{name}"))

    ///|
    let app : @router.Router = @router.Router::must([hello])

    ///|
    pub async fn serve_app(addr : String) -> Unit {
    @server.serve(app.handler(), addr~, on_listen=bound => println(bound))
    }

    ///|
    fn main {
    @mttp.run(async fn() { serve_app("127.0.0.1:8080") })
    }

    The repository has runnable examples: examples/get (a command-line GET), examples/serve (a server) and examples/fetch (the same handlers on JavaScript hosts, with a Cloudflare Workers app).

    #Tests

    Code that takes its Client as a parameter can be tested without the network: give it the app's own client() over BackendStub from gaato/mttp_core/testing, which answers with canned responses and records what was sent. backend= replaces only the network, so the Client keeps every default above: retries, deadlines, redirects and cookies. The test package imports gaato/mttp_core/testing (gaato/mttp_core in moon.mod), and a plain test block runs the async code with run, again without importing core/async:

    ///|
    /// Reads a user's name; the Client is a parameter, so a test can pass a stub.
    pub async fn user_name(api : @mttp.Client, id : Int32) -> String? {
    let user = @mttp.get("/users/\{id}").response(@mttp.as_json_value())
    match api.send(user).body {
    Ok({ "name": String(name), .. }) => Some(name)
    _ => None
    }
    }

    ///|
    test "user_name reads the name from the reply" {
    let stub = @testing.BackendStub::new().when(
    request => request.url == "https://api.example/users/1",
    @testing.Canned::bytes(200, body=b"{\"name\":\"Ada\"}"),
    )
    let api = @mttp.client(base_url="https://api.example", backend=stub)
    let name : Ref[String?] = Ref(None)
    @mttp.run(async fn() { name.val = user_name(api, 1) })
    assert_eq(name.val, Some("Ada"))
    assert_eq(stub.sent().length(), 1)
    }

    A test of retries or deadlines also passes clock= a virtual clock, such as VirtualClock from gaato/mttp/runtime; the Client's timers then run on it too, so 30 s pass without waiting.

    Check results after run, as above, not inside it. A failing inspect or assert_eq inside run reports only its error's name, InspectError, without the difference, and moon test --update cannot rewrite its snapshot, because core/async's run_async_main prints nothing more of the error that ends it.

    To test a handler, call it: it is an async function from Request to Response. To test a server end to end, serve it on 127.0.0.1:0 and read the bound address in on_listen.

    Running work concurrently (spawning tasks, task groups, async test) is moonbitlang/core/async's job: import it and list it in the package's allow-experimental-packages. This module does not re-export it, because its public API names no runtime types.

    #Targets

    native, wasm (under moonrun) and js. On js the client uses the host's fetch, which decides proxies, pooling and decompression itself. wasm-gc has no runtime here; use gaato/mttp_core there and provide its runtime ports.

    #Packages

    PackageTargetsPurpose
    gaato/mttpnative, wasm, jsrun, client(), backend(), clock(), timer(), coordination(), and re-exports of the types and client functions an app uses
    backendnative, wasm, jsAsyncBackend: the HTTP/1.1 engine over TCP and TLS with decompression; the host's fetch on js
    servernative, wasmserve: bounded listener, graceful shutdown, background work
    filesnative, wasmstatic_files: a directory's files for GET and HEAD, streamed, with ETag revalidation and paths kept inside the directory
    fetchjsfetch_handler: a handler as a JavaScript fetch export
    runtimenative, wasm, jsClocks, timers, an admission gate and coordination for gaato/mttp_core's runtime ports
    codingsnative, wasm, jsBounded content codecs, Decompress for clients and compress for handlers
    connnative, wasmTCP and TLS dialing, including CONNECT tunnels
    trustnative, wasm, jsTrust: which TLS roots a socket backend accepts

    The module also ships host shims (hosts/), JavaScript files that connect a fetch export to Workers, Deno, Bun, Node, Fastly, Vercel and Lambda; they are not a MoonBit package.

    #License

    Apache-2.0

    AttributeKey

    sttp-shared AttributeMap without Any: per-key slot plus closures.

    Attributes

    Immutable associations of typed keys to local metadata.

    Backend

    using @gaato/mttp_core { trait Backend }

    A transport for one exchange: it sends the request and returns the response head with a body the caller owns, reads to EOF or closes. HTTP statuses are responses, not errors; a failure to get one is a TransportError carrying the phase it happened in.

    A backend refuses an invalid request before any I/O (Request::validate, SendOptions::validate), moves raw bytes (no redirects, retries, cookies or content decoding), and propagates cancellation unchanged. After close, new sends fail with TransportError(Closed, Pool).

    Body

    using @gaato/mttp_core { type Body }

    A request body: bytes in memory, or a stream read as the request is sent.

    test {
    let request = @mttp_core.Request::post("/upload").body_bytes(b"abc")
    assert_eq(request.body, Bytes(b"abc"))
    assert_eq(request.body.length(), Some(3L))
    assert_true(request.body.is_replayable())
    }

    BodyStream

    A stream with nonempty chunks, sticky EOF and idempotent close.

    Client

    A backend plus shared request defaults, policies and injected timing services. Client::send is the only sending path: it applies the defaults, admission, deadlines, decoded-body limits, redirects, cookies and retries, and maps each plan onto the SendOptions the backend receives.

    CookieJar

    An RFC 6265bis-22 cookie store for an HTTP client: store takes the Set-Cookie headers of a response, header_for builds the Cookie header of a request.

    It is a client's jar, not a browser's: HttpOnly cookies are sent like any other, and SameSite is kept but not enforced, since a client has no notion of a cross-site request. Without the Public Suffix List it rejects only a Domain of a single label (com, localhost); pass is_public_suffix to reject more. An IP-address host only gets host-only cookies. Hosts must arrive in ASCII form, so a Domain with other characters is ignored.

    A host written with the root dot (example.com.) is a different host from example.com, as in browsers: neither sets nor receives the other's cookies. The public-suffix checks read a Domain without its root dot, so Domain=com. is refused as a single label, and a Domain with an empty label (a..b, com..) is ignored.

    test {
    let jar = @cookie.CookieJar::new(@sim.FakeClock::new())
    jar.store(
    @mttp_core.Headers::of([("Set-Cookie", "sid=1; Path=/; Secure")]),
    "https://example.com/login",
    )
    assert_eq(jar.header_for("https://example.com/"), Some("sid=1"))
    assert_eq(jar.header_for("http://example.com/"), None)
    }

    Handler

    A server-side handler. Adapters (mttp-net serve, fetch_handler) and the Router all speak this type. WebSocket Upgrade is not in v0.x; a later 101 response can carry the upgrade callback in an attribute.

    Headers

    using @gaato/mttp_core { type Headers }

    Immutable, ordered header pairs with ASCII lowercase names. Equality compares normalized pairs, including their order.

    test {
    let headers = @mttp_core.Headers::of([("Accept", "text/plain")])
    assert_eq(headers.get("ACCEPT"), Some("text/plain"))
    }

    Method

    using @gaato/mttp_core { type Method }

    Standard HTTP methods and extension tokens.

    RedirectConfig

    How a Client (its redirect argument) or a FollowRedirects wrapper follows redirects. Build it from RedirectConfig::default() with a record update, or take RedirectConfig::none(); the field set is frozen.

    Reply

    A decoded response, preserving the actual HTTP and request metadata.

    Request

    using @gaato/mttp_core { type Request }

    url is the request target as given: an absolute URL or a relative reference ("/users/1?x=2"). Backends parse it once at send time (Kind::Invalid, phase Pool, so it is unsent); Client joins a relative target onto its base URL by prefix (sdk-runtime semantics, not RFC 3986).

    RequestPlan

    A wire request, response description and per-request options.

    Response

    using @gaato/mttp_core { type Response }

    One response type for both directions. A server closes the connection after a response carrying Connection: close (413 does). attributes is the response-side channel: FollowRedirects records the final URL and history there, and the router records the matched route template.

    ResponseFailure

    A received response that is not the value the description asked for. It is a value: readers return it as Err, and transport failures stay raised TransportErrors.

    ResponsePlan

    An immutable response description. Selection after the head arrives decides the actual lifetime; the marker describes possible streaming.

    RetryBudget

    A cap on retries as a share of recent requests, shared by every send of a Client. When a server fails, every caller's retries would otherwise multiply its load just as it tries to recover.

    Each request deposits one token; each retry withdraws one. A retry goes ahead while the withdrawals of the last window_ms stay below min_per_second times the window in seconds plus percent of the deposits in the window. Time comes from the Client's clock. Pass the same budget to several Clients to share it.

    RetryPolicy

    A retry policy for Client: a RetryRule of gaato/mttp_core/client/retry applied to HTTP outcome snapshots.

    Start from a preset — conservative() (the default), none() — or from any rule with from_rule, and adjust it with with_max_retries, allow_non_idempotent or a struct update of rule.

    SendOptions

    The per-send settings a caller hands a Backend. Build them from SendOptions::default() with a record update; the field set is frozen.

    StreamBody

    A request body read once from a BodyStream, with its length when known.

    A transport sends a known length as Content-Length and an unknown one, on HTTP/1.1, as Transfer-Encoding: chunked. The transport that sends the body takes it (take) and closes the stream when it is done with it, sent or failed; a stream that is never taken stays open and is its creator's to close. Two stream bodies are equal only when they are the same one.

    TransportError

    A failure with its category, phase and diagnostic description.

    Trust

    using @gaato/mttp/trust { type Trust }

    TLS peer verification for socket backends. JavaScript fetch uses the host's trust store and ignores this setting. PemFile replaces system roots; supply a combined PEM bundle to trust private and system roots.

    Url

    using @gaato/mttp_core { type Url }

    A URI reference (RFC 3986 §4.1), held normalised and percent-encoded.

    Url::parse lower-cases the scheme and host, writes an IPv6 address in RFC 5952's canonical hex form (a dotted-quad tail too, as WHATWG URL does), drops a scheme's default port (http 80, https 443, ws 80, wss 443, ftp 21), percent-encodes what the WHATWG URL Standard's sets encode (non-ASCII as UTF-8), and removes dot segments from the path of a reference with a scheme. Existing escapes are kept as written. No scheme makes the reference relative; no host means no authority, which is not an empty host. A host is ASCII only: internationalised domain names (IDNA) are refused, so pass their xn-- form.

    test {
    let base = @mttp_core.Url::parse("HTTPS://Example.COM:443/a/b?x=1")
    assert_eq(base.to_string(), "https://example.com/a/b?x=1")
    let next = base.resolve(@mttp_core.Url::parse("../c d"))
    assert_eq(next.to_string(), "https://example.com/c%20d")
    }

    UrlError

    using @gaato/mttp_core { type UrlError }

    Why a string is not a URI reference this package accepts.

    as_bytes

    Buffers successful bodies; non-2xx responses are bounded failure values.

    as_lines

    Streams LF-separated UTF-8 lines, stripping CR in CRLF and replacing malformed UTF-8. A final nonempty unterminated line is delivered; a lone CR is data. Limits content bytes per line (default one MiB), retaining at most that limit plus a pending CR. Callback errors close the body and never retry an already-delivered stream.

    as_sse

    Reads bounded SSE events until EOF or a protocol stop predicate matches. An event over max_event_bytes raises TooLarge(limit) in phase Body as soon as the chunk holding it is read, after the events before it in that chunk were delivered.

    as_string

    Decodes UTF-8 lossily while preserving HTTP failures as values.

    backend

    The real network as a Backend: HTTP/1.1 over TCP and TLS with connection reuse and decompressed responses. It is only the I/O: the Client built over it follows redirects, and keeps cookies when given a jar, as Client::new does over any backend. Pass it, with clock and timer, to a library that builds its own Client, such as an API SDK:

    let claude = @anthropic.Anthropic::new(
    api_key~,
    @mttp.backend(),
    @mttp.clock(),
    timer=@mttp.timer(),
    )

    The libraries keep their own defaults (base URL, authentication, retries) and follow redirects through Client::new; this only supplies the I/O. Closing the library's Client closes the connections.

    background

    let background :
    AttributeKey
    [(async () -> Unit) -> Unit]

    Adapter-owned scheduling for work that may outlive the response. Invoke during request handling; the adapter owns completion and shutdown policy.

    client

    A Client over the real network with the defaults an API client wants:

    • retry (default RetryPolicy::conservative()): up to two retries of an idempotent request on 408, 409, 429, 500, 502, 503 or 504 or a network failure, and of any request that was never sent, honouring Retry-After (a server that asks to wait more than 60 s is not retried); set it to RetryPolicy::none() to turn retries off.
    • retry_budget (default RetryBudget::new()): retries stay within 20 % of the last 10 s of requests plus 10 per second.
    • timeout_ms (default 30 s) for each attempt, and total_ms (default 120 s) for the whole call, retries and their waits included.
    • redirect (default RedirectConfig::default()): up to 10 hops, no HTTPS-to-HTTP downgrade, credentials dropped across origins; RedirectConfig::none() turns redirects off, and a request opts out with RequestPlan::follow_redirects(false).
    • Responses are decompressed (gzip, deflate, br, zstd) and buffered bodies are limited to 64 MiB after decoding (max_body_bytes).
    • cookies (no jar by default): a CookieJar that stores every response's Set-Cookie and sends matching cookies, on each redirect hop too. The jar belongs to the caller, so several Clients may share it and it outlives them:

    let jar = @mttp.CookieJar::new(@mttp.clock())

    let site = @mttp.client(cookies=jar)

    base_url is joined to each relative request target, headers are sent with every request, limiter admits requests (no limit by default), trust chooses the TLS roots and config tunes the connection pool. Close the Client when done; that closes its connections.

    backend, clock and timer replace the real network, the system clock and the event loop's timers, so a test gets the same Client, with all the defaults above, over a stub such as @testing.BackendStub and a virtual clock. timer defaults to a timer on clock, so a virtual clock drives the deadlines too; without backend, the network backend runs its connection deadlines and pool expiry on the same clock and timer. A given backend is used as it is: trust and config configure only the network backend this builds when backend is omitted, and are ignored otherwise.

    clock

    The system's wall and monotonic clock, for a Client built by hand.

    close_on_shutdown

    let close_on_shutdown :
    AttributeKey
    [Bool]

    Set on a response by its handler: a streamed response with this attribute set to true is closed when the server starts to shut down, instead of being drained; for endless streams such as server-sent events, whose clients reconnect. The server stops pulling the body, closes it and ends the response at the point the stream had reached (a chunked body with its last chunk, otherwise by closing the connection), so shutdown does not wait out its drain time for a stream that never ends by itself.

    test {
    let response = @mttp_core.Response::new(200)
    let response = {
    ..response,
    attributes: response.attributes.put(@mttp_core.close_on_shutdown, true),
    }
    assert_eq(response.attributes.get(@mttp_core.close_on_shutdown), Some(true))
    }

    coordination

    Signals on core/async's event loop, for the parts of gaato/mttp_core that wait without doing I/O themselves, such as @endpoint.EventStream::channel.

    get

    Creates a GET request with the default Result-valued string reader.

    post

    Creates a POST request with the default Result-valued string reader.

    read_bounded

    async fn read_bounded(stream : &
    BodyStream
    , limit : Int) -> Bytes raise
    TransportError

    Reads and closes a stream, refusing the first chunk that crosses limit. Subtraction before comparison prevents accumulated-length overflow.

    remote_addr

    Set by server adapters.

    replay_safe

    A caller's statement that resending this request is safe even after it was written (discord's REPLAY_SAFE_HEADER, without a wire header).

    request

    Pairs a wire request with a UTF-8 string reader accepting successful statuses.

    run

    fn run(main : async () -> Unit) -> Unit

    Runs main on moonbitlang/core/async's event loop and returns when it and every task it started have finished. An app calls it from a plain fn main, so it needs neither an import of core/async nor its experimental opt-in:

    fn main {
    @mttp.run(async fn() { ... })
    }

    timer

    Timers on core/async's event loop, which let a Client enforce its per-attempt and total deadlines.