Lightweight HTTP web framework and router for MoonBit, inspired by Chi
Dependencies
moon add jaredzhou/pony # latest
moon add jaredzhou/pony@0.3.0 # pin version///|
fn main {
let r = Router::Router()
// GET /hello => 200 "Hello, world!"
r.add(HttpMethod::Get, "/hello", ctx => {
ctx.write_text(status_ok, "Hello, world!")
})
// GET /greet/pony => 200 "Hello, pony!"
r.add(HttpMethod::Get, "/greet/{name}", ctx => {
let name = ctx.try_param("name").unwrap_or("stranger")
ctx.write_text(status_ok, "Hello, \(name)!")
})
start("127.0.0.1:3000", r)
}let r = Router::Router()
// Static route — exact path match
r.add(HttpMethod::Get, "/ping", pong_handler)
// Path parameter {param} — matches a single segment
r.add(HttpMethod::Get, "/users/{id}", user_handler)
// Wildcard * — matches everything after the prefix
r.add(HttpMethod::Get, "/static/*", static_handler)
// Multiple parameters in one pattern
r.add(HttpMethod::Get, "/users/{userId}/posts/{postId}", post_handler)
// Match all HTTP methods
r.any("/health", health_handler)
// Route-specific middleware (applied before the handler)
r.add(
HttpMethod::Get,
"/admin",
mws=[admin_auth],
admin_handler,
)
// Sub-router mounting — all routes under /api/v1
let api = Router::Router()
api.add(HttpMethod::Get, "/status", status_handler)
r.mount("/api/v1", api)
// Custom 404 / 405 handlers
r.set_not_found(ctx => {
ctx.reply_error(ApiError::not_found("page not found"))
})
r.set_method_not_allowed(ctx => {
ctx.write_json(status_method_not_allowed, { "error": "method not allowed" })
})| Method | Description |
|---|---|
| r.add(method, pattern, handler) | Add a route for a specific HTTP method |
| r.any(pattern, handler) | Match all HTTP methods |
| r.mount(prefix, sub_router) | Mount a sub-router under a prefix |
| r.use_mw(middleware) | Add global middleware |
| r.set_not_found(handler) | Custom 404 handler |
| r.set_method_not_allowed(handler) | Custom 405 handler |
| Pattern | Example | Matches |
|---|---|---|
| Static | /ping | /ping only |
| Param {name} | /users/{id} | /users/42, /users/alice |
| Wildcard * | /static/* | /static/css/app.css, /static/js/main.js |
let server = @pony.Server::new("127.0.0.1:3000", router)
.with_timeout(read=5000, write=10000)
server.start()!///|
let id : Int = ctx.param("id") catch {
e => {
ctx.reply_error(e)
return
}
}
///|
let token : String = ctx.header("Authorization") catch {
e => {
ctx.reply_error(e)
return
}
}
///|
let page : Int = ctx.query("page") catch { _ => 1 }///|
struct UserId(Int)
///|
pub impl @pony.FromStr for UserId with fn from_str(s) {
UserId(@pony.FromStr::from_str(s))
}
///|
let uid : UserId = ctx.param("id") catch { ... }///|
let page = ctx.try_param("page").unwrap_or("1")
///|
let q = ctx.try_query("q").unwrap_or("")
///|
let name = ctx.try_form("name").unwrap_or("")///|
struct LoginReq {
username : String
password : String
} derive(FromJson)
///|
let req : LoginReq = ctx.json() catch {
_ => {
ctx.reply_error(ApiError::invalid_argument("invalid JSON body"))
return
}
}ctx.set_content_type("application/json")
ctx.write_text(status_ok, "Hello")
ctx.write_json(status_ok, {"key": "value"})
ctx.reply_ok({"status": "ok"}) // 200 JSON
ctx.reply_error(ApiError::invalid_argument("bad input")) // 400 JSON
ctx.reply_error(ApiError::not_found("not found")) // 404 JSON
ctx.reply_error(ApiError::permission_denied("not allowed")) // 403 JSON
ctx.redirect("/login") // 302
ctx.no_content() // 204| Raising version | Option / non-raising version | Description |
|---|---|---|
| ctx.param("id") → T | ctx.try_param("id") → String? | Path parameter — T : FromStr |
| ctx.query("q") → T | ctx.try_query("q") → String? | Query string value — T : FromStr |
| ctx.header("Accept") → T | ctx.try_header("Accept") → String? | Request header — T : FromStr |
| ctx.form("name") → T | ctx.try_form("name") → String? | Form field (async) — T : FromStr |
| ctx.wildcard() → String | ctx.try_wildcard() → String? | Wildcard path capture |
| ctx.json[T]() → T | — | JSON body deserialization — T : FromJson |
| ctx.form_file("file") → FileHeader | ctx.try_form_file("file") → FileHeader? | Uploaded file header |
///|
// Marker type — an empty struct used as a type-safe key
struct UserId {}
///|
// Middleware: extract user_id from header and store in context
fn auth_middleware(next : Handler) -> Handler {
ctx => {
match ctx.try_header("X-User-Id") {
Some(user_id) => ctx.set_ext(UserId{}, user_id)
None => {
ctx.reply_error(ApiError::unauthenticated("missing X-User-Id header"))
return
}
}
next(ctx)
}
}
///|
fn main {
let r = Router::Router()
r.use_mw(auth_middleware)
r.add(HttpMethod::Get, "/me", ctx => {
let user_id : String = ctx.get_ext(UserId{}) catch {
e => {
ctx.reply_error(e)
return
}
}
ctx.reply_ok({ "user_id": user_id })
})
start("127.0.0.1:3000", r)
}ctx.set_ext(@pony.RequestId{}, "req-abc")
ctx.set_ext(@pony.UserId{}, "user-42")
let uid = ctx.get_ext(@pony.UserId{}) catch {
e => {
ctx.reply_error(e)
return
}
}| Method | Description |
|---|---|
| ctx.set_ext(marker, value) | Store a typed value |
| ctx.get_ext(marker) | Retrieve, raises PonyError::MissingExt if absent |
| ctx.try_get_ext(marker) | Retrieve, returns Option |
| ctx.remove_ext(marker) | Remove a stored value |
r.use_mw(@mw.logger())
r.use_mw(@mw.cors(
allow_origins=["*"],
allow_methods=["GET", "POST"],
))
r.use_mw(@mw.jwt(new_hmac_sha256(secret)))| Middleware | Description |
|---|---|
| logger() | Request logging (method, path, status, duration) |
| cors() | CORS headers with configurable origins, methods, headers, max-age |
| jwt(signing_method) | JWT bearer token auth, stores sub claim via set_ext(UserId, …) |
///|
pub suberror PonyError {
MissingParam(String)
MissingQuery(String)
MissingForm(String)
MissingHeader(String)
MissingExt(String)
ExtDecodeError(String, String)
MissingFormFile(String)
InvalidValue(String, String)
}ctx.reply_error(ApiError::invalid_argument("invalid id"))
ctx.reply_error(ApiError::not_found("list not found"))
ctx.reply_error(ApiError::permission_denied("access denied"))///|
pub impl @pony.ToApiError for MyError with fn to_api_error(self : MyError) -> @pony.ApiError {
match self {
MyError::NotFound(m) => @pony.ApiError::not_found(m)
MyError::Forbidden(m) => @pony.ApiError::permission_denied(m)
}
}
// Now you can pass MyError directly:
ctx.reply_error(my_error)///|
r.add(HttpMethod::Post, "/upload", async ctx => {
// Parse the multipart body (call once per request)
ctx.parse_multipart_form()!
// Read regular form fields
let title : String = ctx.form("title") catch {
e => { ctx.reply_error(e); return }
}
// Access uploaded files
let file = ctx.form_file("avatar") catch {
e => { ctx.reply_error(e); return }
}
// File metadata available immediately
println("received: \{file.filename} (\{file.size} bytes, \{file.content_type})")
// Read file content (async)
let data = file.bytes()
ctx.reply_ok({
"title": title,
"filename": file.filename,
"size": file.size,
})
})| Method | Returns | Description |
|---|---|---|
| ctx.parse_multipart_form() | Unit | Parse multipart body (call before accessing files/fields) |
| ctx.form("key") | T raise PonyError | Form field value, parsed via FromStr (use try_form for raw String?) |
| ctx.form_file("key") | FileHeader raise PonyError | Single uploaded file (use try_form_file for Option) |
| ctx.form_files("key") | Array[FileHeader] | All files for a multi-file field |
| file.bytes() | Bytes | Read full file content |
| file.filename | String | Original filename |
| file.size | Int64 | Uncompressed file size |
| file.content_type | String? | MIME type (e.g. "image/png") |
| file.path() | String? | Temp file path if spilled to disk |
pub trait Closer {
fn close(Self) -> Unit
}pub(open) trait FromStr {
fn from_str(String) -> Self raise
}let id : Int = ctx.param("id") // parse failure -> InvalidValue (400)
let name : String = ctx.param("name") // identity, never failspub impl @pony.FromStr for MyType with fn from_str(s) {
...
}test {
let n : Int = FromStr::from_str("42") catch { _ => -1 }
inspect(n, content="42")
let b : Bool = FromStr::from_str("true") catch { _ => false }
inspect(b, content="true")
}pub(all) suberror MultipartError {
MissingBoundary
MissingName
MalformedBody(String)
FileTooLarge(String, Int64)
FileIOError(String)
} derive(Debug)impl ToApiError for PonyErrorimpl ToApiError for ApiErrorpub(all) struct Context {
http_method : HttpMethod
url_str : String
url : URL
route_path : String
req_headers : HttpHeaders
resp_headers : HttpHeaders
queries : Values
form_values : Values?
multipart_form : MultipartForm?
exts : ExtStore
path_params : Array[ParamKV]
req_body : &Reader
resp_writer : ServerConnection
}async fn Context::parse_multipart_form(self : Context, max_memory? : Int64, writers? : Map[String, FileWriter]) -> Unitpub(all) struct FileHeader {
filename : String
size : Int64
content_type : String?
header : PartHeaders
backend : FileBackend
}type MultipartFormpub(all) struct Part {
source : &Reader
delimiter : Bytes
headers : PartHeaders
buf : Buffer
body_eof : Bool
}pub(all) struct RequestId {
}fn Router::add(self : Router, meth : HttpMethod, pattern : String, name? : String, mws? : Array[Middleware], handler : Handler) -> Unit raise RouterErrorfn Router::any(self : Router, pattern : String, name? : String, mws? : Array[Middleware], handler : Handler) -> Unit raise RouterErrorasync fn Router::handle(self : Router, req : Request, req_body : &Reader, resp_writer : ServerConnection) -> Unitpub struct Server {
router : Router
addr : String
read_timeout : Int?
write_timeout : Int?
request_id : Int
}pub(all) struct UserId {
}let default_max_memory : Int64let status_service_unavailable : Intlet status_unavailable_for_legal_reasons : Intlet unavailable : IntLightweight HTTP web framework and router for MoonBit, inspired by Chi
Dependencies