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.

Routes as

1 · Install and your first application

Add the package with moon add moonbitstack/moonapi. A handler receives a context and returns a response. Routes can be written three ways, and the switch at the top of the page picks which one every example shows: annotations on handler functions, one call per route, or a type that answers the methods of a path. All three build the same application.


///|
#app.get("/")
fn hello(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "Hello from moonapi")
}

///|
#app.post("/users")
fn create_user(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(201, "created")
}

///|
#app.get("/users/{id}")
fn user(ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "user " + ctx.param("id").unwrap_or(""))
}

let app = @moonapi.App::new()
register(app)

The generator reads the annotations at build time and writes register(app). Wire it into the build once: a rule in moon.mod running moonx moonbitstack/moonapi/cmd/routes, and a dev_build in the package's moon.pkg; the README has both lines.

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(""))
})

Each route is one call on the app: the verb, the path template and the handler. Annotations generate exactly these calls, and routes built at run time are written this way.


///|
struct Home {}

///|
impl @moonapi.View for Home with fn get(_) {
  Some(_ctx => @moonapi.text(200, "Hello from moonapi"))
}

///|
struct Users {}

///|
impl @moonapi.View for Users with fn post(_) {
  Some(_ctx => @moonapi.text(201, "created"))
}

///|
struct User {}

///|
impl @moonapi.View for User with fn get(_) {
  Some(ctx => @moonapi.text(200, "user " + ctx.param("id").unwrap_or("")))
}

let app = @moonapi.App::new()
app.view("/", Home::{ })
app.view("/users", Users::{ })
app.view("/users/{id}", User::{ })

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 listing the ones it does answer.

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. Every HTTP verb has its own annotation, method and View method, and App::route or #app.route attaches any other.


///|
#app.get("/items/{id}")
fn read_item(ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "item " + ctx.param("id").unwrap_or(""))
}

///|
#app.post("/items")
fn create_item(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(201, "created")
}

///|
#app.put("/items/{id}")
fn replace_item(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "replaced")
}

///|
#app.patch("/items/{id}")
fn patch_item(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "patched")
}

///|
#app.delete("/items/{id}")
fn delete_item(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "deleted")
}

///|
#app.head("/items")
fn head_items(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "")
}

///|
#app.options("/items")
fn options_items(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(204, "")
}
app.get("/items/{id}", ctx => {
  @moonapi.text(200, "item " + ctx.param("id").unwrap_or(""))
})
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.head("/items", _ctx => @moonapi.text(200, ""))
app.options("/items", _ctx => @moonapi.text(204, ""))

///|
struct Items {}

///|
impl @moonapi.View for Items with fn post(_) {
  Some(_ctx => @moonapi.text(201, "created"))
}

///|
impl @moonapi.View for Items with fn head(_) {
  Some(_ctx => @moonapi.text(200, ""))
}

///|
impl @moonapi.View for Items with fn options(_) {
  Some(_ctx => @moonapi.text(204, ""))
}

///|
struct Item {}

///|
impl @moonapi.View for Item with fn get(_) {
  Some(ctx => @moonapi.text(200, "item " + ctx.param("id").unwrap_or("")))
}

///|
impl @moonapi.View for Item with fn put(_) {
  Some(_ctx => @moonapi.text(200, "replaced"))
}

///|
impl @moonapi.View for Item with fn patch(_) {
  Some(_ctx => @moonapi.text(200, "patched"))
}

///|
impl @moonapi.View for Item with fn delete(_) {
  Some(_ctx => @moonapi.text(200, "deleted"))
}

app.view("/items", Items::{ })
app.view("/items/{id}", Item::{ })
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 -> 405 {"detail":"Method Not Allowed"}

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")
fn search(ctx : @moonapi.Context) -> @moonasgi.Response {
  let q = ctx.query("q").unwrap_or("")
  let limit = ctx.query("limit").unwrap_or("20")
  @moonapi.json(200, { "q": Json(q), "limit": Json(limit) })
}
app.get("/search", ctx => {
  let q = ctx.query("q").unwrap_or("")
  let limit = ctx.query("limit").unwrap_or("20")
  @moonapi.json(200, { "q": Json(q), "limit": Json(limit) })
})

///|
struct Search {}

///|
impl @moonapi.View for Search with fn get(_) {
  Some(ctx => {
    let q = ctx.query("q").unwrap_or("")
    let limit = ctx.query("limit").unwrap_or("20")
    @moonapi.json(200, { "q": Json(q), "limit": Json(limit) })
  })
}

