///|
/// HTTP methods a moonapi route can bind to.
pub(all) enum Method {
Get
Post
Put
Patch
Delete
Head
Options
} derive(Eq)
///|
fn parse_method(s : String) -> Method? {
match s {
"GET" => Some(Get)
"POST" => Some(Post)
"PUT" => Some(Put)
"PATCH" => Some(Patch)
"DELETE" => Some(Delete)
"HEAD" => Some(Head)
"OPTIONS" => Some(Options)
// TRACE, CONNECT, or something invented. Answering it as a GET would run a
// handler the caller never asked for.
_ => None
}
}
///|
/// A method's name as it appears in an `Allow` header.
fn method_name(m : Method) -> String {
match m {
Get => "GET"
Post => "POST"
Put => "PUT"
Patch => "PATCH"
Delete => "DELETE"
Head => "HEAD"
Options => "OPTIONS"
}
}
///|
fn method_lower(m : Method) -> String {
match m {
Get => "get"
Post => "post"
Put => "put"
Patch => "patch"
Delete => "delete"
Head => "head"
Options => "options"
}
}
///|
/// The per-request context handed to a handler: the raw request plus the path
/// parameters extracted from the matched route (`:name` segments).
pub(all) struct Context {
request : @moonasgi.Request
params : Map[String, String]
}
///|
/// Look up a path parameter by name.
pub fn Context::param(self : Context, name : String) -> String? {
self.params.get(name)
}
///|
/// A moonapi route handler: request context in, response out. It may `raise` an
/// `HttpException` (or any error) instead of returning — the app catches it and
/// maps it to a response through the registered exception handlers, so handlers
/// read like FastAPI's `raise HTTPException(...)` rather than threading a
/// `Result` back by hand. A plain non-raising closure is still a valid handler.
pub type ApiHandler = (Context) -> @moonasgi.Response raise
///|
/// A background-aware handler: it additionally receives the request's
/// `BackgroundTasks` queue, so it can schedule work to run after its response is
/// sent (← declaring a `BackgroundTasks` parameter in FastAPI).
pub type BackgroundHandler = (Context, BackgroundTasks) -> @moonasgi.Response raise
///|
/// A streaming route's handler: request context in, a chunked response out (←
/// returning a `StreamingResponse` from a FastAPI path operation). Every chunk
/// becomes one body event on the wire, so a client reads the first long before
/// the last one exists — the point of streaming, and what a single buffered
/// `Response` cannot express.
pub type StreamHandler = (Context) -> @moonasgi.StreamingResponse raise
///|
/// What a route produced. Two kinds because moonasgi types a buffered response
/// and a chunked one differently, and the difference has to survive as far as
/// `to_asgi`, which is the only place that can put chunks on the wire.
priv enum Reply {
Buffered(@moonasgi.Response)
Streamed(@moonasgi.StreamingResponse)
}
///|
/// A route stores one background-aware handler; a plain `ApiHandler` is wrapped
/// to ignore the queue. `security` is the per-operation requirements (emitted as
/// OpenAPI `security` and enforced before the handler when their scheme has an
/// enforcer).
struct Route {
verb : Method
path : String
run : (Context, BackgroundTasks) -> Reply raise
summary : String
description : String
tags : Array[String]
deprecated : Bool
operation_id : String
// The status the success response is documented under. Documentation only: the
// handler builds its own response, and rewriting the status it chose would make
// the app disagree with itself rather than with the spec.
status_code : Int?
// Documented alongside whatever the endpoint descriptor produced, and written
// after it, so the status a route names here is the one that stands.
responses : Array[ResponseSpec]
// The handle `App::url_for` looks the route up by. Empty means unnamed, and an
// unnamed route is not addressable.
name : String
// Merged into the operation object last, so it can override anything moonapi
// generated (← FastAPI's `openapi_extra`).
openapi_extra : Json?
endpoint : Endpoint?
security : Array[SecurityRequirement]
// Provider keys resolved through the app's container before the handler runs,
// and torn down after it (← FastAPI's `dependencies=[Depends(...)]`, whose
// values the handler never sees).
dependencies : Array[String]
// FastAPI's `include_in_schema`: a route that serves the documentation itself has
// no business appearing in the document it serves.
include_in_schema : Bool
// Whether the declared `endpoint` is checked before the handler runs. On by
// default: a route that advertises constraints in its documentation and does not
// apply them is worse than one that declares nothing.
validate : Bool
}
///|
/// What a mount prefix routes to: a moonapi sub-application, whose routes,
/// security schemes and lifespan hooks fold into the parent's, or a foreign
/// moonasgi handler, which the parent can only forward requests to.
enum Mount {
Sub(App)
Asgi(@moonasgi.Handler)
}
///|
/// A moonapi application: routes, WebSocket routes, an outer middleware chain,
/// exception handlers, mounted sub-applications, the security schemes surfaced
/// in the OpenAPI document (with optional runtime enforcers), per-status
/// exception handlers, and the verification clock. Compiles to a moonasgi
/// `AsgiApp` any server (mooncat) can run.
pub struct App {
routes : Array[Route]
ws_routes : Array[WsRoute]
middlewares : Array[@moonasgi.Middleware]
exception_handlers : Array[ExceptionHandler]
security_schemes : Array[DeclaredScheme]
enforcers : Map[String, SecurityEnforcer]
status_handlers : Map[Int, (Context) -> @moonasgi.Response]
mounts : Array[(String, Mount)]
// Run in registration order when the server starts, and in reverse when it stops,
// so a resource is torn down before whatever it was built on.
startup_hooks : Array[() -> Unit raise]
shutdown_hooks : Array[() -> Unit raise]
// What the emitted document says about itself. `enable_docs` serves the same
// values, so the page a reader opens is not a different API from the one
// `App::openapi` describes.
mut info : ApiInfo
mut clock : () -> Int64
// The container a route's declared `dependencies` are resolved through.
mut deps : Deps?
}
///|
/// Create an empty application. The verification clock defaults to `0` (Unix
/// epoch); a server sets a real one with `App::set_clock`, and tests inject a
/// fixed time so token expiry is deterministic.
pub fn App::new() -> App {
{
routes: [],
ws_routes: [],
middlewares: [],
exception_handlers: [],
security_schemes: [],
enforcers: Map([]),
status_handlers: Map([]),
mounts: [],
startup_hooks: [],
shutdown_hooks: [],
info: ApiInfo::new(),
clock: () => 0L,
deps: None,
}
}
///|
/// Give the app the dependency container its routes resolve their declared
/// `dependencies` through (← FastAPI's `Depends` wiring, which a route names and
/// the application supplies). The container's value type is erased on the way
/// in, because a route-level dependency runs for its effect and its value is
/// never handed to the handler — the same thing FastAPI's `dependencies=[...]`
/// does with the values it builds.
///
/// A route that declares a dependency the container does not provide is answered
/// with a `500`: the setup the route promised did not happen, and running the
/// handler as though it had is worse than saying so.
pub fn App::depends(self : App, deps : Deps) -> Unit {
self.deps = Some(deps)
}
///|
/// Set what the OpenAPI document says about this API (← FastAPI's `FastAPI(title=…,
/// description=…, contact=…, license_info=…, servers=…)`). `App::openapi` and the
/// `/openapi.json` route `enable_docs` registers both read it, so the document a
/// client fetches and the one a test builds cannot describe different APIs.
pub fn App::describe(
self : App,
title? : String,
api_version? : String,
description? : String,
terms_of_service? : String,
contact? : Contact?,
license? : License?,
servers? : Array[Server],
) -> Unit {
let cur = self.info
self.info = {
title: title.unwrap_or(cur.title),
api_version: api_version.unwrap_or(cur.api_version),
description: description.unwrap_or(cur.description),
terms_of_service: terms_of_service.unwrap_or(cur.terms_of_service),
contact: contact.unwrap_or(cur.contact),
license: license.unwrap_or(cur.license),
servers: servers.unwrap_or(cur.servers),
}
}
///|
/// Run `hook` when the server starts, before it accepts the first request (←
/// FastAPI's `on_event("startup")`). Hooks run in the order they were registered;
/// one that raises aborts startup, and the server is told why.
pub fn App::on_startup(self : App, hook : () -> Unit raise) -> Unit {
self.startup_hooks.push(hook)
}
///|
/// Run `hook` when the server shuts down, after the last request has been served (←
/// FastAPI's `on_event("shutdown")`). Hooks run in reverse registration order, so a
/// resource is released before whatever it was opened from. A hook that raises does
/// not stop the others — shutdown reports the first failure once the rest have run.
pub fn App::on_shutdown(self : App, hook : () -> Unit raise) -> Unit {
self.shutdown_hooks.push(hook)
}
///|
/// Set the clock the app reads to verify token expiry when enforcing route
/// security (Unix seconds). A native server passes the wall clock; a test passes
/// a fixed function so expiry is deterministic.
pub fn App::set_clock(self : App, clock : () -> Int64) -> Unit {
self.clock = clock
}
///|
/// Add an outer middleware. Middlewares wrap the router as an onion; the first
/// registered is the outermost (it sees the request first and the response
/// last). `cors(...)` and `gzip(...)` are middlewares.
pub fn App::middleware(self : App, mw : @moonasgi.Middleware) -> Unit {
self.middlewares.push(mw)
}
///|
/// Register an exception handler. On a raised error the handlers are tried in
/// registration order; the first to return `Some(response)` wins. An
/// unhandled error falls through to the built-in mapping — an `HttpException`
/// becomes its own `status` / `detail`, anything else a `500`.
pub fn App::exception_handler(self : App, h : ExceptionHandler) -> Unit {
self.exception_handlers.push(h)
}
///|
/// Declare a security scheme under `name`, surfaced in the emitted OpenAPI
/// document (`components/securitySchemes` in 3.x, `securityDefinitions` in
/// Swagger 2.0) so the generated spec describes how to authenticate.
/// `description` is the prose shown beside it in the documentation UI.
///
/// This declares only. A route naming this scheme in its `security` is
/// documented as protected and left unguarded, exactly as a FastAPI scheme that
/// no path operation depends on is — the `secure_*` functions are the ones that
/// wire an enforcer.
pub fn App::add_security_scheme(
self : App,
name : String,
scheme : SecurityScheme,
description? : String = "",
) -> Unit {
self.security_schemes.push({ name, kind: scheme, description, })
}
///|
/// Register a route for an explicit method. An optional `endpoint` descriptor
/// makes the route fully typed (its parameters, request body, and responses
/// surface in the OpenAPI document and drive validation); `security` attaches
/// per-operation requirements (emitted as OpenAPI `security` and enforced before
/// the handler when their scheme has an enforcer); `dependencies` names provider
/// keys resolved through the app's container (`App::depends`) before the handler
/// and torn down after it, whatever the handler did.
///
/// The rest describe the operation: `summary` and `description` are its prose,
/// `operation_id` the stable handle client generators name their method after,
/// `status_code` the status its success response is documented under,
/// `responses` further documented responses, `name` the handle `App::url_for`
/// resolves, and `openapi_extra` a fragment merged over the generated operation
/// object — the same keyword arguments FastAPI's path operations take.
pub fn App::route(
self : App,
verb : Method,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.routes.push({
verb,
path,
run: (ctx, _bg) => Buffered(handler(ctx)),
summary,
description,
tags,
deprecated,
operation_id,
status_code,
responses,
name,
openapi_extra,
endpoint,
security,
dependencies,
include_in_schema,
validate,
})
}
///|
/// Register a background-aware route: the handler additionally receives the
/// request's `BackgroundTasks` queue, whose thunks the app runs after the
/// response is sent (← declaring a `BackgroundTasks` parameter in FastAPI).
pub fn App::route_bg(
self : App,
verb : Method,
path : String,
handler : BackgroundHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.routes.push({
verb,
path,
run: (ctx, bg) => Buffered(handler(ctx, bg)),
summary,
description,
tags,
deprecated,
operation_id,
status_code,
responses,
name,
openapi_extra,
endpoint,
security,
dependencies,
include_in_schema,
validate,
})
}
///|
/// Register a streaming route (← a FastAPI path operation returning a
/// `StreamingResponse`). The handler's chunks reach the client as separate body
/// events, so a long or open-ended reply starts arriving before it is finished.
///
/// A stream is only streamed as far as the app can honestly keep it one: moonasgi
/// types a middleware as buffered response in, buffered response out, so a
/// middleware that rewrites the body — `gzip` does — collapses the reply to a
/// single chunk. Everything else about the route is ordinary; it documents,
/// validates and enforces security exactly as `route` does.
pub fn App::route_stream(
self : App,
verb : Method,
path : String,
handler : StreamHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.routes.push({
verb,
path,
run: (ctx, _bg) => Streamed(handler(ctx)),
summary,
description,
tags,
deprecated,
operation_id,
status_code,
responses,
name,
openapi_extra,
endpoint,
security,
dependencies,
include_in_schema,
validate,
})
}
///|
/// Register a streaming `GET` route — the verb a stream is nearly always read
/// over, and what an SSE endpoint is. A shorthand for `route_stream(Get, ...)`.
pub fn App::stream(
self : App,
path : String,
handler : StreamHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route_stream(
Get,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Register a `GET` route. A shorthand for `route(Get, ...)` carrying the same
/// documentation and security arguments.
pub fn App::get(
self : App,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route(
Get,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Serve the app's own documentation, the way FastAPI does out of the box: the
/// OpenAPI document at `openapi_url`, Swagger UI at `docs_url`, and ReDoc at
/// `redoc_url`. Pass `None` for any of the three to leave it off. The three routes
/// are kept out of the document they serve, so enabling docs does not change the
/// spec a client reads.
///
/// It is a call rather than a default because registering routes behind the app's
/// back would surprise anyone mounting this app under a prefix.
pub fn App::enable_docs(
self : App,
openapi_url? : String? = Some("/openapi.json"),
docs_url? : String? = Some("/docs"),
redoc_url? : String? = Some("/redoc"),
version? : OpenApiVersion = OpenApi31,
) -> Unit {
let title = self.info.title
if openapi_url is Some(url) {
self.get(
url,
_ctx => json(200, self.openapi(version~)),
include_in_schema=false,
)
}
let spec_url = match openapi_url {
Some(url) => url
None => "/openapi.json"
}
if docs_url is Some(url) {
self.get(
url,
_ctx => html(200, swagger_ui(spec_url~, title~)),
include_in_schema=false,
)
}
if redoc_url is Some(url) {
self.get(
url,
_ctx => html(200, redoc_ui(spec_url~, title~)),
include_in_schema=false,
)
}
}
///|
/// Register a `POST` route — the verb that carries a request body, so this is the
/// one most often given an `endpoint` describing that body.
pub fn App::post(
self : App,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route(
Post,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Register a `PUT` route: replace the addressed resource wholesale.
pub fn App::put(
self : App,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route(
Put,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Register a `PATCH` route: change part of the addressed resource.
pub fn App::patch(
self : App,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route(
Patch,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Register a `DELETE` route.
pub fn App::delete(
self : App,
path : String,
handler : ApiHandler,
summary? : String = "",
description? : String = "",
tags? : Array[String] = [],
deprecated? : Bool = false,
operation_id? : String = "",
status_code? : Int,
responses? : Array[ResponseSpec] = [],
name? : String = "",
endpoint? : Endpoint? = None,
security? : Array[SecurityRequirement] = [],
dependencies? : Array[String] = [],
include_in_schema? : Bool = true,
validate? : Bool = true,
openapi_extra? : Json,
) -> Unit {
self.route(
Delete,
path,
handler,
summary~,
description~,
tags~,
deprecated~,
operation_id~,
status_code?,
responses~,
name~,
endpoint~,
security~,
dependencies~,
include_in_schema~,
validate~,
openapi_extra?,
)
}
///|
/// Declare an OAuth2 password-bearer scheme under `name` and wire it as a
/// runtime enforcer. Like `add_security_scheme` it surfaces the scheme in the
/// OpenAPI document (with the advertised `scopes` and `description`), and
/// additionally registers the guard a route names in its `security`: the app
/// pulls the bearer token, verifies it against `bearer`'s secret at the app
/// clock's time, and checks the route's required scopes — returning 401/403
/// before the handler runs.
///
/// `auto_error=false` admits the request instead of refusing it (← the argument
/// every FastAPI security class takes, whose dependency then yields `None` and
/// leaves the decision to the route).
pub fn App::secure_oauth2(
self : App,
name : String,
bearer : OAuth2PasswordBearer,
scopes? : Array[(String, String)] = [],
description? : String = "",
auto_error? : Bool = true,
) -> Unit {
self.security_schemes.push({
name,
kind: bearer.scheme(scopes~),
description,
})
self.enforcers[name] = (ctx, required, now) => {
verify_bearer(ctx, bearer.secret, required, now, auto_error)
}
}
///|
/// Register a per-status exception handler (← FastAPI's `add_exception_handler`
/// keyed by an HTTP status code). Whenever an error path yields `status` — a
/// routing `404` / `405`, or a raised `HttpException` (or the fallback `500`) —
/// the handler's response replaces the default, so an app can serve a custom
/// error page. Successful handler returns are never rewritten.
pub fn App::add_status_handler(
self : App,
status : Int,
handler : (Context) -> @moonasgi.Response,
) -> Unit {
self.status_handlers[status] = handler
}
///|
/// Mount a sub-application under `prefix` (← FastAPI's `app.mount(prefix, sub)`).
/// A request whose path lies under `prefix` is routed by `subapp` with the
/// prefix stripped (its own middleware, security, and background tasks apply),
/// and the sub-app's routes appear under `prefix` in the merged OpenAPI document
/// with its security schemes folded into the parent's.
pub fn App::mount(self : App, prefix : String, subapp : App) -> Unit {
self.mounts.push((prefix, Sub(subapp)))
}
///|
/// Mount a foreign moonasgi handler under `prefix` (← FastAPI mounting a plain
/// ASGI app, `app.mount("/static", StaticFiles(...))`). A request under `prefix`
/// is handed to `handler` with the prefix stripped, exactly as for a sub-app.
///
/// A handler is not a moonapi application, so it contributes nothing to the
/// OpenAPI document and has no lifespan of its own to run — the app only routes
/// to it. Mounts are tried in registration order, whichever kind they are.
pub fn App::mount_handler(
self : App,
prefix : String,
handler : @moonasgi.Handler,
) -> Unit {
self.mounts.push((prefix, Asgi(handler)))
}
///|
/// Split a path into its non-empty segments.
fn segments(path : String) -> Array[String] {
let out : Array[String] = []
let n = path.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || path[i] == '/' {
if i > start {
out.push(path[start:i].to_owned())
}
start = i + 1
}
}
out
}
///|
/// Match a route pattern against a request path, extracting `:name` params.
fn match_path(pattern : String, path : String) -> Map[String, String]? {
let pp = segments(pattern)
let ph = segments(path)
if pp.length() != ph.length() {
return None
}
let params : Map[String, String] = Map([])
for i = 0; i < pp.length(); i = i + 1 {
let seg = pp[i]
if seg.length() > 0 && seg[0] == ':' {
params[seg[1:].to_owned()] = ph[i]
} else if seg != ph[i] {
return None
}
}
Some(params)
}
///|
/// Fill a route pattern's `:name` segments from `params`, the inverse of
/// `match_path`. `None` when the pattern needs a parameter `params` does not
/// carry — half a URL is worse than none.
fn render_path(pattern : String, params : Map[String, String]) -> String? {
let sb = StringBuilder()
for seg in segments(pattern) {
sb.write_string("/")
if seg.length() > 0 && seg[0] == ':' {
match params.get(seg[1:].to_owned()) {
Some(v) => sb.write_string(v)
None => return None
}
} else {
sb.write_string(seg)
}
}
let rendered = sb.to_string()
Some(if rendered == "" { "/" } else { rendered })
}
///|
/// The path of the route registered under `name`, with its `:name` segments
/// filled from `params` (← FastAPI's `url_path_for`). Mounted sub-applications
/// are searched too, so what comes back already carries the mount prefix — the
/// path a client would call, not the one the sub-app knows itself by.
///
/// `None` when nothing is registered under that name, or when `params` is
/// missing one the path needs. Values are substituted as given: a value with a
/// `/` in it lands as extra path segments, so encode before calling if that
/// matters.
pub fn App::url_for(
self : App,
name : String,
params? : Map[String, String] = Map([]),
) -> String? {
// Every route starts out unnamed, so an empty name would match the first one.
if name == "" {
return None
}
let all : Array[(String, Route)] = []
self.collect_routes("", all)
for entry in all {
if entry.1.name == name {
return render_path(entry.0, params)
}
}
None
}
///|
/// A plain-text response.
pub fn text(status : Int, body : String) -> @moonasgi.Response {
@moonasgi.Response::new(
status,
[("content-type", "text/plain; charset=utf-8")],
@utf8.encode(body),
)
}
///|
/// An HTML response — what the documentation pages are served as.
pub fn html(status : Int, body : String) -> @moonasgi.Response {
@moonasgi.Response::new(
status,
[("content-type", "text/html; charset=utf-8")],
@utf8.encode(body),
)
}
///|
/// A JSON response serialised from a `Json` value.
pub fn json(status : Int, value : Json) -> @moonasgi.Response {
@moonasgi.Response::new(
status,
[("content-type", "application/json")],
@utf8.encode(value.stringify()),
)
}
///|
/// Rewrite an error-path response through a registered per-status handler, if one
/// is registered for its status; otherwise pass it through unchanged.
fn App::apply_status(
self : App,
ctx : Context,
resp : @moonasgi.Response,
) -> @moonasgi.Response {
match self.status_handlers.get(resp.status) {
Some(h) => h(ctx)
None => resp
}
}
///|
/// Try each registered exception handler in order; the first to claim the error
/// wins outright. Otherwise fall through to the built-in mapping, which a
/// per-status handler may still rewrite (a custom `500` page, say).
fn App::dispatch_exception(
self : App,
ctx : Context,
err : Error,
) -> @moonasgi.Response {
for h in self.exception_handlers {
match h(ctx, err) {
Some(resp) => return resp
None => ()
}
}
self.apply_status(ctx, default_exception_response(err))
}
///|
/// Enforce a route's security requirements. `None` means allow (nothing to
/// enforce, or a requirement passed); `Some(resp)` is the 401/403 to return.
/// Requirements are alternatives (OpenAPI OR-semantics): the first enforceable
/// one that passes admits the request; if every enforceable requirement fails,
/// the last failure response is returned. A requirement naming a
/// documentation-only scheme (no enforcer) grants nothing by itself.
fn App::enforce_security(
self : App,
ctx : Context,
reqs : Array[SecurityRequirement],
) -> @moonasgi.Response? {
if reqs.is_empty() {
return None
}
let now = (self.clock)()
let mut enforceable = false
let mut last : @moonasgi.Response? = None
for r in reqs {
match self.enforcers.get(r.scheme) {
Some(enf) => {
enforceable = true
match enf(ctx, SecurityScopes::new(scopes=r.scopes), now) {
Ok(_) => return None
Err(resp) => last = Some(resp)
}
}
None => ()
}
}
if enforceable {
last
} else {
None
}
}
///|
/// The routing + exception core, inside the middleware chain: match a route,
/// enforce its security, run its (possibly raising) handler, and turn any raised
/// error into a response. Falls through to mounted sub-apps, then 405 (a path
/// matched but no method) or 404 (no path). `bg` is the request's background
/// queue, handed to the matched handler and drained by the caller after the
/// response is sent.
///
/// The chain around this is typed in buffered responses, so a streaming route's
/// chunks are left in `stream` on the way past and the buffered join is what
/// travels through the middleware.
fn App::route_and_dispatch(
self : App,
request : @moonasgi.Request,
bg : BackgroundTasks,
stream : Ref[@moonasgi.StreamingResponse?],
) -> @moonasgi.Response {
let verb = parse_method(request.http_method)
let allowed : Array[String] = []
for route in self.routes {
match match_path(route.path, request.path) {
Some(params) =>
// A GET route answers HEAD too, with the body dropped — the same rule
// Starlette applies, and what a client probing a resource expects.
if verb is Some(v) &&
(route.verb == v || (v is Head && route.verb is Get)) {
let ctx : Context = { request, params, }
match self.enforce_security(ctx, route.security) {
Some(denied) => return self.apply_status(ctx, denied)
None => ()
}
if route.validate && route.endpoint is Some(ep) {
let errs = ep.validate(ctx)
if errs.length() > 0 {
return self.apply_status(ctx, unprocessable(errs))
}
}
let reply = self.run_with_deps(route.dependencies, () => {
(route.run)(ctx, bg)
}) catch {
e => Buffered(self.dispatch_exception(ctx, e))
}
let resp = match reply {
Buffered(r) => r
Streamed(s) => {
stream.val = Some(s)
@moonasgi.Response::new(
s.status,
s.headers,
join_chunks(s.chunks),
)
}
}
return if verb is Some(Head) && route.verb is Get {
// A HEAD reply carries no body, so a streaming route's chunks
// describe nothing that will be sent.
stream.val = None
@moonasgi.Response::new(resp.status, resp.headers, b"")
} else {
resp
}
} else {
allowed.push(method_name(route.verb))
if route.verb is Get {
allowed.push("HEAD")
}
}
None => ()
}
}
for entry in self.mounts {
let (prefix, target) = entry
match strip_prefix(prefix, request.path) {
Some(rest) => {
let subreq : @moonasgi.Request = {
http_method: request.http_method,
path: rest,
query_string: request.query_string,
headers: request.headers,
body: request.body,
}
match target {
Sub(sub) => {
let (sresp, subbg) = sub.handle_with_stream(subreq)
bg.absorb(subbg)
stream.val = Some(sresp)
return @moonasgi.Response::new(
sresp.status,
sresp.headers,
join_chunks(sresp.chunks),
)
}
// A foreign handler has no background queue of its own to absorb.
Asgi(h) => return h(subreq)
}
}
None => ()
}
}
let ctx : Context = { request, params: Map([]), }
if allowed.length() > 0 {
// RFC 9110 §15.5.6 requires the Allow header on a 405; a client that probes
// a resource reads it rather than guessing.
self.apply_status(
ctx,
http_exception_response(405, "Method Not Allowed".to_json(), [
("allow", dedupe(allowed).join(", ")),
]),
)
} else {
self.apply_status(
ctx,
http_exception_response(404, "Not Found".to_json(), []),
)
}
}
///|
/// The error a route's unprovidable dependency is answered with. It is a `500`
/// because the fault is the app's wiring, not the request.
fn unresolved_dep(key : String) -> Error {
http_error(500, "Unresolved dependency: " + key)
}
///|
/// Resolve a route's declared dependencies into one request scope, run `body`,
/// then close the scope — handing whatever error the body raised to the
/// teardowns before re-raising it, so a `yield` dependency's cleanup sees the
/// failure. A route with no dependencies never opens a scope.
fn App::run_with_deps(
self : App,
keys : Array[String],
body : () -> Reply raise,
) -> Reply raise {
if keys.is_empty() {
return body()
}
let scope = match self.deps {
Some(d) => (d.open)()
None => raise unresolved_dep(keys[0])
}
let mut missing = ""
for k in keys {
if !(scope.resolve)(k) {
missing = k
break
}
}
if missing != "" {
// Whatever resolved before the gap still has to be released.
let err = unresolved_dep(missing)
(scope.close)(Some(err))
raise err
}
let outcome : Result[Reply, Error] = Ok(body()) catch { err => Err(err) }
match outcome {
Ok(reply) => {
(scope.close)(None)
reply
}
Err(err) => {
(scope.close)(Some(err))
raise err
}
}
}
///|
/// The distinct entries of `names`, in first-seen order.
fn dedupe(names : Array[String]) -> Array[String] {
let out : Array[String] = []
for n in names {
if !out.contains(n) {
out.push(n)
}
}
out
}
///|
/// Concatenate a streamed body's chunks — what the buffered half of the app sees
/// of a stream.
fn join_chunks(chunks : Array[Bytes]) -> Bytes {
if chunks.length() == 1 {
return chunks[0]
}
let buf = Buffer()
for c in chunks {
buf.write_bytes(c)
}
buf.to_bytes()
}
///|
/// Route a request through the middleware chain and return the reply in its
/// streamed form, plus the background queue the handler filled. `to_asgi` sends
/// each chunk as its own body event; a test reads `chunks` to see where the
/// boundaries fell. A route that is not a streaming one comes back as a single
/// chunk, so this answers every request, not only the streamed ones.
///
/// A middleware is typed buffered-in, buffered-out, so the chain is run over the
/// joined body and the chunks are kept only when what came back is what went in.
/// A middleware that rewrote the body — `gzip` — has produced something the old
/// boundaries no longer describe, and cutting the new bytes at them would send a
/// corrupt stream.
pub fn App::handle_with_stream(
self : App,
request : @moonasgi.Request,
) -> (@moonasgi.StreamingResponse, BackgroundTasks) {
let bg = BackgroundTasks::new()
let stream : Ref[@moonasgi.StreamingResponse?] = Ref(None)
let base : @moonasgi.Handler = req => self.route_and_dispatch(req, bg, stream)
let resp = @moonasgi.compose(self.middlewares, base)(request)
let out = match stream.val {
Some(s) if join_chunks(s.chunks) == resp.body =>
@moonasgi.StreamingResponse::new(
status=resp.status,
headers=resp.headers,
chunks=s.chunks,
trailers=s.trailers,
early_hints=s.early_hints,
)
_ =>
@moonasgi.StreamingResponse::new(
status=resp.status,
headers=resp.headers,
chunks=[resp.body],
)
}
(out, bg)
}
///|
/// Route a request through the middleware chain and return both the response and
/// the background queue the handler filled — the caller (`to_asgi`, or a test)
/// runs the queue after the response is sent. `handle` is the plain-response
/// wrapper over this.
pub fn App::handle_with_background(
self : App,
request : @moonasgi.Request,
) -> (@moonasgi.Response, BackgroundTasks) {
let (s, bg) = self.handle_with_stream(request)
(@moonasgi.Response::new(s.status, s.headers, join_chunks(s.chunks)), bg)
}
///|
/// Route a request to its handler through the middleware chain, returning 404
/// when no path matches and 405 when a path matches but no method does. Any
/// error a handler raises is mapped to a response by the exception handlers.
/// Background tasks a handler scheduled are dropped on this path; use
/// `handle_with_background` (as `to_asgi` does) to run them.
pub fn App::handle(
self : App,
request : @moonasgi.Request,
) -> @moonasgi.Response {
self.handle_with_background(request).0
}
///|
/// Strip a mount `prefix` off `path` by whole segments, returning the sub-app's
/// path (`"/"` when the request hits the mount root). `None` when `path` does not
/// lie under `prefix` — the comparison is segment-wise, so `/sub` does not match
/// `/subway`.
fn strip_prefix(prefix : String, path : String) -> String? {
let pp = segments(prefix)
let ph = segments(path)
if pp.length() > ph.length() {
return None
}
for i = 0; i < pp.length(); i = i + 1 {
if pp[i] != ph[i] {
return None
}
}
let sb = StringBuilder()
for i = pp.length(); i < ph.length(); i = i + 1 {
sb.write_string("/")
sb.write_string(ph[i])
}
let rest = sb.to_string()
if rest == "" {
Some("/")
} else {
Some(rest)
}
}
///|
async fn drain_body(receive : @moonasgi.Receive) -> Bytes {
let buf = Buffer()
let mut more = true
while more {
match receive() {
HttpRequest(body~, more_body~) => {
buf.write_bytes(body)
more = more_body
}
_ => more = false
}
}
buf.to_bytes()
}
///|
/// This app's hooks as a moonasgi `LifespanHandler` — the synchronous core, which is
/// what makes boot and teardown testable on every backend without a server. Mounted
/// apps are included: a mount is part of the composition being started, and nothing
/// else would ever drive its hooks.
pub fn App::lifespan_handler(self : App) -> @moonasgi.LifespanHandler {
@moonasgi.LifespanHandler::new(
on_startup=_scope => {
match self.run_startup_hooks() {
None => Complete
Some(why) => Failed(message=why)
}
},
on_shutdown=_scope => {
match self.run_shutdown_hooks() {
None => Complete
Some(why) => Failed(message=why)
}
},
)
}
///|
/// Answer the ASGI lifespan protocol: run each server message through the handler
/// above and send back its reply. The run ends on shutdown, or on a failed startup —
/// ASGI has the server abort the boot there and never send `lifespan.shutdown`.
async fn App::serve_lifespan(
self : App,
scope : @moonasgi.Scope,
receive : @moonasgi.Receive,
send : @moonasgi.Send,
) -> Unit {
let handler = self.lifespan_handler()
for ;; {
let event = receive()
let replies = @moonasgi.run_lifespan(handler, scope, [event])
for reply in replies {
send(reply)
}
if event is LifespanShutdown {
break
}
if replies.iter().any(r => r is LifespanStartupFailed(_)) {
break
}
}
}
///|
/// Run every startup hook, this app's mounts included, stopping at the first that
/// raises and returning its message.
fn App::run_startup_hooks(self : App) -> String? {
for hook in self.startup_hooks {
hook() catch {
err => return Some(err.to_string())
}
}
for mount in self.mounts {
if mount.1 is Sub(sub) && sub.run_startup_hooks() is Some(why) {
return Some(why)
}
}
None
}
///|
/// Run every shutdown hook in reverse registration order, mounts first since they
/// were started last. Every hook runs even if an earlier one raised; the first
/// failure is what comes back.
fn App::run_shutdown_hooks(self : App) -> String? {
let mut failure = None
for i = self.mounts.length() - 1; i >= 0; i = i - 1 {
if self.mounts[i].1 is Sub(sub) &&
sub.run_shutdown_hooks() is Some(why) &&
failure is None {
failure = Some(why)
}
}
for i = self.shutdown_hooks.length() - 1; i >= 0; i = i - 1 {
self.shutdown_hooks[i]() catch {
err => if failure is None { failure = Some(err.to_string()) }
}
}
failure
}
///|
/// Compile the app to a moonasgi `AsgiApp` a server can run: drain the request
/// body, route it, and stream the response back over the SEAM.
pub fn App::to_asgi(self : App) -> @moonasgi.AsgiApp {
(scope, receive, send) => {
match scope {
Http(hs) => {
let body = drain_body(receive)
let request : @moonasgi.Request = {
http_method: hs.http_method,
path: hs.path,
query_string: hs.query_string,
headers: hs.headers,
body,
}
let (resp, bg) = self.handle_with_stream(request)
// moonasgi lowers the chunks: one body event each, `more_body` false only
// on the last, and trailers after them when the route declared any.
for event in resp.events() {
send(event)
}
// The response is on the wire; now drain any background tasks.
bg.run()
}
WebSocket(wss) => self.serve_websocket(wss, receive, send)
Lifespan(_) => self.serve_lifespan(scope, receive, send)
}
}
}