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, 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 // 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. 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, ""))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")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)),
])))
})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"))])))
})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)
}
})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.
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", _ctx => raise @exceptions.HttpException(
status = 418,
detail = Json::string("teapot"),
headers = [],
))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 serveThe 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 nativeThe 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.