moonapi
Guide

From an empty application to a documented service

This guide builds a moonapi service step by step. Every fragment is real MoonBit that compiles, and every result beneath it is the output of running that exact code through App::handle — no paraphrasing.

1 · Install and your first application

Add the package, then build one application object and register a route. A handler receives a context and returns a response.

let app = @moonapi.App::new()
app.get("/", _ctx => @moonapi.text(200, "Hello from moonapi"))
app.post("/users", _ctx => @moonapi.text(201, "created"))
app.get("/users/{id}", ctx =>
  @moonapi.text(200, "user " + ctx.param("id").unwrap_or("")))

A moonapi application is just a value. You can run a request through it in-process with App::handle, which returns a response you can inspect — useful for tests and for the demos on this page.

let response = app.handle({
http_method: "GET", path: "/users/42",
query_string: b"", headers: [], body: b"",
})
response.status  // 200

When no handler matches the path, you get 404; when the path exists but not for that method, you get 405. Both are produced by the framework, not written by hand.

recorded · App::handle
GET / -> 200 Hello from moonapi GET /users/42 -> 200 user 42 POST /users -> 201 created GET /missing -> 404 {"detail":"Not Found"} DELETE / -> 405 {"detail":"Method Not Allowed"}

2 · Routes, verbs and path parameters

A {name} piece in the path becomes a parameter you read from the context. The same verb may be bound to several paths, and any HTTP method can be attached with App::route.

app.get("/items/{id}", ctx =>
  @moonapi.text(200, "item " + ctx.param("id").unwrap()))
app.post("/items",       _ctx => @moonapi.text(201, "created"))
app.put("/items/{id}",   _ctx => @moonapi.text(200, "replaced"))
app.patch("/items/{id}", _ctx => @moonapi.text(200, "patched"))
app.delete("/items/{id}",_ctx => @moonapi.text(200, "deleted"))
app.route(Head,    "/items", _ctx => @moonapi.text(200, ""))
app.route(Options, "/items", _ctx => @moonapi.text(204, ""))
recorded · every verb
GET /items/233 -> 200 item 233 POST /items -> 201 created PUT /items/233 -> 200 replaced PATCH /items/233 -> 200 patched DELETE /items/233 -> 200 deleted HEAD /items -> 200 OPTIONS/items -> 204 GET /items (exists, wrong verb) -> 405 {"detail":"Method Not Allowed"} GET /missing (no such path) -> 404 {"detail":"Not Found"}

Routes can be named so you can build their URLs later with App::url_for — handy for redirects and links.

let app = @moonapi.App::new()
app.get("/items/{id}", _ctx => @moonapi.text(200, "x"), name="get_item")
app.url_for("get_item", params=Map([("id", "77")]))
// Some("/items/77")

3 · Reading the request

The context exposes the path, the query string, the headers and the body. Each extractor returns an option, so you handle the missing case inline.

app.get("/search", ctx => {
  let q = ctx.query("q").unwrap_or("")
  let limit = ctx.query("limit").unwrap_or("20")
  @moonapi.json(200, Json::object(Map([
    ("q", Json::string(q)),
    ("limit", Json::string(limit)),
  ])))
})
recorded · GET /search?q=cat&limit=5
200 {"q":"cat","limit":"5"}

For a JSON body, Context::body_json returns the parsed value, or None if the body is not valid JSON.

app.post("/login", ctx =>
  match ctx.body_json() {
    Some(j) => @moonapi.json(200, j)
    None    => @moonapi.json(400, Json::object(Map([
                 ("error", Json::string("bad json"))])))
  })
recorded · POST /login
200 {"user":"heke","pass":"s3cret"}

4 · Typed bodies and validation

When the payload matters, ask for a typed value. Context::body_validated checks the JSON against a descriptor and deserialises into a derive(FromJson) struct; if it does not conform you receive located errors instead of a crash.

