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.
moon add gaato/mttp///|
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)
}
}///|
fn main {
@mttp.run(show_user)
}///|
/// 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}")
}
}| Default | Change it with | |
|---|---|---|
| Retries | Up 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 jitter | retry=RetryPolicy::none() or another policy |
| Retry budget | Retries stay within 20 % of the requests of the last 10 s, plus 10 per second, so a failing server is not flooded | retry_budget=RetryBudget::new(..) or RetryBudget::unlimited() |
| Deadlines | 30 s per attempt, 120 s for the whole call, retries included | timeout_ms=, total_ms= |
| Redirects | Up to 10 hops, never from HTTPS to HTTP, credentials dropped across origins | redirect=, redirect=RedirectConfig::none() to turn them off, or .follow_redirects(false) on a request |
| Cookies | None kept | cookies= a CookieJar |
| Decompression | gzip, deflate, br and zstd | per request, by setting Accept-Encoding |
| Body size | Buffered bodies up to 64 MiB after decoding | max_body_bytes= |
| TLS | The system's roots | trust=PemFile(..) |
| Network, clock and timers | The real ones | backend=, clock=, timer=, for tests (see Tests) |
///|
/// 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()
}///|
let claude = @anthropic.Anthropic::new(
api_key~,
@mttp.backend(),
@mttp.clock(),
timer=@mttp.timer(),
)@server.serve(request => @mttp.Response::new(200), addr="127.0.0.1:8080")///|
/// 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))
}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") })
}///|
/// 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)
}| Package | Targets | Purpose |
|---|---|---|
| gaato/mttp | native, wasm, js | run, client(), backend(), clock(), timer(), coordination(), and re-exports of the types and client functions an app uses |
| backend | native, wasm, js | AsyncBackend: the HTTP/1.1 engine over TCP and TLS with decompression; the host's fetch on js |
| server | native, wasm | serve: bounded listener, graceful shutdown, background work |
| files | native, wasm | static_files: a directory's files for GET and HEAD, streamed, with ETag revalidation and paths kept inside the directory |
| fetch | js | fetch_handler: a handler as a JavaScript fetch export |
| runtime | native, wasm, js | Clocks, timers, an admission gate and coordination for gaato/mttp_core's runtime ports |
| codings | native, wasm, js | Bounded content codecs, Decompress for clients and compress for handlers |
| conn | native, wasm | TCP and TLS dialing, including CONNECT tunnels |
| trust | native, wasm, js | Trust: which TLS roots a socket backend accepts |
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())
}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)
}test {
let headers = @mttp_core.Headers::of([("Accept", "text/plain")])
assert_eq(headers.get("ACCEPT"), Some("text/plain"))
}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")
}fn as_lines(receive : async (String) -> Unit, max_line_bytes? : Int) -> ResponsePlan[Result[Unit, ResponseFailure], Streaming]fn as_sse(receive : async (SseEvent) -> Unit, max_event_bytes? : Int, stop? : (SseEvent) -> Bool) -> ResponsePlan[Result[Unit, ResponseFailure], Streaming]let claude = @anthropic.Anthropic::new(
api_key~,
@mttp.backend(),
@mttp.clock(),
timer=@mttp.timer(),
)fn client(base_url? : String, headers? : Headers, timeout_ms? : Int, total_ms? : Int, max_body_bytes? : Int, retry? : RetryPolicy, retry_budget? : RetryBudget, redirect? : RedirectConfig, limiter? : &RateLimiter, trust? : Trust, config? : ClientConfig, cookies? : CookieJar, backend? : &Backend, clock? : &Clock, timer? : &Timer) -> Clientlet jar = @mttp.CookieJar::new(@mttp.clock())
let site = @mttp.client(cookies=jar)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))
}fn run(main : async () -> Unit) -> Unitfn main {
@mttp.run(async fn() { ... })
}Install
Download zipHTTP 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.