app.view("/search", Search::{ })
recorded · App::handle
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")
fn login(ctx : @moonapi.Context) -> @moonasgi.Response {
  match ctx.body_json() {
    Some(body) => @moonapi.json(200, body)
    None => @moonapi.json(400, { "error": "bad json" })
  }
}
app.post("/login", ctx => {
  match ctx.body_json() {
    Some(body) => @moonapi.json(200, body)
    None => @moonapi.json(400, { "error": "bad json" })
  }
})

///|
struct Login {}

///|
impl @moonapi.View for Login with fn post(_) {
  Some(ctx => {
    match ctx.body_json() {
      Some(body) => @moonapi.json(200, body)
      None => @moonapi.json(400, { "error": "bad json" })
    }
  })
}

app.view("/login", Login::{ })
recorded · App::handle
POST /login {"user":"heke","pass":"s3cret"} -> 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.


///|
#derive(FromJson)
priv struct GreetReq {
  name : String
}

///|
fn GreetReq::schema() -> @schema.Schema {
  @schema.Schema::object("GreetReq", [@schema.Field::new("name", SStr)])
}

///|
#app.post("/greet")
fn greet(ctx : @moonapi.Context) -> @moonasgi.Response {
  match (ctx.body_validated(GreetReq::schema()) : Result[GreetReq, _]) {
    Ok(g) => @moonapi.text(200, "Hello, " + g.name)
    Err(e) => @moonapi.unprocessable(e)
  }
}

///|
#derive(FromJson)
priv struct GreetReq {
  name : String
}

///|
fn GreetReq::schema() -> @schema.Schema {
  @schema.Schema::object("GreetReq", [@schema.Field::new("name", SStr)])
}

///|

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)
  }
})

///|
#derive(FromJson)
priv struct GreetReq {
  name : String
}

///|
fn GreetReq::schema() -> @schema.Schema {
  @schema.Schema::object("GreetReq", [@schema.Field::new("name", SStr)])
}

///|


///|
struct Greet {}

///|
impl @moonapi.View for Greet with fn post(_) {
  Some(ctx => {
    match (ctx.body_validated(GreetReq::schema()) : Result[GreetReq, _]) {
      Ok(g) => @moonapi.text(200, "Hello, " + g.name)
      Err(e) => @moonapi.unprocessable(e)
    }
  })
}

app.view("/greet", Greet::{ })
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"}]}

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")
fn boom(_ctx : @moonapi.Context) -> @moonasgi.Response raise {
  raise @exceptions.HttpException(status=418, detail="teapot", headers=[])
}
app.get("/boom", _ctx => {
  raise @exceptions.HttpException(status=418, detail="teapot", headers=[])
})

///|
struct Boom {}

///|
impl @moonapi.View for Boom with fn get(_) {
  Some(_ctx => {
    raise @exceptions.HttpException(status=418, detail="teapot", headers=[])
  })
}

app.view("/boom", Boom::{ })
recorded · App::handle
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", summary="Health check", tags="misc")
fn ping(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.text(200, "pong")
}

///|
#app.post("/echo", summary="Echo", tags="misc")
fn echo(_ctx : @moonapi.Context) -> @moonasgi.Response {
  @moonapi.json(200, { "ok": true })
}
app.get("/ping", _ctx => @moonapi.text(200, "pong"), summary="Health check", tags=[
  "misc",
])
app.post(
  "/echo",
  _ctx => @moonapi.json(200, { "ok": true }),
  summary="Echo",
  tags=["misc"],
)

///|
struct Ping {}

///|
impl @moonapi.View for Ping with fn get(_) {
  Some(_ctx => @moonapi.text(200, "pong"))
}

///|
struct Echo {}

///|
impl @moonapi.View for Echo with fn post(_) {
  Some(_ctx => @moonapi.json(200, { "ok": true }))
}

app.view("/ping", Ping::{ }, tags=["misc"])
app.view("/echo", Echo::{ }, tags=["misc"])

app.view takes the tags its methods share. A summary belongs to one method and is given with an annotation or a call, so this document carries the tags but no summaries.

let doc = app.openapi_json(version=OpenApi31)
recorded · openapi_json
{ "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.

cd integration && moon run src/full-example --target native

The bundled 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.