app.post("/greet", ctx => {
  match (ctx.body_validated(GreetReq::schema()) : Result[GreetReq, _]) {
    Ok(g)  => @moonapi.text(200, "Hello, " + g.name)
    Err(e) => @moonapi.unprocessable(e)
  }
})
recorded · validated extraction
POST /greet {"name":"lfanke"} -> 200 Hello, lfanke POST /greet {"name":233} -> 422 {"detail":[{"type":"string_type","loc":["body","name"],"msg":"Input should be a valid string"}]} POST /greet -> 422 {"detail":[{"type":"missing","loc":["body"],"msg":"Field required"}]}

The unchecked counterpart Context::body returns a T? — None when the body cannot shape-match, rather than an error. Use it when a missing payload is not exceptional.

5 · Shared services with dependency injection

Register services in a container and let the framework build them per request, caching each one and tearing it down when the request ends. A sum type holds the different service shapes in one type-safe container.

enum Dep {
  Cfg(String)
  Conn(String)
  Repo(String)
}

let c : @moonapi.Container[Dep] = @moonapi.Container::new()
c.provide("config", () => Cfg("prod-dsn")) |> ignore
c.provide_using("conn", scope => {
  let dsn = match scope.get("config") { Some(Cfg(s)) => s; _ => "?" }
  Conn(dsn)
}) |> ignore
c.provide_using("repo", scope =>
  match scope.get("conn") { Some(Conn(d)) => Repo("repo@" + d); _ => Repo("repo@?") }) |> ignore

When a request resolves repo, the container pulls conn, which in turn pulls config — each built once. Overriding config for a request swaps the whole chain, the way a test substitutes a fake.

recorded · three requests
request A: [open] conn -> prod-dsn [close] conn -> 200 repo@prod-dsn conn opened 1 time(s) request B (config overridden): [open] conn -> test-dsn [close] conn -> 200 repo@test-dsn request C (manual scope): resolved prod-dsn unknown key resolved: false

6 · Errors as values

Raise a typed HttpException from anywhere in a handler; the framework turns it into a response with the status and detail you gave it.

app.get("/boom", _ctx => raise @exceptions.HttpException(
  status = 418,
  detail = Json::string("teapot"),
  headers = [],
))
recorded · GET /boom
418 {"detail":"teapot"}

7 · Generating an OpenAPI document

Give each route a summary and tags and the application can emit an OpenAPI 3.1 document on demand. Serve it at /openapi.json or hand it to a documentation UI.

app.get("/ping", _ctx => @moonapi.text(200, "pong"),
  summary="Health check", tags=["misc"])
app.post("/echo", _ctx => @moonapi.json(200,
  Json::object(Map([("ok", Json::boolean(true))]))),
  summary="Echo", tags=["misc"])

let doc = app.openapi_json(version=OpenApi31)
// doc is a JSON string ready to serve
recorded · openapi_json (first lines)
{ "openapi": "3.1.0", "info": { "title": "moonapi", "version": "0.1.0" }, "paths": { "/ping": { "get": { "summary": "Health check", "tags": ["misc"], "responses": { "200": { "description": "OK" } } } }, "/echo": { "post": { "summary": "Echo", "tags": ["misc"], "responses": { "200": { "description": "OK" } } } } } }

8 · Serving it

The same application runs on a native server or compiles to WebAssembly. With the Mooncat server the application listens on 127.0.0.1:12000; open /docs for the API reference, /redoc for ReDoc, or /openapi.json for the document.

moon run src/examples/25-full-example --target native

The bundled 25-full-example composes routers, authentication, typed bodies, response filtering, CORS and gzip layers, dependency injection, forms, files, background tasks, server-sent events, WebSockets, lifespan hooks and OpenAPI into one integrated service. The focused examples in src/examples/ isolate each of those features.

Where to go next. The API reference lists every public type, function and method with its parameters. The showcase visualises how a request travels through the application.