Sign in

    pony

    Lightweight HTTP web framework and router for MoonBit, inspired by Chi

    web
    framework
    http
    router
    server
    middleware
    rest
    api
    json
    auth
    jwt
    cors
    Download zip
    Author
    Version
    0.2.2
    License
    Apache-2.0
    Last updated
    3 months ago
    Downloads
    83

    #jaredzhou/pony

    A lightweight HTTP web framework for MoonBit, inspired by Go's Chi.

    #Installation

    moon add jaredzhou/pony

    #Quick Start

    ///|
    fn main {
    let r = Router::Router()

    // GET /ping => 200 "pong"
    r.add(HttpMethod::Get, "/ping", fn(ctx) { ctx.write_text(status_ok, "pong") })

    // GET /users/{id} => 200 "user: 42"
    // ctx.param raises PonyError if missing — use try_param for Option
    r.add(HttpMethod::Get, "/users/{id}", fn(ctx) {
    let id = ctx.try_param("id").unwrap_or("")
    ctx.write_text(status_ok, "user: \(id)")
    })

    // GET /search?q=pony => 200 "searching: pony"
    r.add(HttpMethod::Get, "/search", fn(ctx) {
    let q = ctx.try_query("q").unwrap_or("")
    ctx.write_text(status_ok, "searching: \(q)")
    })

    // GET /api/status => 200 {"status":"ok"}
    r.add(HttpMethod::Get, "/api/status", fn(ctx) {
    ctx.reply_ok({ "status": "ok" })
    })

    // POST /api/items — standard error pattern with to_api_error
    r.add(HttpMethod::Post, "/api/items", async fn(ctx) {
    let req : ItemReq = ctx.json() catch {
    e => {
    ctx.reply_error(to_api_error(PonyError::InvalidArgument(e.to_string())))
    return
    }
    }
    ctx.reply_ok({ "created": true })
    })

    // GET /static/css/app.css => 200 "file: css/app.css"
    r.add(HttpMethod::Get, "/static/*", fn(ctx) {
    let path = ctx.try_wildcard().unwrap_or("")
    ctx.write_text(status_ok, "file: \(path)")
    })

    // Handler with auth — ctx.header raises PonyError, caught and converted
    r.add(HttpMethod::Get, "/me", async fn(ctx) {
    let user = ctx.header("X-User") catch {
    e => {
    ctx.reply_error(to_api_error(e))
    return
    }
    }
    ctx.reply_ok({ "user": user })
    })

    start("127.0.0.1:3000", r)
    }

    #Features

    #Router

    • Radix tree with priority matching for static, parameter, and wildcard routes
    • {param} path parameters and * wildcard capture
    • Route-specific middleware per endpoint
    • Sub-router mounting with mount()
    • Custom 404 and 405 handlers

    #Middleware

    Built-in via jaredzhou/pony/mw:

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

    MiddlewareDescription
    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 in context

    #Request Context (Context)

    Every accessor comes in two forms:

    Raising version (new)Option version (try_*)Description
    ctx.param("id")ctx.try_param("id")Path parameter (raises PonyError::MissingParam)
    ctx.query("search")ctx.try_query("search")Query string value (raises PonyError::MissingQuery)
    ctx.header("Accept")ctx.try_header("Accept")Request header (raises PonyError::MissingHeader)
    ctx.form("name")ctx.try_form("name")Form field — async (raises PonyError::MissingForm)
    ctx.wildcard()ctx.try_wildcard()Wildcard path capture (raises PonyError::MissingParam)
    ctx.json[T]()(catch pattern)JSON body deserialization
    ctx.form_file("file")ctx.try_form_file("file")Uploaded file header (raises PonyError::MissingFormFile)

    Standard error handling pattern — the raising methods work with to_api_error:

    ///|
    let user = ctx.header("X-User") catch {
    e => {
    ctx.reply_error(to_api_error(e))
    return
    }
    }

    For cases where a missing value should fall back to a default, use the try_ variant:

    ///|
    let q = ctx.try_query("search").unwrap_or("")

    ///|
    let page = ctx.try_query("page").unwrap_or("1")

    Response helpers:

    ctx.set_content_type("application/json")
    ctx.write_text(200, "Hello")
    ctx.write_json(200, {"key": "value"})
    ctx.reply_ok({"status": "ok"}) // 200 JSON
    ctx.reply_error(to_api_error(PonyError::InvalidArgument("bad input"))) // error JSON
    ctx.reply_error(ApiError::new(invalid_argument, "bad input")) // direct ApiError
    ctx.redirect("/login") // 302 redirect
    ctx.no_content() // 204

    #Error handling

    PonyError is the unified error type for context accessors. Use to_api_error to convert it:

    ///|
    pub suberror PonyError {
    MissingParam(String)
    MissingQuery(String)
    MissingForm(String)
    MissingHeader(String)
    MissingExt(String)
    ExtDecodeError(String, String)
    MissingFormFile(String)
    InvalidArgument(String) // generic 400
    NotFound(String) // generic 404
    PermissionDenied(String) // generic 403
    }

    For non-context errors (e.g. failed auth checks, invalid IDs from parse_int), construct a PonyError variant and pass to to_api_error:

    ctx.reply_error(to_api_error(PonyError::InvalidArgument("invalid id")))
    ctx.reply_error(to_api_error(PonyError::NotFound("list not found")))

    For ctx.json() parse failures, capture the error and wrap it:

    ///|
    let req : MyBody = ctx.json() catch {
    e => {
    ctx.reply_error(to_api_error(PonyError::InvalidArgument(e.to_string())))
    return
    }
    }

    #Extension Store

    Type-safe key-value store for request-scoped data:

    ctx.set_ext(@pony.RequestId{}, "req-abc")
    ctx.set_ext(@pony.UserId{}, "user-42")

    let uid = ctx.get_ext(@pony.UserId{})

    Full HTTP header management with canonical keys and multi-value support:

    let h = @pony.new()
    h.set("Content-Type", "application/json")
    h.add("Set-Cookie", "session=abc")
    let ct = h.get("Content-Type") // "application/json"

    #Server

    let server = @pony.Server::new("127.0.0.1:3000", router)
    .with_timeout(read=5000, write=10000)
    server.start()!

    #License

    Apache-2.0

    Closer

    pub trait Closer {
    fn close(Self) -> Unit
    }

    Close the resource. Safe to call multiple times.

    ReaderCloser

    pub trait ReaderCloser :
    Reader
    + Closer {
    }

    A reader that can be closed.

    WriterCloser

    pub trait WriterCloser :
    Writer
    + Closer {
    }

    A writer that can be closed.

    ExtError

    pub(all) suberror ExtError {
    Missing
    DecodeError(key~ : String, msg~ : String)
    } derive(Eq, ToJson,
    Debug
    )

    MultipartError

    pub(all) suberror MultipartError {
    MissingBoundary
    MissingName
    MalformedBody(String)
    FileTooLarge(String, Int64)
    FileIOError(String)
    } derive(
    Debug
    )

    Errors that may occur during multipart parsing.

    PonyError

    pub(all) suberror PonyError {
    MissingParam(String)
    MissingQuery(String)
    MissingForm(String)
    MissingHeader(String)
    MissingExt(String)
    ExtDecodeError(String, String)
    MissingFormFile(String)
    InvalidArgument(String)
    NotFound(String)
    PermissionDenied(String)
    } derive(ToJson,
    Debug
    )

    Unified error type for context accessor methods. Raised by param, query, form, header, form_file, get_ext, and wildcard when the requested value is missing. Also used for generic request-level errors in handlers.

    RouterError

    pub suberror RouterError {
    InvalidPattern(http_method~ : String, path~ : String, reason~ : String)
    } derive(ToJson,
    Debug
    )

    impl Show for RouterError

    ApiError

    pub(all) struct ApiError {
    code : Int
    message : String
    details : Array[Map[String, String]]?
    } derive(ToJson,
    Debug
    )

    ApiError::new

    fn ApiError::new(code : Int, message : String) -> ApiError

    ApiError::to_http_status

    fn ApiError::to_http_status(code : Int) -> Int

    Context

    pub(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

    }

    impl Show for Context

    Context::cleanup

    async fn Context::cleanup(self : Context) -> Unit

    Context::form

    async fn Context::form(self : Context, key : String) -> String raise PonyError

    Context::form_file

    fn Context::form_file(self : Context, key : String) -> FileHeader raise PonyError

    Context::form_files

    fn Context::form_files(self : Context, key : String) -> Array[FileHeader]

    Context::get_ext

    fn[K, V] Context::get_ext(self : Context, _marker : K) -> V raise PonyError

    Context::header

    fn Context::header(self : Context, key : String) -> String raise PonyError

    Context::json

    Context::no_content

    async fn Context::no_content(self : Context) -> Unit

    Context::param

    fn Context::param(self : Context, name : String) -> String raise PonyError

    Context::parse_multipart_form

    async fn Context::parse_multipart_form(self : Context, max_memory? : Int64, writers? : Map[String, FileWriter]) -> Unit

    Context::query

    fn Context::query(self : Context, key : String) -> String raise PonyError

    Context::redirect

    async fn Context::redirect(self : Context, url : String, http_status? : Int, msg? : String) -> Unit

    Context::remove_ext

    fn[K] Context::remove_ext(self : Context, _marker : K) -> Unit

    Context::reply_error

    async fn Context::reply_error(self : Context, e : ApiError) -> Unit

    Context::reply_ok

    async fn[T : ToJson] Context::reply_ok(self : Context, value : T) -> Unit

    Context::set_content_type

    fn Context::set_content_type(self : Context, ct : String) -> Unit

    Context::set_ext

    fn[K, V] Context::set_ext(self : Context, _marker : K, v : V) -> Unit

    Context::set_header

    fn Context::set_header(self : Context, key : String, value : String) -> Unit

    Context::try_form

    async fn Context::try_form(self : Context, key : String) -> String?

    Context::try_form_file

    fn Context::try_form_file(self : Context, key : String) -> FileHeader?

    Context::try_get_ext

    fn[K, V] Context::try_get_ext(self : Context, _marker : K) -> V?

    Context::try_header

    fn Context::try_header(self : Context, key : String) -> String?

    Context::try_param

    fn Context::try_param(self : Context, name : String) -> String?

    Context::try_query

    fn Context::try_query(self : Context, key : String) -> String?

    Context::try_wildcard

    fn Context::try_wildcard(self : Context) -> String?

    Context::wildcard

    fn Context::wildcard(self : Context) -> String raise PonyError

    Context::write_bytes

    async fn Context::write_bytes(self : Context, http_status : Int, msg? : String, body : Bytes) -> Unit

    Context::write_json

    async fn[T : ToJson] Context::write_json(self : Context, http_status : Int, msg? : String, v : T) -> Unit

    Context::write_text

    async fn Context::write_text(self : Context, http_status : Int, msg? : String, text : String) -> Unit

    Endpoint

    ExtStore

    pub(all) struct ExtStore(Array[(String,
    Any
    )])

    ExtStore::get

    fn[K, V] ExtStore::get(self : ExtStore, _marker : K) -> V raise ExtError

    ExtStore::new

    fn ExtStore::new() -> ExtStore

    ExtStore::remove

    fn[K] ExtStore::remove(self : ExtStore, _marker : K) -> Unit

    ExtStore::set

    fn[K, V] ExtStore::set(self : ExtStore, _marker : K, v : V) -> Unit

    ExtStore::try_get

    fn[K, V] ExtStore::try_get(self : ExtStore, _marker : K) -> V?

    FileBackend

    type FileBackend

    Where the uploaded file content lives.

    FileHeader

    pub(all) struct FileHeader {
    filename : String
    size : Int64
    content_type : String?
    header : PartHeaders
    backend : FileBackend
    }

    Metadata for a single uploaded file, plus its content. Fields mirror Go's mime/multipart.FileHeader.

    FileHeader::bytes

    async fn FileHeader::bytes(self : FileHeader) -> Bytes

    Read the entire file content as Bytes.

    • For files that fit in memory: returns immediately.
    • For files spilled to disk: reads from the temp file.
    • For External files: returns empty — file was streamed to a custom writer.

    Must be called from an async context.

    FileHeader::open

    Open a reader for the file content.

    • Memory: returns a reader backed by the in-memory bytes.
    • Disk: reads the temp file and returns a memory-backed reader.
    • External: returns an empty reader — file was streamed to a custom writer.

    FileHeader::path

    fn FileHeader::path(self : FileHeader) -> String?

    Return the file path for on-disk files, or None for in-memory/External.

    FileWriter

    pub(all) struct FileWriter(
    File
    )

    Wraps an @fs.File as a WriterCloser for multipart uploads.

    Handler

    pub(all) struct Handler(async (Context) -> Unit) derive(
    Debug
    )

    pub struct Header(Map[String, Array[String]])

    Header::add

    fn Header::add(h : Header, key : String, value : String) -> Unit

    Header::copy

    fn Header::copy(h : Header) -> Header

    Header::del

    fn Header::del(h : Header, key : String) -> Unit

    Header::get

    fn Header::get(h : Header, key : String) -> String?

    Header::has

    fn Header::has(h : Header, key : String) -> Bool

    Header::set

    fn Header::set(h : Header, key : String, value : String) -> Unit

    Header::values

    fn Header::values(h : Header, key : String) -> Array[String]

    HttpHeaders

    pub(all) struct HttpHeaders(Map[String, String])

    Case-insensitive HTTP headers with lowercased keys.

    HttpHeaders::from_map

    fn HttpHeaders::from_map(m : Map[String, String]) -> HttpHeaders

    HttpHeaders::get

    fn HttpHeaders::get(self : HttpHeaders, key : String) -> String?

    Return the value for key (case-insensitive lookup), or None.

    HttpHeaders::merge_in_place

    fn HttpHeaders::merge_in_place(self : HttpHeaders, other : Map[String, String]) -> Unit

    Merge entries from other into self, lowercasing keys.

    HttpHeaders::new

    HttpHeaders::set

    fn HttpHeaders::set(self : HttpHeaders, key : String, value : String) -> Unit

    Set the value for key, lowercasing the key first.

    HttpHeaders::to_map

    fn HttpHeaders::to_map(self : HttpHeaders) -> Map[String, String]

    Return the underlying Map[String, String].

    HttpMethod

    pub(all) enum HttpMethod {
    Get
    Head
    Post
    Put
    Delete
    Connect
    Options
    Trace
    Patch
    Any
    } derive(Compare, Eq, Hash, ToJson,
    Debug
    )

    impl Show for HttpMethod

    Middleware

    pub(all) struct Middleware((Handler) -> Handler) derive(
    Debug
    )

    MultipartForm

    type MultipartForm

    Accumulator for a parsed multipart form. Holds both regular fields (via Values) and file uploads.

    Node

    impl Show for Node

    NodeType

    impl Show for NodeType

    ParamKV

    type ParamKV

    Part

    pub(all) struct Part {
    source : &
    Reader

    delimiter : Bytes
    headers : PartHeaders
    buf :
    Buffer

    body_eof : Bool
    }

    A single part in a multipart stream.

    Owns a lookback buffer for boundary scanning. When the body reaches the next boundary delimiter, read_chunk returns None and the remaining bytes after the boundary are recoverable via take_remaining().

    Part::content_type

    fn Part::content_type(self : Part) -> String?

    Return the Content-Type of the part, if present.

    Part::form_name

    fn Part::form_name(self : Part) -> (String, String?)

    Extract (name, filename?) from the Content-Disposition header.

    Part::headers

    fn Part::headers(self : Part) -> PartHeaders

    Return the part's MIME headers.

    PartHeaders

    pub(all) struct PartHeaders(Map[String, String])

    Case-insensitive MIME part headers.

    Keys are normalised to lower-case on insertion and lookup.

    PartHeaders::get

    fn PartHeaders::get(self : PartHeaders, key : String) -> String?

    Return the value for key (case-insensitive), or None.

    PartHeaders::has

    fn PartHeaders::has(self : PartHeaders, key : String) -> Bool

    Return true if key is present.

    PartHeaders::is_empty

    fn PartHeaders::is_empty(self : PartHeaders) -> Bool

    Return true if there are no entries.

    PartHeaders::new

    RequestId

    pub(all) struct RequestId {
    }

    Marker type for per-request extension data (e.g. request ID).

    RouteResult

    pub enum RouteResult {
    Found(Endpoint, Array[String])
    MethodNotAllowed
    NotFound
    }

    Router

    pub struct Router {
    tree : Node
    middlewares : Array[Middleware]
    not_found_handler : Handler?
    method_not_allowed_handler : Handler?
    } derive(
    Debug
    )

    impl Show for Router

    Router::Router

    fn Router::Router() -> Router

    Router::add

    fn Router::add(self : Router, meth : HttpMethod, pattern : String, name? : String, mws? : Array[Middleware], handler : Handler) -> Unit raise RouterError

    Add a new route to the router

    Parameters

    • meth: HTTP method
    • pattern: URL pattern, must start with /
    • mws: Optional array of middlewares, applied in order before the handler only applies to this route
    • name: Optional name for the route
    • handler: Handler function

    Raises

    • RouterError::InvalidPattern: If the pattern does not start with / or is otherwise invalid

    Router::any

    fn Router::any(self : Router, pattern : String, name? : String, mws? : Array[Middleware], handler : Handler) -> Unit raise RouterError

    Router::handler

    async fn Router::handler(self : Router, ctx : Context) -> Unit

    Router::mount

    fn Router::mount(self : Router, prefix : String, sub : Router) -> Unit raise RouterError

    Mount a sub-router under the given prefix using a prefix/* wildcard route, like chi does. The sub-router stays alive — routes added to it after mount are automatically picked up.

    Router::set_method_not_allowed

    fn Router::set_method_not_allowed(self : Router, handler : Handler) -> Unit

    Router::set_not_found

    fn Router::set_not_found(self : Router, handler : Handler) -> Unit

    Router::use_mw

    fn Router::use_mw(self : Router, mw : Middleware) -> Unit

    Server

    pub struct Server {
    router : Router
    addr : String
    read_timeout : Int?
    write_timeout : Int?
    request_id : Int
    }

    Server::Server

    fn Server::Server(addr : String, router : Router) -> Server

    Server::start

    async fn Server::start(self : Server) -> Unit

    Server::with_timeout

    fn Server::with_timeout(self : Server, read? : Int, write? : Int) -> Server

    UserId

    pub(all) struct UserId {
    }

    Marker type for per-user extension data (e.g. authenticated user ID).

    Values

    pub(all) struct Values(Map[String, Array[String]])

    Values::get

    fn Values::get(self : Values, key : String) -> String?

    Values::get_all

    fn Values::get_all(self : Values, key : String) -> Array[String]

    Values::new

    fn Values::new() -> Values

    aborted

    let aborted : Int

    already_exists

    let already_exists : Int

    cancelled

    let cancelled : Int

    canonicalHeaderKey

    fn canonicalHeaderKey(key : String) -> String

    data_loss

    let data_loss : Int

    deadline_exceeded

    let deadline_exceeded : Int

    default_max_memory

    let default_max_memory : Int64

    Default memory threshold: 32 MiB. Files larger than this are written to temporary disk files (via MultipartForm::spill_to_disk).

    failed_precondition

    let failed_precondition : Int

    internal

    let internal : Int

    invalid_argument

    let invalid_argument : Int

    new

    fn new() -> Header

    not_found

    let not_found : Int

    let ok : Int

    out_of_range

    let out_of_range : Int

    permission_denied

    let permission_denied : Int

    register

    fn register(code : Int, http_status : Int) -> Unit

    resource_exhausted

    let resource_exhausted : Int

    start

    async fn start(addr : String, router : Router) -> Unit

    status_accepted

    let status_accepted : Int

    status_already_reported

    let status_already_reported : Int

    status_bad_gateway

    let status_bad_gateway : Int

    status_bad_request

    let status_bad_request : Int

    status_conflict

    let status_conflict : Int

    status_continue

    let status_continue : Int

    status_created

    let status_created : Int

    status_early_hints

    let status_early_hints : Int

    status_expectation_failed

    let status_expectation_failed : Int

    status_failed_dependency

    let status_failed_dependency : Int

    status_forbidden

    let status_forbidden : Int

    status_found

    let status_found : Int

    status_gateway_timeout

    let status_gateway_timeout : Int

    status_gone

    let status_gone : Int

    status_http_version_not_supported

    let status_http_version_not_supported : Int

    status_im_used

    let status_im_used : Int

    status_insufficient_storage

    let status_insufficient_storage : Int

    status_internal_server_error

    let status_internal_server_error : Int

    status_length_required

    let status_length_required : Int

    status_locked

    let status_locked : Int

    status_loop_detected

    let status_loop_detected : Int

    status_method_not_allowed

    let status_method_not_allowed : Int

    status_misdirected_request

    let status_misdirected_request : Int

    status_moved_permanently

    let status_moved_permanently : Int

    status_multi_status

    let status_multi_status : Int

    status_multiple_choices

    let status_multiple_choices : Int

    status_network_authentication_required

    let status_network_authentication_required : Int

    status_no_content

    let status_no_content : Int

    status_non_authoritative_info

    let status_non_authoritative_info : Int

    status_not_acceptable

    let status_not_acceptable : Int

    status_not_extended

    let status_not_extended : Int

    status_not_found

    let status_not_found : Int

    status_not_implemented

    let status_not_implemented : Int

    status_not_modified

    let status_not_modified : Int

    status_ok

    let status_ok : Int

    status_partial_content

    let status_partial_content : Int

    status_payment_required

    let status_payment_required : Int

    status_permanent_redirect

    let status_permanent_redirect : Int

    status_precondition_failed

    let status_precondition_failed : Int

    status_precondition_required

    let status_precondition_required : Int

    status_processing

    let status_processing : Int

    status_proxy_auth_required

    let status_proxy_auth_required : Int

    status_request_entity_too_large

    let status_request_entity_too_large : Int

    status_request_header_fields_too_large

    let status_request_header_fields_too_large : Int

    status_request_timeout

    let status_request_timeout : Int

    status_request_uri_too_long

    let status_request_uri_too_long : Int

    status_requested_range_not_satisfiable

    let status_requested_range_not_satisfiable : Int

    status_reset_content

    let status_reset_content : Int

    status_see_other

    let status_see_other : Int

    status_service_unavailable

    let status_service_unavailable : Int

    status_switching_protocols

    let status_switching_protocols : Int

    status_temporary_redirect

    let status_temporary_redirect : Int

    status_text

    fn status_text(code : Int) -> String

    status_too_early

    let status_too_early : Int

    status_too_many_requests

    let status_too_many_requests : Int

    status_unauthorized

    let status_unauthorized : Int

    let status_unavailable_for_legal_reasons : Int

    status_unprocessable_entity

    let status_unprocessable_entity : Int

    status_unsupported_media_type

    let status_unsupported_media_type : Int

    status_upgrade_required

    let status_upgrade_required : Int

    status_use_proxy

    let status_use_proxy : Int

    status_variant_also_negotiates

    let status_variant_also_negotiates : Int

    to_api_error

    fn to_api_error(e : PonyError) -> ApiError

    Convert a PonyError to an ApiError suitable for reply_error.

    unauthenticated

    let unauthenticated : Int

    unavailable

    let unavailable : Int

    unimplemented

    let unimplemented : Int

    unknown

    let unknown : Int