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.
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 // 200When 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.
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::{ })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::{ })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::{ })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::{ })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@?") }) |> ignoreWhen 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.
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::{ })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)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 nativeThe 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.