This version of the module is deprecated: gaato/mttp 0.2 and earlier is the I/O-free core, now gaato/mttp_core. From 0.3, gaato/mttp is the module an app adds (formerly gaato/mttp_async). See https://github.com/gaato/mttp

    mttp

    HTTP for MoonBit without I/O, for libraries such as SDKs: the types, an HTTP/1.1 codec and engines, a client with retries under a budget, deadlines, rate limits, redirects and cookies, SSE, multipart, and typed endpoints with a router and OpenAPI. Apps add gaato/mttp_async.

    http
    client
    server
    router
    sans-io
    openapi
    endpoint
    sse
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    188

    #gaato/mttp

    HTTP for MoonBit without I/O: the types, an HTTP/1.1 codec and connection engines, a client with retries under a budget, deadlines, rate limits, redirects and cookies, server-sent events and multipart bodies, and typed endpoints with a router and OpenAPI documents. It depends on moonbitlang/core alone and builds on wasm-gc, wasm, js and native, with the same tests on each.

    Building an app? Start with gaato/mttp_async: it sends requests over the network with sensible defaults and re-exports the types and client functions an app uses most. Add gaato/mttp beside it for the rest: typed endpoints, the router, OpenAPI documents, multipart bodies, rate limits and test stubs. Libraries, such as an SDK for some API, depend on this module alone, so the app decides how requests are sent and tests run without a network.

    Coming from gaato/http or gaato/http-async? They are now gaato/mttp and gaato/mttp_async; the changelog says what to rename.

    The design follows the split of Scala's sttp family: a model, a client over swappable backends, and endpoint descriptions interpreted as a server, a client and documentation.

    #Install

    moon add gaato/mttp

    #Requests and backends

    Values are immutable: each builder returns a copy.

    ///|
    test "describe a request" {
    let request = @mttp.Request::post("https://api.example.com/v1/items")
    .query("dry_run", "true")
    .bearer("token")
    .body_json({ "name": "first" })
    assert_eq(request.url, "https://api.example.com/v1/items?dry_run=true")
    assert_eq(request.headers.get("content-type"), Some("application/json"))
    assert_false(@mttp.is_idempotent(request))
    }

    A Backend sends one exchange and returns the response. Every failure is a TransportError whose phase says how far the exchange got, so is_unsent tells whether resending a non-idempotent request is safe.

    #On another runtime

    gaato/mttp_async provides the backends for moonbitlang/core/async, on native, wasm and js (through the host's fetch); wasm-gc has no runtime there. A program on wasm-gc or on another runtime uses this module directly: implement Backend over that runtime's HTTP client, or implement only the ports in rt and reuse the h1client engine, then pass the backend, a Clock and, for deadlines, a Timer to Client::new. The client, typed endpoints and server-sent events work unchanged. A framework on the registry module moonbitlang/async can share a program with gaato/mttp_async: from 0.22.6 on, that module runs on moonbitlang/core/async's runtime, so an app that needs it requires moonbitlang/async@0.22.6 or later.

    #A client in a library

    A library takes a Client and leaves the backend to its caller. In a test, the backend is a function:

    ///|
    /// Reads an item's name from a JSON API.
    async fn item_name(client : @client.Client, id : Int32) -> String? {
    let reply = client.send(
    @client.get("/items/\{id}").response(@client.as_json_value()),
    )
    match reply.body {
    Ok({ "name": String(name), .. }) => Some(name)
    _ => None
    }
    }

    ///|
    test "a library call against a test backend" {
    let seen = Ref("")
    let backend = @client.from_fn((request, _) => {
    seen.val = request.url
    @mttp.Response::new(
    200,
    headers=@mttp.Headers::of([("content-type", "application/json")]),
    body=Bytes(b"{\"name\": \"lamp\"}"),
    )
    })
    let client = @client.Client::new(
    backend,
    @sim.FakeClock::new(),
    base_url="https://api.example.com",
    )
    assert_eq(@driver.run_sync(() => item_name(client, 7)), Some("lamp"))
    assert_eq(seen.val, "https://api.example.com/items/7")
    }

    gaato/mttp/driver is experimental, so a test package that calls @driver.run_sync lists it in its moon.pkg: options("allow-experimental-packages": [ "gaato/mttp/driver" ]).

    Client::new sends nothing it is not told to: retries, deadlines and rate limits are opt-in here (retry=, timeout_ms=, limiter=), while gaato/mttp_async's client() turns retries and deadlines on for apps. Redirects are followed by default, over any backend, a test function included (redirect= configures them, RedirectConfig::none() turns them off), and cookies are kept when the Client is given a jar (cookies=). The gaato/mttp/client/ratelimit section below shows a limiter that follows a server's quota headers.

    Every failure prints as a readable message: "\{error}" on a TransportError, or failure.to_string() on a ResponseFailure, summarises it (status, phase, cause) for logs and UIs.

    #Typed endpoints

    One definition gives a client call, a server handler behind a router and an OpenAPI document:

    ///|
    test "one definition for client, server and docs" {
    let hello = @endpoint.endpoint
    .get("/hello/{name}")
    .input(@endpoint.path("name", @schema.string))
    .output(@endpoint.text_out())
    .operation_id("hello")
    let router = @router.Router::new([
    hello.serve_public(name => Ok("hello, \{name}")),
    ])
    let client = @client.Client::new(router, @sim.FakeClock::new())
    @driver.run_sync(() => {
    let reply = hello.request((), "moon").send(client)
    assert_true(reply.body is Ok(Ok("hello, moon")))
    })
    let spec = @openapi.to_openapi(
    [hello.doc()],
    @apispec.Info::new("Hello", "1"),
    )
    assert_eq(@openapi.check_documented(spec, "/hello/{name}", Get, 200), None)
    }

    The router is itself a Backend, as above, and its handler() is what gaato/mttp_async's server or a JavaScript fetch export serves.

    #Packages

    PackageWhat it holds
    gaato/mttpThe vocabulary: methods, headers, URLs, requests, responses and bodies, Backend and SendOptions, TransportError, URL, form and HTTP-date (parse and format) helpers
    gaato/mttp/clientRequest and response plans with Result-valued readers, the Client loop (defaults, admission, deadlines, decoded-body limits, redirects, cookies, retries and the RetryBudget), FollowRedirects, Paginator, Retry-After and Link helpers
    gaato/mttp/client/retryRetry rules and decisions as data
    gaato/mttp/client/ratelimitBounded window state, pacing, global quotas and header-driven admission
    gaato/mttp/client/cookiesCookies, the cookie-jar backend wrapper a Client with cookies= uses, for stacks built by hand
    gaato/mttp/sseA bounded text/event-stream parser, reader and encoder
    gaato/mttp/multipartA multipart/form-data writer with streamed parts
    gaato/mttp/cookieAn RFC 6265 cookie jar on an injected clock
    gaato/mttp/jsonkitPresence, an object builder, field decoders and open enums for JSON models
    gaato/mttp/schemaText, JSON and body codecs that carry their JSON Schema, and validators
    gaato/mttp/endpointTyped endpoint inputs and outputs, client calls and bounded server dispatch
    gaato/mttp/routerPath and method dispatch, server policy, middleware, CORS; also a Backend
    gaato/mttp/openapiOpenAPI documents projected from endpoints, reference checks and a docs endpoint
    gaato/mttp/apispecOpenAPI document records with their JSON codecs (3.1, and 3.2's fields)
    gaato/mttp/endpoint/testkitTyped endpoint stubs and the definition checker
    gaato/mttp/testingBackendStub, canned answers and fault injection for client tests
    gaato/mttp/h1The sans-IO HTTP/1.1 codec: bounded decoders, strict parsers that refuse request smuggling, wire writers
    gaato/mttp/h1clientThe HTTP/1.1 client engine over the rt ports: connection pool, bounded heads and bodies, phase deadlines, proxies
    gaato/mttp/h1serverThe HTTP/1.1 server engine over the rt ports for one accepted connection
    gaato/mttp/rtThe ports a runtime provides: Clock, Timer, Gate, Conn, Dialer, Signal, Coordination
    gaato/mttp/simIn-memory doubles of the ports and the contract checks every implementation passes
    gaato/mttp/driverA deterministic task driver with virtual time and run_sync for plain tests (experimental: it relies on compiler intrinsics)

    #License

    Apache-2.0

    Handler

    type Handler = async (Request) -> Response noraise

    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.

    Backend

    pub(open) trait Backend {
    async fn send(Self, Request, SendOptions) -> Response raise TransportError
    fn close(Self) -> Unit = _
    }

    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).

    BodyStream

    pub(open) trait BodyStream {
    async fn read_some(Self) -> Bytes? raise TransportError
    fn close(Self) -> Unit
    }

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

    UriPart

    pub(open) trait UriPart {
    fn write_uri(Self, UriBuilder) -> Unit
    }

    Types which can contribute URI data. Custom implementations should call write_encoded, or delegate to another UriPart, for untrusted values.
    impl UriPart for Bool
    impl UriPart for Int
    impl UriPart for Int64
    impl UriPart for Double
    impl UriPart for String
    impl UriPart for Option[T]

    TransportError

    pub(all) suberror TransportError {
    TransportError(kind~ : Kind, phase~ : Phase, detail~ : String)
    } derive(
    Debug
    )

    A failure with its category, phase and diagnostic description.

    TransportError::is_unsent

    fn TransportError::is_unsent(self : TransportError) -> Bool

    Whether the failure proves no request bytes reached the origin: it happened in phase Pool or Dial (CONTRACT.md, Backend rule 2), or it is a connect failure or a proxy's refusal, which precede the request. Whether resending is worthwhile is a separate question: an Invalid request is unsent and fails the same way again.

    TransportError::message

    fn TransportError::message(self : TransportError) -> String

    A human-readable diagnostic including category and phase.

    TransportError::output

    fn TransportError::output(self : TransportError, logger : &Logger) -> Unit

    The message, as \{error} interpolation writes it.

    TransportError::to_repr

    Formats a transport failure for diagnostics.

    TransportError::to_string

    fn TransportError::to_string(self : TransportError) -> String

    The message, as \{error} interpolation writes it.

    UrlError

    pub(all) suberror UrlError {
    Invalid(String)
    } derive(Eq,
    Debug
    )

    Why a string is not a URI reference this package accepts.
    impl Show for UrlError

    UrlError::equal

    fn UrlError::equal(UrlError, UrlError) -> Bool

    Compares refusals by their reason.

    UrlError::not_equal

    fn UrlError::not_equal(x : UrlError, y : UrlError) -> Bool

    Compares refusals by their reason.

    UrlError::output

    fn UrlError::output(self : UrlError, logger : &Logger) -> Unit

    The message, as \{error} interpolation writes it.

    UrlError::to_repr

    Debug representation of a refused URL.

    UrlError::to_string

    fn UrlError::to_string(self : UrlError) -> String

    The message, as \{error} interpolation writes it.

    AttributeKey

    pub struct AttributeKey[T] {
    // private fields
    }

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

    AttributeKey::name

    fn[T] AttributeKey::name(self : AttributeKey[T]) -> String

    Returns the key's diagnostic name.

    AttributeKey::new

    fn[T] AttributeKey::new(name : String) -> AttributeKey[T]

    Creates a distinct typed key; identical names do not alias keys.

    Attributes

    pub struct Attributes {
    // private fields
    }

    Immutable associations of typed keys to local metadata.

    Attributes::empty

    fn Attributes::empty() -> Attributes

    Creates an empty attribute collection.

    Attributes::get

    fn[T] Attributes::get(self : Attributes, key : AttributeKey[T]) -> T?

    Looks up a value with its original type, without consuming it.

    Attributes::put

    fn[T] Attributes::put(self : Attributes, key : AttributeKey[T], value : T) -> Attributes

    Associates a value with a key on an independent copy.

    Body

    pub(all) enum Body {
    Empty
    Bytes(Bytes)
    Stream(StreamBody)
    } derive(Eq,
    Debug
    )

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

    test {
    let request = @mttp.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())
    }

    Body::as_bytes

    fn Body::as_bytes(self : Body) -> Bytes?

    The bytes of a body held in memory, or None for a stream (as reqwest's Body::as_bytes).

    Body::equal

    fn Body::equal(Body, Body) -> Bool

    Equality: equal bytes, or the same StreamBody.

    Body::is_replayable

    fn Body::is_replayable(self : Body) -> Bool

    Whether a sender may send this body (again): always for bytes, and for a stream only while no transport has taken it.

    Body::length

    fn Body::length(self : Body) -> Int64?

    The number of bytes the body will send, when it is known: the length of the bytes, or the length a stream was declared with.

    Body::not_equal

    fn Body::not_equal(x : Body, y : Body) -> Bool

    Equality: equal bytes, or the same StreamBody.

    Body::read_all

    async fn Body::read_all(self : Body, max~ : Int) -> Bytes raise TransportError

    Reads the whole body into memory, refusing more than max bytes, as a server handler reads a request body or a backend a response body. A body over max raises TooLarge(limit=max) in phase Body; a negative max raises Invalid. A stream is taken and closed in every case, read or refused, so the body cannot be read again.

    test {
    let request = @mttp.Request::post("/echo").body_bytes(b"hello")
    assert_eq(@driver.run_sync(() => request.body.read_all(max=1024)), b"hello")
    let refused = @driver.run_sync(() => {
    request.body.read_all(max=4) catch {
    @mttp.TransportError(kind=TooLarge(limit=4), ..) => b"too large"
    error => raise error
    }
    })
    assert_eq(refused, b"too large")
    }

    Body::to_repr

    Debug representation of a body; a stream shows its length, not its bytes.

    BodyTiming

    pub(all) enum BodyTiming {
    Exchange(max_bytes~ : Int)
    Stream(idle_ms~ : Int?)
    } derive(Eq,
    Debug
    )

    How the caller will consume the response body, which decides what the attempt deadline covers and how many bytes the backend admits.

    test {
    let options = { ..@mttp.SendOptions::default(), timeout_ms: Some(5000), }
    assert_eq(options.body, Exchange(max_bytes=64 * 1024 * 1024))
    let streaming = { ..options, body: Stream(idle_ms=Some(30000)), }
    assert_eq(streaming.timeout_ms, Some(5000))
    }

    BodyTiming::equal

    fn BodyTiming::equal(BodyTiming, BodyTiming) -> Bool

    Compares body timings.

    BodyTiming::not_equal

    fn BodyTiming::not_equal(x : BodyTiming, y : BodyTiming) -> Bool

    Compares body timings.

    BodyTiming::to_repr

    Formats a body timing for diagnostics.

    Headers

    pub struct Headers {
    // private fields
    } derive(Eq,
    Debug
    )

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

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

    Headers::add

    fn Headers::add(self : Headers, name : String, value : String) -> Headers

    Appends one pair, retaining all existing values.

    Headers::contains

    fn Headers::contains(self : Headers, name : String) -> Bool

    Reports whether at least one value exists for a name.

    Headers::each

    fn Headers::each(self : Headers, f : (String, String) -> Unit raise?) -> Unit raise?

    Visits a snapshot of normalized pairs in insertion order.

    Headers::empty

    fn Headers::empty() -> Headers

    Creates an empty header collection.

    Headers::equal

    fn Headers::equal(Headers, Headers) -> Bool

    Explicit equality methods for normalized, ordered pairs.

    Headers::get

    fn Headers::get(self : Headers, name : String) -> String?

    Returns the first value for a name, ignoring ASCII case.

    Headers::get_all

    fn Headers::get_all(self : Headers, name : String) -> Array[String]

    Returns a fresh array containing every matching value in insertion order.

    Headers::iter

    fn Headers::iter(self : Headers) -> Iter[(String, String)]

    Iterates over a snapshot of normalized pairs in insertion order.

    Headers::length

    fn Headers::length(self : Headers) -> Int

    Returns the number of pairs, counting repeated names separately.

    Headers::not_equal

    fn Headers::not_equal(x : Headers, y : Headers) -> Bool

    Explicit equality methods for normalized, ordered pairs.

    Headers::of

    fn Headers::of(pairs : ArrayView[(String, String)]) -> Headers

    Copies pairs in their original order and normalizes their names.

    Headers::remove

    fn Headers::remove(self : Headers, name : String) -> Headers

    Removes all pairs for a name, ignoring ASCII case.

    Headers::set

    fn Headers::set(self : Headers, name : String, value : String) -> Headers

    Replaces all values for a name at its first position, or appends a new name.

    Headers::to_repr

    Debug representation of the header pairs.

    Headers::validate

    fn Headers::validate(self : Headers) -> Unit raise TransportError

    Checks header names and values before serialization. Empty names and non-token name characters are refused; values cannot contain controls other than horizontal tab, or characters outside the HTTP byte range.

    Kind

    pub(all) enum Kind {
    Invalid
    Connect
    Timeout(ms~ : Int)
    Network
    Protocol
    Proxy(status~ : Int)
    TooLarge(limit~ : Int)
    TooManyRedirects(max~ : Int)
    RedirectRefused
    Unreplayable
    Closed
    } derive(Eq,
    Debug
    )

    Transport failure category, distinct from an HTTP status response.

    Kind::equal

    fn Kind::equal(Kind, Kind) -> Bool

    Compares failure categories and their associated limits.

    Kind::not_equal

    fn Kind::not_equal(x : Kind, y : Kind) -> Bool

    Compares failure categories and their associated limits.

    Kind::to_repr

    Formats a failure category for diagnostics.

    Method

    pub(all) enum Method {
    Get
    Head
    Post
    Put
    Delete
    Connect
    Options
    Trace
    Patch
    Other(String)
    } derive(Eq, Hash,
    Debug
    )

    Standard HTTP methods and extension tokens.
    impl Show for Method

    Method::equal

    fn Method::equal(Method, Method) -> Bool

    Compares HTTP methods.

    Method::hash

    fn Method::hash(self : Method) -> Int

    Hashes an HTTP method.

    Method::hash_combine

    fn Method::hash_combine(Method, Hasher) -> Unit

    Hashes an HTTP method.

    Method::is_idempotent

    fn Method::is_idempotent(self : Method) -> Bool

    Reports methods defined as idempotent by HTTP semantics.

    Method::not_equal

    fn Method::not_equal(x : Method, y : Method) -> Bool

    Compares HTTP methods.

    Method::output

    fn Method::output(self : Method, logger : &Logger) -> Unit

    Writes the HTTP method token.

    Method::parse

    fn Method::parse(s : String) -> Method

    Parses a case-sensitive method token, retaining unknown tokens verbatim.

    Method::to_repr

    Formats an HTTP method for diagnostics.

    Method::to_string

    fn Method::to_string(self : Method) -> String

    Returns the wire method token.

    Phase

    pub(all) enum Phase {
    Pool
    Dial
    Write
    Head
    Body
    Unknown
    } derive(Eq,
    Debug
    )

    The exchange phase in which a failure occurred.

    Phase::equal

    fn Phase::equal(Phase, Phase) -> Bool

    Compares exchange phases.

    Phase::not_equal

    fn Phase::not_equal(x : Phase, y : Phase) -> Bool

    Compares exchange phases.

    Phase::to_repr

    Formats an exchange phase for diagnostics.

    Request

    pub(all) struct Request {
    meth : Method
    url : String
    headers : Headers
    body : Body
    attributes : Attributes
    }

    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).

    Request::attribute

    fn[T] Request::attribute(self : Request, key : AttributeKey[T], value : T) -> Request

    Associates typed local metadata with a copy; it is never sent as a header.

    Request::basic

    fn Request::basic(self : Request, username : String, password : String) -> Request

    Sets Basic authorization from UTF-8 username and password bytes. A colon separates the username from the password; it cannot be part of a username.

    Request::bearer

    fn Request::bearer(self : Request, token : String) -> Request

    Sets a bearer Authorization header on a copy.

    Request::body_bytes

    fn Request::body_bytes(self : Request, b : Bytes, content_type? : String) -> Request

    Sets a replayable body on a copy, optionally replacing its content type.

    Request::body_form

    fn Request::body_form(self : Request, pairs : ArrayView[(String, String)]) -> Request

    Encodes pairs with form_urlencode as the body and supplies application/x-www-form-urlencoded only if content-type is absent.

    Request::body_json

    Serializes JSON as UTF-8 with the application/json content type.

    Request::body_stream

    fn Request::body_stream(self : Request, s : &BodyStream, length? : Int64, content_type? : String) -> Request

    Sets a stream that can be acquired once, optionally enforcing its length.

    Request::body_text

    fn Request::body_text(self : Request, s : String) -> Request

    Sets a UTF-8 text body, replacing unpaired surrogates with U+FFFD.

    Request::delete

    fn Request::delete(url : String) -> Request

    Creates a DELETE request.

    Request::get

    fn Request::get(url : String) -> Request

    Creates a GET request.

    Request::head

    fn Request::head(self : Request) -> RequestHead

    Extracts request metadata.

    Request::header

    fn Request::header(self : Request, name : String, value : String) -> Request

    Returns a copy with an additional header, preserving duplicate fields.

    Request::new

    fn Request::new(meth : Method, url : String) -> Request

    Creates a request with empty headers, attributes and body.

    Request::options

    fn Request::options(url : String) -> Request

    Creates an OPTIONS request.

    Request::patch

    fn Request::patch(url : String) -> Request

    Creates a PATCH request.

    Request::post

    fn Request::post(url : String) -> Request

    Creates a POST request.

    Request::put

    fn Request::put(url : String) -> Request

    Creates a PUT request.

    Request::query

    fn Request::query(self : Request, name : String, value : String) -> Request

    Appends a percent-encoded query parameter to a copy.

    Request::set_header

    fn Request::set_header(self : Request, name : String, value : String) -> Request

    Returns a copy replacing every header of this name at its first position.

    Request::validate

    fn Request::validate(self : Request) -> Unit raise TransportError

    Validates the method token, raw URL, headers and declared stream length before a backend sends it. A stream body declaring a negative length is refused here as StreamBody::take would refuse it, and its source is closed, since no sender will take it.

    RequestHead

    pub(all) struct RequestHead {
    meth : Method
    url : String
    headers : Headers
    } derive(Eq,
    Debug
    )

    Request metadata without its body or local attributes.

    RequestHead::equal

    fn RequestHead::equal(RequestHead, RequestHead) -> Bool

    Compares request metadata.

    RequestHead::not_equal

    fn RequestHead::not_equal(x : RequestHead, y : RequestHead) -> Bool

    Compares request metadata.

    RequestHead::to_repr

    Formats request metadata for diagnostics.

    Response

    pub(all) struct Response {
    status : Int
    headers : Headers
    body : Body
    attributes : Attributes
    }

    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.

    Response::close

    fn Response::close(self : Response) -> Unit

    Releases a streaming response body; empty and byte bodies need no cleanup.

    Response::close_if_unclaimed

    fn Response::close_if_unclaimed(self : Response) -> Unit

    Closes a discarded response only while its streamed reader is unclaimed. StreamBody::take transfers ownership to the returned reader, so a lazy middleware wrapper that took it remains responsible for closing it.

    Response::closes_connection

    fn Response::closes_connection(self : Response) -> Bool

    Reports a close token across all Connection header values.

    Response::head

    fn Response::head(self : Response) -> ResponseHead

    Extracts response metadata.

    Response::new

    fn Response::new(status : Int, headers? : Headers, body? : Body) -> Response

    Creates a response, defaulting to empty headers and body.

    ResponseHead

    pub(all) struct ResponseHead {
    status : Int
    headers : Headers
    } derive(Eq,
    Debug
    )

    Response status and headers without the body.

    ResponseHead::equal

    Compares response metadata.

    ResponseHead::not_equal

    fn ResponseHead::not_equal(x : ResponseHead, y : ResponseHead) -> Bool

    Compares response metadata.

    ResponseHead::to_repr

    Formats response metadata for diagnostics.

    SendOptions

    pub(all) struct SendOptions {
    timeout_ms : Int?
    body : BodyTiming
    } derive(Eq,
    Debug
    )

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

    SendOptions::default

    fn SendOptions::default() -> SendOptions

    No attempt deadline and a buffered body of at most 64 MiB on the wire.

    SendOptions::equal

    fn SendOptions::equal(SendOptions, SendOptions) -> Bool

    Compares send options.

    SendOptions::not_equal

    fn SendOptions::not_equal(x : SendOptions, y : SendOptions) -> Bool

    Compares send options.

    SendOptions::to_repr

    Formats send options for diagnostics.

    SendOptions::validate

    fn SendOptions::validate(self : SendOptions) -> Unit raise TransportError

    Refuses options no send can honour, before any I/O and so unsent: a nonpositive timeout_ms raises Timeout(ms) at once, and a negative Exchange(max_bytes) or a nonpositive Stream(idle_ms) raises Invalid, all in phase Pool. Exchange(max_bytes=0) is valid and admits only an empty body; an idle limit of zero is refused because no read could ever meet it.

    test {
    @mttp.SendOptions::default().validate()
    let bad = { ..@mttp.SendOptions::default(), body: Exchange(max_bytes=-1), }
    try bad.validate() catch {
    @mttp.TransportError(kind~, phase~, ..) => {
    assert_eq(kind, Invalid)
    assert_eq(phase, Pool)
    }
    } noraise {
    _ => fail("a negative byte limit must be refused")
    }
    }

    StreamBody

    pub struct StreamBody {
    // private fields
    }

    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.
    impl Eq for StreamBody
    impl Debug for StreamBody

    StreamBody::close

    fn StreamBody::close(self : StreamBody) -> Unit

    Closes the source and prevents future acquisition.

    StreamBody::equal

    fn StreamBody::equal(self : StreamBody, other : StreamBody) -> Bool

    Identity: the same stream body, not an equal one.

    StreamBody::length

    fn StreamBody::length(self : StreamBody) -> Int64?

    The declared length, if any.

    StreamBody::new

    fn StreamBody::new(source : &BodyStream, length? : Int64) -> StreamBody

    Wraps source, declared to produce exactly length bytes when given.

    StreamBody::not_equal

    fn StreamBody::not_equal(x : StreamBody, y : StreamBody) -> Bool

    Identity: the same stream body, not an equal one.

    StreamBody::take

    fn StreamBody::take(self : StreamBody) -> &BodyStream raise TransportError

    Hands the stream to the one sender that reads it. The returned stream raises Protocol in phase Body when the source produces more bytes than the declared length, ends before it, or yields an empty chunk; it closes the source then, at EOF, and on close.

    A second call raises Unreplayable in phase Pool, since the bytes already read are gone. A negative declared length raises Invalid in phase Pool before anything is read, and closes the source, which no sender will take.

    StreamBody::to_repr

    Debug representation of a stream body.

    Trusted

    pub(all) struct Trusted(String)

    Explicit, already-encoded URI syntax. Unlike String interpolation, this may introduce separators, a scheme or an authority; only use trusted data.
    impl UriPart for Trusted

    Trusted::write_uri

    fn Trusted::write_uri(self : Trusted, builder : UriBuilder) -> Unit

    Writes this explicitly trusted fragment to a URI builder.

    UriBuilder

    pub struct UriBuilder {
    // private fields
    }

    An interpolation builder for URI strings. Literal syntax is preserved; interpolated values are percent-encoded as individual components. Query parameters are staged until their separator so None can omit the whole parameter without retracting or repeatedly copying the preceding URL.

    UriBuilder::new

    fn UriBuilder::new() -> UriBuilder

    Creates an empty URI builder; use uri for interpolation and finalization.

    UriBuilder::to_string

    fn UriBuilder::to_string(self : UriBuilder) -> String

    Returns the current URI without mutating the builder. Empty query syntax and parameters omitted by None do not leave stray separators.

    UriBuilder::write_encoded

    fn UriBuilder::write_encoded(self : UriBuilder, value : String) -> Unit

    Writes a component, escaping every character outside RFC 3986 unreserved syntax. Even at the start, a plain String cannot inject a scheme or host.

    UriBuilder::write_string

    fn UriBuilder::write_string(self : UriBuilder, text : String) -> Unit

    Writes URI syntax literally. Reserved characters in interpolated data should instead go through interpolation or write_encoded.

    UriBuilder::write_string_interpolation

    fn[T : UriPart] UriBuilder::write_string_interpolation(self : UriBuilder, value : T) -> Unit

    Writes an interpolated value using its UriPart implementation.

    Url

    pub struct Url {
    scheme : String?
    userinfo : String?
    host : String?
    port : Int?
    path : String
    query : String?
    fragment : String?
    } derive(Eq,
    Debug
    )

    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.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.Url::parse("../c d"))
    assert_eq(next.to_string(), "https://example.com/c%20d")
    }
    impl Show for Url

    Url::append_query_params

    fn Url::append_query_params(self : Url, params : Array[(String, String)]) -> Url

    Appends encoded pairs after the existing query, keeping it as written. Names and values keep only RFC 3986 unreserved characters, as percent_encode does.

    test {
    let url = @mttp.Url::parse("https://h/p?a=1#top")
    let next = url.append_query_params([("b", "x y"), ("a", "2")])
    assert_eq(next.to_string(), "https://h/p?a=1&b=x%20y&a=2#top")
    }

    Url::equal

    fn Url::equal(Url, Url) -> Bool

    Compares all components; parsing has already normalised them.

    Url::is_absolute

    fn Url::is_absolute(self : Url) -> Bool

    Reports whether the reference has a scheme.

    Url::merge_query_params

    fn Url::merge_query_params(self : Url, params : Array[(String, String)]) -> Url

    Replaces every existing pair whose decoded name is among params' names, then appends params in order, as httpx's merge_params does. The pairs kept are kept as written.

    test {
    let url = @mttp.Url::parse("https://h/p?page=1&q=a+b&page=2")
    let next = url.merge_query_params([("page", "3")])
    assert_eq(next.to_string(), "https://h/p?q=a+b&page=3")
    }

    Url::not_equal

    fn Url::not_equal(x : Url, y : Url) -> Bool

    Compares all components; parsing has already normalised them.

    Url::output

    fn Url::output(self : Url, logger : &Logger) -> Unit

    The serialised reference.

    Url::parse

    fn Url::parse(text : String) -> Url raise UrlError

    Parses a URI reference, absolute or relative.

    Leading and trailing C0 controls and spaces are trimmed and tabs and line breaks removed, as browsers do. Only the authority can be refused: a port that is not a decimal number up to 65535, an IP literal that is not an IPv6 address (zone identifiers and IPvFuture included), or a host with a character outside RFC 3986's reg-name — non-ASCII and percent-encoded hosts among them, since IDNA is out of scope. A backslash is data, not a separator, and is percent-encoded.

    Url::path_segments

    fn Url::path_segments(self : Url) -> Array[String] raise UrlError

    Decoded path segments, for routing.

    Url::port_or_default

    fn Url::port_or_default(self : Url) -> Int?

    The port, or the scheme's default when the URL names none.

    Url::query_params

    fn Url::query_params(self : Url) -> Array[(String, String)]

    The query's name-value pairs, decoded as application/x-www-form-urlencoded: + is a space, escapes are UTF-8 (malformed ones are kept literally), empty pairs are skipped and a pair without = has an empty value.

    Url::resolve

    fn Url::resolve(self : Url, reference : Url) -> Url

    Resolves reference against this base (RFC 3986 §5.2.2, strict): a reference with a scheme, even the base's own, stays absolute. The base should be absolute; RFC 3986 defines no result otherwise.

    test {
    let base = @mttp.Url::parse("http://a/b/c/d;p?q")
    assert_eq(base.resolve(@mttp.Url::parse("../g")).to_string(), "http://a/b/g")
    assert_eq(base.resolve(@mttp.Url::parse("//g")).to_string(), "http://g")
    }

    Url::same_origin

    fn Url::same_origin(self : Url, other : Url) -> Bool

    Reports whether both URLs have the same scheme, host and port, counting the scheme's default port. A URL without a scheme or host has no origin and matches nothing.

    Url::to_repr

    Debug representation of the components.

    Url::to_string

    fn Url::to_string(self : Url) -> String

    The serialised reference.

    Url::without_fragment

    fn Url::without_fragment(self : Url) -> Url

    The same URL without its fragment, which is never sent to a server.

    append_query

    fn append_query(url : String, name : String, value : String) -> String

    Appends an encoded query pair, using ampersand if the URL contains ?.

    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.

    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.Response::new(200)
    let response = {
    ..response,
    attributes: response.attributes.put(@mttp.close_on_shutdown, true),
    }
    assert_eq(response.attributes.get(@mttp.close_on_shutdown), Some(true))
    }

    encode_path

    fn encode_path(segments : Array[String]) -> String

    Encodes path segments for a request target ("/users/a%2Fb").

    encode_utf8

    fn encode_utf8(text : String) -> Bytes

    Encodes text as UTF-8, writing each lone surrogate as U+FFFD, as the WHATWG Encoding Standard's UTF-8 encoder and TextEncoder do. @utf8.encode aborts the process on a lone surrogate instead, so it is unsafe for strings that came from outside.

    test {
    let lone = String::from_array([(0xD800).unsafe_to_char()])
    assert_eq(@mttp.encode_utf8("é" + lone), b"\xC3\xA9\xEF\xBF\xBD")
    }

    form_urldecode

    fn form_urldecode(input : String, max_pairs? : Int) -> Array[(String, String)] raise TransportError

    Decodes application/x-www-form-urlencoded pairs in order, preserving duplicates. Empty pieces are skipped, a missing equals means empty value, plus becomes space, malformed escapes stay literal and invalid UTF-8 is replaced. At most max_pairs (default 256) nonempty pairs are accepted. Byte limits belong to the caller that reads the input body.

    form_urlencode

    fn form_urlencode(pairs : ArrayView[(String, String)]) -> String

    Serializes name/value pairs, in order, as an application/x-www-form-urlencoded body with the WHATWG URL Standard's urlencoded serializer, as browsers and URLSearchParams do: a space becomes +, and every UTF-8 byte other than an ASCII alphanumeric or one of *-._ is percent-encoded in uppercase hex. This differs from percent_encode, which keeps ~, encodes * and writes a space as %20. A lone surrogate is encoded as U+FFFD.

    test {
    assert_eq(
    @mttp.form_urlencode([("q", "a b&c"), ("lang", "日本")]),
    "q=a+b%26c&lang=%E6%97%A5%E6%9C%AC",
    )
    }

    format_http_date

    fn format_http_date(unix_ms : Int64) -> String

    Formats Unix milliseconds as an IMF-fixdate (RFC 9110 §5.6.7), the form a sender generates for Date, Last-Modified, Expires and a cookie's Expires. Milliseconds are dropped, rounding toward the past. The format has a four-digit year, so a time before 0001-01-01 or after 9999-12-31 is clamped to the first or last second it can carry. parse_http_date reads the result back to the same second.

    test {
    assert_eq(
    @mttp.format_http_date(784111777000L),
    "Sun, 06 Nov 1994 08:49:37 GMT",
    )
    assert_eq(@mttp.format_http_date(-1L), "Wed, 31 Dec 1969 23:59:59 GMT")
    }

    is_idempotent

    fn is_idempotent(req : Request) -> Bool

    An idempotent method or an explicit replay_safe statement. A header alone cannot establish the remote service's deduplication contract.

    is_success

    fn is_success(status : Int) -> Bool

    Reports whether a status belongs to the successful 2xx class.

    join_url

    fn join_url(base : String, target : String) -> String

    Base-URL join by prefix (sdk-runtime's base_url semantics): an absolute http(s) target passes through; otherwise one slash is kept between base and target. "https://api.openai.com/v1" + "/chat" gives "https://api.openai.com/v1/chat" (RFC 3986 resolve would drop /v1).

    parse_http_date

    fn parse_http_date(s : String, now_unix_ms~ : Int64) -> Int64?

    Parses an HTTP-date (RFC 9110 §5.6.7) into Unix milliseconds: the IMF-fixdate a sender generates, or one of the two obsolete forms a recipient must also accept, rfc850-date and asctime-date. HTTP-date is case-sensitive and the input is not trimmed. The weekday must agree with the date. now_unix_ms places rfc850-date's two-digit year: a year more than 50 years after it is taken from the century before.

    test {
    assert_eq(
    @mttp.parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT", now_unix_ms=0L),
    Some(784111777000L),
    )
    assert_eq(@mttp.parse_http_date("06 Nov 1994", now_unix_ms=0L), None)
    }

    percent_decode

    fn percent_decode(s : String) -> String raise UrlError

    Decodes percent-encoded UTF-8, refusing malformed escapes or encoding.

    percent_encode

    fn percent_encode(value : String) -> String

    Percent-encodes UTF-8 bytes, preserving only RFC 3986 unreserved bytes.

    percent_encode_path

    fn percent_encode_path(value : String) -> String

    Percent-encodes a multi-segment path, also preserving slash separators.

    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.

    reason_phrase

    fn reason_phrase(status : Int) -> String

    The phrase RFC 9110 (and RFC 6585 for 428, 429, 431 and 511, RFC 7725 for 451) gives a status code, or an empty string for an unregistered one.

    remote_addr

    let remote_addr : AttributeKey[String]

    Set by server adapters.

    replay_safe

    let replay_safe : AttributeKey[Bool]

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

    uri

    fn uri(build : (UriBuilder) -> Unit) -> String

    Builds a URI string without parsing it. Send-time validation still applies.

    test {
    let id = "a/b"
    let q : String? = None
    assert_eq(
    @mttp.uri(b => b <+ "https://example.test/\{id}?q=\{q}&n=\{3}"),
    "https://example.test/a%2Fb?n=3",
    )
    }

    validate_url

    fn validate_url(url : String) -> Unit raise TransportError

    Validates the original URL before normalization can encode an invalid request target. Unicode text is permitted and encoded by the URL parser.