Moved on mooncakes from
Lfan-ke/moonapitomoonbitstack/moonapi.
moonapi builds an AsgiApp from typed routes and generates its own OpenAPI / Swagger document — the role FastAPI plays for Python. It depends only on moonasgi, so it's backend-agnostic (routing and OpenAPI run in-process on every backend); a server such as mooncat runs the resulting app.
flowchart LR
routes["typed routes<br/>App::get / post / …"] --> app["**moonapi** App"]
app -->|"App::to_asgi()"| asgi(["moonasgi AsgiApp"])
app -->|"App::openapi()"| spec["OpenAPI 2.0 / 3.0 / 3.1"]
asgi --> cat["mooncat serves it"]
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()),
summary="fetch a user")
app.post("/users", _ctx => @moonapi.text(201, "created"))
// One set of routes → every mainstream spec version:
let v31 = app.openapi_json(version=OpenApi31) // OpenAPI 3.1.0
let v30 = app.openapi_json(version=OpenApi30) // OpenAPI 3.0.3
let v20 = app.openapi_json(version=Swagger20) // Swagger 2.0
let docs_page = @moonapi.swagger_ui() // a Swagger UI page
// Serve it (native, via mooncat):
@mooncat.serve(app.to_asgi(), port=8000)- Routing —
App::get/post/put/patch/delete/route,:parampath segments extracted intoContext::param, correct404(no path) vs405(path but not method). A route takes the operation arguments FastAPI's path operations take —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 (←url_path_for), mount prefix included. - 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 (← FastAPI'sAPIRouter/include_router). 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. - Descriptor tree — a runtime
Schema/Param/Endpointtree (a one-first-class-value substitute for FastAPI's from-signature reflection) 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 (the mctl-friendly shape). - Multi-version OpenAPI —
App::openapi/openapi_jsonemit 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), because a good FastAPI is not pinned to one spec version. - Typed body extractors —
Context::body[T]deserialises the JSON body into aderive(FromJson)struct;Context::body_validated[T]first checks it against the endpoint descriptor and returns either the built value or a FastAPI-shaped422error list — schema emission, validation, and deserialisation all off one descriptor. - Dependency injection — a
Container(provider registry +dependency_overrides) with request-scoped, one-shot resolution (per-request caching) andyield-style teardown run LIFO around the handler — the explicit MoonBit equivalent of FastAPI'sDepends.App::depends(container.erase())attaches one to the application, and any route builder takesdependencies=["key"](←dependencies=[Depends(...)]): 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 — all of that ismooncred's to guarantee, and it does. - 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/mime's; a body is attacker-controlled and already buffered, so@mime.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 — an outer middleware chain (
App::middleware) withcors(...)(preflight + actual-request headers, configurable origins/methods/headers, credentials, exposed headers) andgzip(...)(moonzip's compressor), plus 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 (FastAPI's split between a scheme and a wired dependency). - Background tasks — a background-aware route (
App::route_bg) receives aBackgroundTasksqueue;add_taskdefers work that the app runs, in order, after the response is sent (← FastAPI'sBackgroundTasks), so a slow write never delays the client. - Sub-application mounting —
App::mount(prefix, subapp)composes routers: a request underprefixis routed by the sub-app with the prefix stripped (its own middleware, 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.App::mount_handler(prefix, handler)mounts a foreign moonasgiHandlerthe same way — a third-party component, or static files — which the app routes to but does not document. - Streaming responses —
App::stream(path, handler)/App::route_stream(verb, ...)register a route returning amoonasgi.StreamingResponse, whose chunks reach the client as separate body events — a client reads the first long before the last one exists.App::handle_with_streamis the chunk-level view a test reads. A middleware is typed buffered-in, buffered-out, so one that rewrites the body (gzip) collapses the reply to a single chunk rather than cutting new bytes at boundaries that no longer describe them. - Server-Sent Events —
moonhttp/sseframes an event per the WHATWG event-stream format (id/event/retry/ multi-linedata/:comments);sse_responseis atext/event-streamstream of those frames, one chunk per event, so each dispatches on arrival. Hand it toApp::stream. (An event stream delivered as one body is not an event stream — it is a file shaped like one.) - WebSocket routes —
App::websocket(path, handler)over the moonasgi WS SEAM. The handler drives aWebSocket(accept / receive / send / close); it's a synchronous core, sodrive_websocketruns it against an in-memory frame queue in a test andApp::to_asgiserves it over the async transport. - 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. - Swagger UI —
swagger_ui()returns a ready-to-serve documentation page. - Responses —
text,html,jsonandjson_modelhelpers overmoonasgi.Response, plusredirect(url, status=307)(←RedirectResponse; 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=)(←FileResponse: 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. - Cookies —
set_cookie(resp, name, value, max_age=, expires=, path=, domain=, secure=, http_only=, same_site=)anddelete_cookie(...)(←response.set_cookie/delete_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. Names, values and attributes are stripped of the octets RFC 6265 forbids, so a value cannot forge an attribute or open a header of its own;delete_cookieexpires by bothMax-Age=0and a 1970 date.Context::cookiereads them back. - Status constants — the 63
HTTP_*and 15WS_*names FastAPI re-exports from Starlette (HTTP_404_NOT_FOUND,WS_1008_POLICY_VIOLATION), so a route table shows a deliberate307rather than a bare number.
Verified across all backends (wasm, wasm-gc, js, native) in CI, 0 warnings under --deny-warn.
Nothing here decides for you twice. Every bound is an argument with a published default, and every default is the one the mainstream framework uses.
ctx.query("tag") // the preset bound
ctx.query("tag", limits=@mime.Limits::new(parts=100000))
ctx.form(limits=@mime.Limits::new(part_size=8 << 20)) // an upload endpoint
create_access_token(sub, secret, now, extra={ "tenant": Json::string("acme") })
sse_response(events, headers=[("cache-control", "no-store")])
sse_response(events, space=false)| Setting | Default | Why that one |
|---|---|---|
query_limits.parts |
1000 | qs, and therefore Express, allows a thousand query parameters |
query_limits.part_size |
64 KiB | Well past the 8 KiB the common servers allow a whole request line |
form's limits |
@mime.limits |
Starlette's max_files and max_fields, with python-multipart's part size |
expires_in_secs |
3600 | The hour every OAuth2 example issues |
space on a stream |
on | The space after a colon is universal on the wire and stripped by every reader |
create_access_token computes sub, iat, exp and scopes from its
arguments, and extra adds claims alongside them. sse_response 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_response |
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, aborting, and handing the decision to a callback of your
own. They are the same wins and clash mooncred uses, with the same
meanings.
Two places make an explicit, documented trade-off rather than a silent shortcut:
- GZip is
moonzip's, whose output zlib reads and whose reader takes what zlib writes. What lives here is the middleware around it: when to compress, and which headers to set. - WebSocket handlers are a synchronous core: the same handler runs in a test and under a server. Because the core can't suspend on the async transport (MoonBit runs async only in an async context), the serving shell buffers the client's inbound frames, runs the handler, then emits its frames. Content and order are preserved — exact for echo, broadcast, and request-reply — but it doesn't interleave live per-frame with the client.
The descriptor tree (Endpoint / Param / Schema) drives OpenAPI body schemas and validation; on top sit the typed derive(FromJson) body extractors, the dependency-injection container, the OAuth2 password-bearer security layer, the Form / File extractors, and response_model filtering. On the middleware side: CORS, gzip, exception and per-status handlers, Server-Sent Events, and WebSocket routes.
0.9.0 is where moonapi stopped being several libraries at once. The cryptography went to mooncrypt, the token format to mooncred, the wire formats to moonhttp, the compression to moonzip, and the JSON to moonjson — 2 329 lines out of the framework and into libraries anything can use, with every existing test still passing. What is left is routing, extraction, validation, OpenAPI, injection and middleware, which is what a web framework is.
Next: splitting what remains into packages, so a program that wants the router does not link the OpenAPI writer; static files and templates; and codegen'd request schemas via moonctl.
Apache-2.0.