moonapi builds a server application from typed routes and generates its own
OpenAPI document. One application object holds the routes, the layers around
them, the exception and per-status handlers, the security schemes, the lifespan
hooks and the OpenAPI metadata; the same object compiles to a server that runs
natively or in WebAssembly, and to the pages and the agent interface that
describe it.
flowchart LR
routes["typed routes<br/>App::get / post / …"] --> app["**moonapi** App"]
app -->|"App::to_asgi()"| asgi(["moonasgi AsgiApp"])
app -->|"App::openapi_json()"| spec["OpenAPI 3.1"]
app -->|"/docs /redoc /redocly"| pages["four readers"]
app -->|"agent::expose"| mcp["MCP tools + skill"]
asgi --> cat["a server runs it"]
Routing and the OpenAPI document build on every backend. Handlers are async, and
serving needs a socket, so the entry point of a program that serves is
async fn main; everything below works without one, because App::handle drives
an application in-process through the same path a server uses.
moon add moonbitstack/moonapi
async test {
let app = @moonapi.App::new()
app.get("/", _ctx => @moonapi.text(200, "Hello from moonapi!"))
app.get(
"/users/{id}",
ctx => @moonapi.text(200, "user " + ctx.param("id").unwrap_or("")),
summary="Fetch a user",
)
app.post("/users", _ctx => @moonapi.text(201, "created"), status_code=201)
app.describe(title="users api", api_version="1.0.0")
let reply = app.handle({
http_method: "GET",
path: "/users/42",
query_string: b"",
headers: [],
body: b"",
})
assert_eq(reply.status, 200)
assert_eq(@utf8.decode_lossy(reply.body), "user 42")
// One set of routes, every mainstream spec version.
assert_eq(app.openapi_json(version=OpenApi31).contains("\"3.1.0\""), true)
assert_eq(app.openapi_json(version=OpenApi30).contains("\"3.0.3\""), true)
assert_eq(app.openapi_json(version=Swagger20).contains("\"2.0\""), true)
}A function is what a server serves: @mooncat.serve(app.to_asgi(), port=12000)
runs the application above on a socket, native or wasm, and @mooncat is not a
dependency of this library — App::to_asgi() is the whole contract, and any
server that speaks ASGI runs it.
Annotations generate these calls, and routes built at run time are written the same
way. #app.get("/", summary="Say hello") above a handler names its verb, and the
generator it belongs to writes the register(app) that registers it:
#app.get("/", summary="Say hello")
fn hello(_ctx : @moonapi.Context) -> @moonasgi.Response {
@moonapi.text(200, "Hello from moonapi!")
}
async fn main {
let app = @moonapi.App::new()
register(app)
@mooncat.serve(app.to_asgi(), port=12000)
}#app.get, post, put, patch, delete, head, options and trace name their
verb, #app.route("PURGE", "/cache") takes any other, #app.websocket a socket, and
#router.* registers on a Router through register_router(router). Each takes the
same arguments as the method it stands for. The generator reads them at build time —
one rule in moon.mod and one line in the moon.pkg of the package holding the
handlers, both shown in src/examples/27-annotations.
Without them the methods below are the whole surface.
A type implementing View answers the methods of one path, and app.view registers each
method it answers. A method it leaves out answers 405, with Allow naming the ones it does:
struct User {}
impl @moonapi.View for User with get(_) {
Some(ctx => @moonapi.text(200, "user " + ctx.param("id").unwrap_or("")))
}
impl @moonapi.View for User with delete(_) {
Some(_ctx => @moonapi.text(204, ""))
}async test {
let app = @moonapi.App::new()
app.view("/users/{id}", User::{})
let fetched = app.handle({
http_method: "GET",
path: "/users/7",
query_string: b"",
headers: [],
body: b"",
})
assert_eq(fetched.status, 200)
let refused = app.handle({
http_method: "POST",
path: "/users/7",
query_string: b"",
headers: [],
body: b"",
})
assert_eq(refused.status, 405)
assert_eq(refused.header("allow").unwrap_or(""), "GET, HEAD, DELETE")
}app.urls takes a route table instead, kept apart from the handlers: path, view and
group each make an entry.
Two ways to ask an application what it answers, both without running anything:
route_for reports the decision a request meets — the route it reaches with the
parameters its template bound, the methods a path owns when the method is not one
of them, or nothing — and routes lists what the application serves, in the
order it tries them.
async test {
let app = @moonapi.App::new()
app.get(
"/pets/{id}",
ctx => @moonapi.text(200, "pet " + ctx.param("id").unwrap_or("")),
)
app.post("/pets", _ctx => @moonapi.text(201, "created"), status_code=201)
assert_true(app.route_for("GET", "/pets/7") is Reached(_, _))
match app.route_for("GET", "/pets/7") {
Reached(route, params) => {
assert_eq(route.path, "/pets/{id}")
assert_eq(params.get("id"), Some("7"))
}
_ => ()
}
// A path that answers other methods names them, which is the 405's `Allow`.
assert_true(app.route_for("DELETE", "/pets/7") is MethodNotAllowed(_))
assert_true(app.route_for("GET", "/nope") is NotFound)
// The routes, in the order they are tried.
assert_eq(app.routes().map(r => r.verb.name() + " " + r.path), [
"GET /pets/{id}",
"POST /pets",
])
}The same decision answers App::handle, so a page that shows what an
application answers and a test that asserts a 405 read one implementation.
An application serves /openapi.json and four readers of it from the start:
/docs, /redoc, /redocly, and the same document in each of them. ui picks
which reader /docs serves, theme how the pages look, and each of the two
readers with a page of its own can be moved or left off:
async test {
let app = @moonapi.App::new()
app.get("/pets", _ctx => @moonapi.text(200, "pets"), summary="List pets")
app.describe(title="petstore")
app.enable_docs(ui=Swagger)
let page = app.handle({
http_method: "GET",
path: "/docs",
query_string: b"",
headers: [],
body: b"",
})
assert_eq(page.header("content-type"), Some("text/html; charset=utf-8"))
let html = @utf8.decode_lossy(page.body)
assert_eq(html.contains("[email protected]"), true)
// The page reads the document this app publishes, not a copy of it.
assert_eq(html.contains("\"/openapi.json\""), true)
// The pages stay out of the document they describe.
assert_eq(app.openapi_json().contains("/docs"), false)
}App::new(docs=None) starts without them and enable_docs(...) sets every
setting at once; scalar_ui(), swagger_ui(), redoc_ui() and redocly_ui()
return those pages on their own, for an application that serves them under its
own paths or behind its own guard. Redocly's reference renders under a licence
key, which reaches it through ui_extra.
The same document, described once, is what an agent needs as well.
@moonapi.agent.expose(app) mounts an MCP service at /mcp and serves a skill
at /SKILL.md: every documented operation becomes one tool, its parameters and
its body are the tool's arguments, and calling a tool calls the route in-process,
so an agent and a client get the same bytes.
async test {
let app = @moonapi.App::new()
app.get(
"/pets/{id}",
ctx => @moonapi.text(200, "pet " + ctx.param("id").unwrap_or("")),
summary="Fetch one pet",
endpoint=@endpoint.Endpoint::new(
params=[@endpoint.Param::new("id", @endpoint.InPath, schema=SStr)],
),
)
app.describe(title="petstore")
@agent.expose(app)
let skill = app.handle({
http_method: "GET",
path: "/SKILL.md",
query_string: b"",
headers: [],
body: b"",
})
assert_eq(skill.header("content-type"), Some("text/markdown; charset=utf-8"))
let markdown = @utf8.decode_lossy(skill.body)
assert_eq(markdown.contains("- MCP endpoint: `/mcp`"), true)
assert_eq(markdown.contains("| `get_pets_id` | `GET /pets/{id}` |"), true)
}Exposure says where either half is served, what the API is called, and which
tags are offered; None for a URL leaves that half off. Neither the service nor
the skill joins the document, so turning them on does not change what a client
reads. The package is imported by name — moonbitstack/moonapi/agent — because
it brings the MCP library with it, which a program that does not talk to agents
should not have to link.
App::to_asgi() is the whole contract between an application and a server:
what runs it gets the same application — the layers, the mounts, the lifespan
hooks and the disconnect handling — and every way of serving below is one call
away from it. The server is a matter of the transport; the application does not
change when it moves to TLS or to another protocol version.
The server of this ecosystem is mooncat.
It is not a dependency here: a library of routes should not decide how its
application listens. A program that serves adds it, and then:
@mooncat.serve(app.to_asgi(), port=12000)— HTTP/1.1 and WebSocket.@mooncat.serve_tls(app.to_asgi(), certificate_file=..., private_key_file=..., port=12000)— HTTP/2 over TLS 1.3,h2andhttp/1.1by ALPN,wss://included; the same port answers cleartext unlesscleartext=false.@mooncat.serve_config(app.to_asgi(), @mooncat.Config::new(port=8443, ...))— the same with the connection limits and the HTTP/2 settings spelled out.@quic.serve_config(app.to_asgi(), @config.Config::new(port=12000), certs)— HTTP/3 on the port number the other two use, UDP rather than TCP.
The limits a plain server applies hold per stream: past limit_concurrency a
stream is answered 503, every stream counts toward limit_max_requests, and a
connection with nothing open for timeout_keep_alive is ended with a GOAWAY. Each
request stream is one ASGI scope, so the application does not change with the
transport, and behind a proxy that terminates QUIC itself the app sees the
forwarded scheme and host: the ASGI root_path under which it is mounted reaches
/docs, /redoc, /openapi.json and /mcp alike.
For a runnable integration of the root API, two routers, authentication,
validation, middleware, background work, streaming and docs, see the
complete application example. The full example list
is in src/examples/README.md. On Windows, macOS or Linux, start
the native server with cd integration && moon run src/full-example --target native, from a module of its own, since a server is not something this library depends on. The service
provides the API reference at http://127.0.0.1:12000/docs, ReDoc at
http://127.0.0.1:12000/redoc, Redocly's reference at
http://127.0.0.1:12000/redocly, the MCP service at
http://127.0.0.1:12000/mcp and OpenAPI 3.0.3 at
http://127.0.0.1:12000/openapi.json.
The src/examples directory is a runnable tour of the public API. Each
numbered example has a runnable main package that isolates one feature:
| Example | Shows |
|---|---|
01-openapi |
building an OpenAPI document |
02-routing |
verbs, path parameters, and the 404-vs-405 distinction |
03-openapi-versions |
the same routes emitted as Swagger 2.0, OpenAPI 3.0 and 3.1 |
04-descriptors |
the descriptor tree behind a typed route |
05-constraints |
field constraints, documented and enforced |
06-typed-body |
validated and unchecked JSON body extraction |
07-request-extractors |
query, headers and body from the context |
08-response-model |
filtering a handler's return to a declared model |
09-dependency-injection |
the container, scopes and teardown |
10-oauth2-jwt |
an OAuth2 password grant and bearer enforcement |
11-per-op-security |
security requirements on individual routes |
12-security-schemes |
the six OpenAPI scheme shapes |
13-forms-files |
form and file uploads with size bounds |
14-background-tasks |
work deferred until after the response |
15-mounting |
composing sub-applications under a prefix |
16-middleware |
layers and middleware around the router |
17-sse |
server-sent events |
18-websocket |
a live WebSocket connection |
19-json-schema |
JSON schema generation |
20-views |
one type answering the methods of a path |
21-route-table |
a route table kept apart from its handlers |
22-docs-ui |
the documentation pages |
23-lifespan |
startup and shutdown hooks |
24-files |
serving files with range and conditional handling |
25-full-example |
a complete application in several packages, with auth, validation, middleware, forms and files |
26-described-model |
describing a model once, using it four ways |
27-annotations |
registering routes from annotated functions |
28-templates |
rendering HTML templates |
29-agents |
the MCP service and the skill |
ecosystem |
one description driving validation, the document, the table and the rows |
Run any of them with, for example:
moon run src/examples/02-routing --target native
Each concern is its own subpackage, so a program links the parts it uses. The
root re-exports all of them, so @moonapi.App, @moonapi.text and the rest work
as they always did. templates, static and agent are the exception: they bring
dependencies of their own, so a program imports them by name.
| Package | What it holds | Depends on |
|---|---|---|
moonapi |
App and its methods, the façade |
everything below |
moonapi/agent |
expose: the document as MCP tools and as a skill |
moonmcp |
moonapi/templates |
Templates: pages rendered into responses, with request and url_for; load_directory (native) |
moonpug, ctx |
moonapi/static |
serve, a Source trait, Memory and Directory (native) |
ctx |
moonapi/route |
Route, WsRoute, Router, path templates; Method from moonhttp |
ctx, endpoint, security, socket |
moonapi/cmd/routes |
the annotation generator, run as a build rule | moonast |
moonapi/openapi |
the document's info block, routes as operations, the four readers | route, schema |
moonapi/security |
schemes, guards, per-operation requirements, tokens | ctx, schema, status |
moonapi/exceptions |
HttpException and exception handlers |
ctx |
moonapi/endpoint |
Param, ResponseSpec, Endpoint and its validation |
ctx, schema |
moonapi/ctx |
Context and every extractor, background tasks |
schema, status |
moonapi/schema |
Schema, constraints, ValidationError, of_model |
status |
moonapi/socket |
the live WebSocket connection | nothing |
moonapi/status |
response helpers, streams, SSE, cookies, status constants | nothing |
moonapi/di |
dependency injection | nothing |
Constructors are written without the package. pub using re-exports types
but not their constructors, so @moonapi.OpenApi31 does not resolve. Wherever
the expected type is known — an argument, a field, a match — the bare name does:
version=OpenApi31, Param::new("id", InPath, schema=SInt). The one place it
is not known is a raised error, where the subpackage is named:
raise @exceptions.HttpException(...).
- Routing —
App::get/post/put/patch/delete/routeon path templates:{name}binds one segment or part of one,{id:int},{x:float}and{id:uuid}bind only what fits,{rest:path}binds the rest slashes included, and the result lands inContext::param. The convertor also types the parameter in the OpenAPI document. Correct404(no path) versus405(path but not method), and a path off by a trailing slash answers307to the one that exists. A route takes the operation arguments —summary,description,tags,deprecated,operation_id,status_code,responses,name,include_in_schema, and anopenapi_extrafragment merged over the generated operation object — andApp::url_for(name, params)resolves a named route back to its path, mount prefix included. - Regex and glob routes —
regex=andglob=on any route decide which paths it answers, in the annotation and in the method alike:#app.get("/users/{id}", regex="/users/(?P<id>[0-9]+)").pathstays what the OpenAPI document andurl_forshow. Either has to match the whole path; a regular expression's named groups are the parameters, and a glob's*and**bind the template's parameters in order. Routes are tried in the order they were registered, and the first that matches answers. Giving both aborts at registration. - Routers —
Routercollects routes away from any application, andApp::include_router(router, prefix=, tags=, security=, dependencies=, responses=, deprecated=, include_in_schema=)folds them in as the app's own. The arguments given at the join reach every route in the group: tags, security requirements and dependencies lead the route's own, group responses are documented under it, anddeprecated/include_in_schemamark or hide the whole set. A router is a value, so the same one can be included twice under different prefixes. Routers include routers, so a large API is a tree included once. - Middleware — middleware on a route or a router node (
hoops=,Router::hoop): it runs inside the app's middleware and outside the route's security, validation and dependencies, so it can answer for the route, change its reply, or hand the rest a context of its own. The hoops of include calls and routers wrap the route's own, outermost first. - Views and route tables — a type implementing
Viewanswers the methods of one path, and a request for any other gets a405whoseAllowlists what it does answer.app.urls([path(...), view(...), websocket(...), group("/blog", [...])])registers a route table kept apart from the handlers, with groups nesting and carrying tags, security, dependencies and hoops. - Descriptor tree — a runtime
Schema/Param/Endpointtree that a typed route carries. Walked once to (a) emit complete OpenAPI request/response body schemas — objects, arrays, scalars,required, nullable (type: [t, "null"]in 3.1,nullablein 3.0,x-nullablein 2.0), aMap[String, T]asadditionalProperties,Anyas the empty schema, aformatsuch asbinary, and defaults, with named models hoisted undercomponents/schemasand referenced by$ref— and (b) drive request validation off the same tree. AParamcarries the same constraints aFielddoes, plus adefaultand analias, so a query parameter's bounds are both documented and enforced, and a422points at the name the client actually sent. User structs describe themselves withderive(ToJson)+ aT::schema()associated function + a one-lineToSchemabridge. - Multi-version OpenAPI —
App::openapi_jsonemits Swagger 2.0, OpenAPI 3.0.3, and OpenAPI 3.1.0 from the same routes and descriptors (3.xrequestBody+components/schemas; 2.0 body-parameter +definitions).App::describeadds the summary, tag descriptions and external documentation;app.webhooksdocuments the requests the API sends out (3.1webhooks); a route'scallbacksdocuments the requests an operation makes back to its caller; aParam, a request body, aResponseSpecand aFieldeach take namedexamples; and a route'sresponse_classstates what its success body is —Text,Html,Xml,EventStream,Bytes— so the document carries that media type (asproducesin Swagger 2.0) instead of assuming JSON. - Typed body extractors —
Context::body[T]deserialises the JSON body into a#derive(FromJson)struct;Context::body_validated[T]first checks it against the endpoint descriptor and returns either the built value or a located422error list — schema emission, validation, and deserialisation all off one descriptor. - Dependency injection — a
Container(provider registry) with request-scoped, one-shot resolution (per-request caching) andyield-style teardown run LIFO around the handler.App::depends(container.erase())attaches one to the application, and any route builder takesdependencies=["key"]: the keys resolve before the handler and are torn down after it, whatever the handler did. The teardown is handed the error that ended the request, so cleanup can tell a rollback from a commit. - OAuth2 + JWT security — a
/tokenpassword-grant endpoint issues an HS256 JWT (create_access_token), and anOAuth2PasswordBearerreads theAuthorization: Bearerheader, verifies the token, and enforces scopes:401on a missing or invalid/expired token,403when a valid token lacks a required scope. The token itself ismooncred's and the algorithms aremooncrypt's; moonapi names HS256 and hands over a key.alg: "none"cannot be expressed, the verifier's algorithm is the one that counts rather than the token's, and signatures compare in constant time. - Form & file extractors —
Context::formparses both anapplication/x-www-form-urlencodedbody (percent- and+-decoded) and amultipart/form-databody, splitting the boundary stream into fields and byte-exact uploads (filename + content-type +size+ the part's own headers + raw bytes). Reading the body ismoonhttp/form's, over moonmime and moonurl; a body is attacker-controlled and already buffered, so@moonmime.Limitsbounds what one may spend — a thousand parts of a megabyte by default — and a body over either bound comes back asNone, refused whole rather than truncated.Context::oauth2_password_formreads the OAuth2 password form off it. - response_model —
filter_response/json_modelvalidate a handler's return value against a declaredSchemaand project it down to exactly the model's fields, so a route can hold a richer object internally than it exposes (an id, a password hash) and still emit only what it promised. - Middleware & exception handlers — two kinds of middleware.
App::layertakes any moonasgi@mid.Layer, which wraps the ASGI application and sees every event, so streams stay streams and WebSocket traffic passes through: moonasgi shipsCors,Gzip,TrustedHosts,HttpsRedirectandServerErrors, andWrapturns any function from app to app into one.App::middlewareis a function of the request andcall_nextthat returns the response, whole. Layers and middleware wrap the app in one list, in registration order with the first outermost, and they wrap mounted applications too. Exception handlers (App::exception_handler): a handlerraises anHttpExceptionand the app maps it to a response, falling through to a built-in{"detail": ...}forHttpExceptionand a500for anything else.App::add_status_handler(code, ...)swaps in a custom response for any error status — a routing404/405or a raised exception's status — so an app can serve its own error pages. - Per-operation security — a route carries
security=[SecurityRequirement::new(scheme, scopes=[...])], and every scheme shape has a guard:secure_oauth2,secure_oauth2_code,secure_bearer,secure_api_key(header / query / cookie),secure_basic,secure_digest,secure_openid. Each surfaces the scheme in the spec and registers the check that runs before the handler —401with theWWW-Authenticatechallenge the scheme owes (a scoped bearer route names the scope it wanted),403on a missing scope or a rejected API key. Every one takesdescription=andauto_error=(with the flag off, the guard admits an anonymous caller and leaves the decision to the route). A bareadd_security_schemedocuments without enforcing. - Background tasks — every handler has
ctx.background;add_taskdefers async work that the app runs, in order, after the last body event is sent, so a slow write never delays the client. - Sub-application mounting —
App::mount(prefix, subapp)composes applications: a request underprefixreaches the sub-app with the prefix moved frompathtoroot_path(its own layers, security and background tasks apply), and the sub-app's routes and security schemes fold into the parent's merged OpenAPI document under the prefix. Mounts nest, and a sub-app's lifespan hooks run with the parent's.App::mount_asgi(prefix, app)mounts any other ASGI application the same way — a third-party component, static files, or the MCP service above — which the app routes to but does not document. - Streaming responses —
App::stream(path, handler)/App::route_stream(verb, ...)register a route returning aStream, whose body writes through aWriter: eachwriteis one body event, so a client reads the first long before the last one exists, and HTTP trailers follow the body when the route declares them. A client that leaves cancels the body. Layers see each event as it goes; anApp::middlewaregets the response whole, so the stream is gathered for it. - Server-Sent Events —
moonhttp/sseframes an event per the WHATWG event-stream format (id/event/retry/ multi-linedata/:comments);sse(body)is atext/event-streamStreamwhose body sends those frames throughEvents::sendandping, one body event per frame, so each dispatches on arrival. A client that reconnects sends the last id it saw, andctx.last_event_id()lets the stream pick up after it. - WebSocket routes —
App::websocket(path, handler)over the moonasgi WebSocket protocol. The async handler holds the live connection:accept(with a subprotocol and headers),receive/receive_text/receive_bytes/receive_json, the matchingsend_*, andclose. It can speak first and answer as messages arrive; a client that leaves raisesDisconnected(code~, reason~)out ofreceive, which ends the handler quietly unless it catches it. A route takessecurity,dependenciesand anamelike an HTTP route; a refused connection is declined at the handshake with the HTTP answer where the server allows it, or close code 1008. The moonasgi test client'swebsocketdrives a route in-process. - Security schemes — the six OpenAPI shapes (OAuth2 password and authorization-code flows, HTTP bearer / basic / digest, an API key in a header / query / cookie, and OpenID Connect) are emitted in each dialect's form:
components/securitySchemesin 3.x,securityDefinitionsin 2.0, where anhttpscheme becomes the standardapiKey-in-Authorizationworkaround and OpenID Connect — which 2.0 cannot express — is left out rather than described as something it is not. - Documentation readers —
/openapi.json,/docs,/redocand/redoclyare on by default (App::new(docs=None)to leave them off,enable_docs(...)or aDocsrecord to change them), all of them withinclude_in_schema=falseso turning documentation on does not change the document./docsserves the Scalar reference by default and Swagger UI withui=Swagger, ReDoc withui=Redocand Redocly's reference withui=Redocly(with its OAuth2 redirect page andinit_oauthwhere the reader has a flow to start);/redocand/redoclyserve those two whatever/docsserves, so one application can offer several. Every reader is loaded at a pinned version from a CDN and every page wears the same theme — the palette, the faces, the mark, the thin scrollbars and a bar naming the application — withtheme=Plainleaving the reader exactly as its maker ships it. Behind a proxy the pages and the document's servers follow the ASGIroot_path, and Redocly's licence key arrives throughui_extra.scalar_ui(),swagger_ui(),redoc_ui()andredocly_ui()return the pages alone, for an app that serves them under its own paths or behind its own guard. - Agent interface —
@moonapi.agent.expose(app)turns the document into an MCP service mounted at/mcpand aSKILL.mdserved at/SKILL.md(← the shapeapi-to-mcpandoas2mcpestablished). Each documented operation is one tool named after itsoperationId, or after the method and the path when it has none; its path and query parameters and its JSON body are the tool's arguments, with$refs inlined so a schema stands on its own; and a call runs the route in-process, so the answer is the one a client gets, byte for byte. A status of 400 or above comes back marked as an error with the body intact.Exposuremoves either half, renames the API and limits the offer to some tags; the MCP server also carries the skill as a resource, so a client that reads resources finds it there. - Templates —
moonapi/templatesrenders moonpug templates into responses: every page seesrequest,url_forresolves named routes, context processors add names to every page, and where the call'scontextand a processor name the same thing the rule is published (wins,clash).load_directoryreads a template tree once on native. - Static files —
moonapi/staticserves aSourceunder a mount with the same conditional and range handling asContext::serve, a405for anything butGETandHEAD, no way out of the source, and anhtmlmode with indexes, the trailing-slash redirect and a404.html.Memoryworks on every target,Directoryreads the disk on native, and a source of your own is one trait implementation. - Lifespan hooks —
App::on_startup/App::on_shutdown, driven over the ASGI lifespan protocol. Startup in registration order, shutdown in reverse, so a resource is released before whatever opened it. A hook gets the lifespan state, and what it stores there reaches every request asctx.state(). The moonasgi test client'slifespanruns the same thing in-process. - Responses —
text,html,jsonandjson_modelhelpers overmoonasgi.Response, plusredirect(url, status=307)(the URL is encoded over the characters a URI reserves for structure, so an already-encoded URL passes through and a smuggledCRLFcannot open a header) andfile_response(content, filename=, media_type=, inline=)(a media type guessed from the extension,Content-Length, and aContent-Dispositionthat falls back to RFC 6266'sfilename*when the name will not survive quoting). It takes bytes rather than a path because moonapi has no filesystem — the same app runs on wasm, js and native. - Conditional and partial serving —
Context::serve(content, ...)isfile_responsewith the request weighed:304when the client's copy is current,412when a precondition fails,206for a range (oneContent-Range, ormultipart/byterangesfor several),416for a range that is not there. This is what makes a download resumable. The entity-tags, the five precondition fields and the range arithmetic are moonhttp'sconditionalandrange, so it is RFC 9110 §13 and §14 rather than a guess. Everything turns onetag=; left out, one is hashed from the content, which costs a pass over the body — pass@conditional.stamp(length~, modified~)for one stat's worth instead. - Data URLs —
data_response(url)serves what adata:URL carries, under the media type it names;data_url(content, media_type=)writes one. This is whatcanvas.toDataURL()andFileReader.readAsDataURL()hand back, and how a client posts an image inside a JSON field. Not a data URL yieldsNone, so a handler tells "no picture" from "not a picture" without touching base64. - Cookies —
set_cookie(resp, name, value, max_age=, expires=, path=, domain=, secure=, http_only=, same_site=)anddelete_cookie(...). Each returns a new response carrying one moreSet-Cookie, which is the correct wire shape: two cookies are two headers, never one folded field.expiresis a@moondate.Moment, because an expiry is a moment and the format says how to write one;delete_cookieexpires by bothMax-Age=0and the epoch.Context::cookiereads them back. The cookie itself — both headers, each read and written, and the stripping of the octets RFC 6265 forbids so a value cannot forge an attribute or open a header of its own — is moonhttp'scookiepackage, which a client uses from the other side. - Status constants — the
HTTP_*andWS_*names, so a route table shows a deliberate307rather than a bare number. - One description, several uses —
Schema::of_model(model, known=)takes a moonmodel description and gives back a schema, so one model declaration drives both validation and the OpenAPI document. ARefis looked up inknown; one that is missing, or that leads back into a model already being written, comes back in the second return value with the reason rather than being guessed at — a schema is a finite value, so a cycle cannot be one. Nothing else changes: the hand-writtenSchematree is still there and still the shorter way to say something small.
Nothing here decides for you twice. Every bound is an argument with a published
default, and every default is a sensible one: ctx.query("tag") takes the preset
bounds and ctx.query("tag", [email protected]::new(pairs=100000)) a wider
one; ctx.form([email protected]::new(part_size=8 << 20)) is an upload
endpoint; create_access_token(sub, secret, now, extra=...) adds claims beside
the four it computes; sse(body, headers=[("cache-control", "no-store")]) adds a
header to the three a stream needs.
| Setting | Default | Why that one |
|---|---|---|
query_limits.pairs |
1000 | a thousand query parameters is a generous ceiling |
query_limits.size |
64 KiB | past the 8 KiB the common servers allow a whole request line |
form's limits |
@moonmime.limits |
a sane file and field count, with a part size bound |
expires_in_secs |
3600 | the hour a token grant issues |
space on a stream |
on | the space after a colon is universal on the wire and stripped by every reader |
redirect_slashes |
on | a link written with or without its trailing slash still arrives |
mcp_url, skill_url |
/mcp, /SKILL.md |
the paths an MCP client and a skill reader look for first |
tags on an exposure |
none | every operation, until an application says which corner to show |
create_access_token computes sub, iat, exp and scopes from its
arguments, and extra adds claims alongside them. sse sets the three
headers a stream needs, and headers adds its own. Where the two name the same
thing, the rule is published rather than implied:
| Function | Who wins by default | What happens when both speak |
|---|---|---|
create_access_token |
the arguments | aborts — a token whose subject is not the subject passed in is a mistake in the program |
sse |
the caller's header | replaces ours, so a response never carries two content-type headers |
Both take wins to turn the direction around and clash to choose between
merging in silence, merging with a warning passed to a function of yours,
aborting, and handing the decision to a callback. Both are
moonknob's, the same in every
library of the family, and a name given the same value twice is not a clash.
- One path for every caller.
App::to_asgiis the application;App::handle, the test client, a server and the tools an agent calls all go through it, so a test exercises the layers, mounts, lifespan state and disconnect handling a server does, not a shortcut beside them. - Middleware that is not moonapi's lives in moonasgi. CORS, gzip and the host checks are ASGI layers, so a moonapi app and any other ASGI application share one implementation and one configuration.
- One description, four surfaces. The document, the pages, the tools and the skill are all written from the routes the application holds, at the moment they are asked for — so a route added late is still described, still documented and still callable by an agent.
The site at moonbitstack.github.io/moonapi has a guide, the full API reference generated from the package interfaces and source comments, an examples gallery and a request lifecycle showcase. The showcase's request box is this library compiled to JavaScript — the page sends each request to a real application and shows the answer it gives, so the 404, the 405 and the trailing-slash redirect on that page are the ones a server answers.
Apache-2.0.