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.
moon add gaato/mttp_core///|
test "describe a request" {
let request = @mttp_core.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_core.is_idempotent(request))
}///|
/// 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_core.Response::new(
200,
headers=@mttp_core.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")
}///|
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)
}| Package | What it holds |
|---|---|
| gaato/mttp_core | The vocabulary: methods, headers, URLs, requests, responses and bodies, Backend and SendOptions, TransportError, URL, form and HTTP-date (parse and format) helpers |
| gaato/mttp_core/client | Request 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_core/client/retry | Retry rules and decisions as data |
| gaato/mttp_core/client/ratelimit | Bounded window state, pacing, global quotas and header-driven admission |
| gaato/mttp_core/client/cookies | Cookies, the cookie-jar backend wrapper a Client with cookies= uses, for stacks built by hand |
| gaato/mttp_core/sse | A bounded text/event-stream parser, reader and encoder |
| gaato/mttp_core/multipart | A multipart/form-data writer with streamed parts |
| gaato/mttp_core/cookie | An RFC 6265 cookie jar on an injected clock |
| gaato/mttp_core/jsonkit | Presence, an object builder, field decoders and open enums for JSON models |
| gaato/mttp_core/schema | Text, JSON and body codecs that carry their JSON Schema, and validators |
| gaato/mttp_core/endpoint | Typed endpoint inputs and outputs, client calls and bounded server dispatch |
| gaato/mttp_core/router | Path and method dispatch, server policy, middleware, CORS; also a Backend |
| gaato/mttp_core/openapi | OpenAPI documents projected from endpoints, reference checks and a docs endpoint |
| gaato/mttp_core/apispec | OpenAPI document records with their JSON codecs (3.1, and 3.2's fields) |
| gaato/mttp_core/endpoint/testkit | Typed endpoint stubs and the definition checker |
| gaato/mttp_core/testing | BackendStub, canned answers and fault injection for client tests |
| gaato/mttp_core/h1 | The sans-IO HTTP/1.1 codec: bounded decoders, strict parsers that refuse request smuggling, wire writers |
| gaato/mttp_core/h1client | The HTTP/1.1 client engine over the rt ports: connection pool, bounded heads and bodies, phase deadlines, proxies |
| gaato/mttp_core/h1server | The HTTP/1.1 server engine over the rt ports for one accepted connection |
| gaato/mttp_core/rt | The ports a runtime provides: Clock, Timer, Gate, Conn, Dialer, Signal, Coordination |
| gaato/mttp_core/sim | In-memory doubles of the ports and the contract checks every implementation passes |
| gaato/mttp_core/driver | A deterministic task driver with virtual time and run_sync for plain tests (experimental: it relies on compiler intrinsics) |
pub(open) trait Backend {
async fn send(Self, Request, SendOptions) -> Response raise TransportError
fn close(Self) -> Unit = _
}pub(open) trait BodyStream {
async fn read_some(Self) -> Bytes? raise TransportError
fn close(Self) -> Unit
}impl Show for TransportErrorpub struct AttributeKey[T] {
// private fields
}pub struct Attributes {
// private fields
}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 request = @mttp_core.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_core.TransportError(kind=TooLarge(limit=4), ..) => b"too large"
error => raise error
}
})
assert_eq(refused, b"too large")
}test {
let options = { ..@mttp_core.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))
}test {
let headers = @mttp_core.Headers::of([("Accept", "text/plain")])
assert_eq(headers.get("ACCEPT"), Some("text/plain"))
}pub(all) struct Request {
meth : Method
url : String
headers : Headers
body : Body
attributes : Attributes
}fn Request::body_stream(self : Request, s : &BodyStream, length? : Int64, content_type? : String) -> Requesttest {
@mttp_core.SendOptions::default().validate()
let bad = {
..@mttp_core.SendOptions::default(),
body: Exchange(max_bytes=-1),
}
try bad.validate() catch {
@mttp_core.TransportError(kind~, phase~, ..) => {
assert_eq(kind, Invalid)
assert_eq(phase, Pool)
}
} noraise {
_ => fail("a negative byte limit must be refused")
}
}pub struct StreamBody {
// private fields
}impl Eq for StreamBodyimpl Debug for StreamBodypub(all) struct Trusted(String)pub struct UriBuilder {
// private fields
}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")
}test {
let url = @mttp_core.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")
}test {
let url = @mttp_core.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")
}test {
let base = @mttp_core.Url::parse("http://a/b/c/d;p?q")
assert_eq(
base.resolve(@mttp_core.Url::parse("../g")).to_string(),
"http://a/b/g",
)
assert_eq(base.resolve(@mttp_core.Url::parse("//g")).to_string(), "http://g")
}fn append_query(url : String, name : String, value : String) -> Stringtest {
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 encode_utf8(text : String) -> Bytestest {
let lone = String::from_array([(0xD800).unsafe_to_char()])
assert_eq(@mttp_core.encode_utf8("é" + lone), b"\xC3\xA9\xEF\xBF\xBD")
}test {
assert_eq(
@mttp_core.form_urlencode([("q", "a b&c"), ("lang", "日本")]),
"q=a+b%26c&lang=%E6%97%A5%E6%9C%AC",
)
}fn format_http_date(unix_ms : Int64) -> Stringtest {
assert_eq(
@mttp_core.format_http_date(784111777000L),
"Sun, 06 Nov 1994 08:49:37 GMT",
)
assert_eq(@mttp_core.format_http_date(-1L), "Wed, 31 Dec 1969 23:59:59 GMT")
}fn is_success(status : Int) -> Boolfn join_url(base : String, target : String) -> Stringfn parse_http_date(s : String, now_unix_ms~ : Int64) -> Int64?test {
assert_eq(
@mttp_core.parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT", now_unix_ms=0L),
Some(784111777000L),
)
assert_eq(@mttp_core.parse_http_date("06 Nov 1994", now_unix_ms=0L), None)
}fn percent_encode(value : String) -> Stringfn percent_encode_path(value : String) -> Stringfn reason_phrase(status : Int) -> Stringtest {
let id = "a/b"
let q : String? = None
assert_eq(
@mttp_core.uri(b => b <+ "https://example.test/\{id}?q=\{q}&n=\{3}"),
"https://example.test/a%2Fb?n=3",
)
}Install
Download zipHTTP 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.