Sign in

    pony

    web
    framework
    auth
    Download zip
    Author
    Version
    0.2.0
    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

    Add to your moon.mod:

    { "deps": { "jaredzhou/pony": "0.1.0" } }

    #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