///|
/// A route handler. It may raise an `Error`, which the router turns into a 500
/// via the app's `on_error` handler, so one bad parse does not take the
/// response down with it. Handlers that never raise are accepted unchanged.
pub type Handler = (Request) -> Response raise Error
///|
pub type Next = (Request) -> Response raise Error
///|
/// A middleware wraps the chain. Registration order is wrapping order: the first
/// `use`d middleware is outermost. A middleware may short-circuit by returning
/// without calling `next`, or catch what the chain below it raised.
pub type Middleware = (Request, Next) -> Response raise Error
///|
/// One registered route. `path` may be a pattern: literal segments, `:name`
/// segment parameters and a single trailing `*` wildcard.
priv struct Route {
http_method : String
path : String
handler : Handler
}
///|
/// One registered middleware, applied only to requests whose path matches its
/// pattern (the default matches every path).
priv struct MiddlewareEntry {
path : String
mw : Middleware
}
///|
/// The application object. Matching walks each registered pattern segment by
/// segment; among competing matches the most specific route wins, with
/// registration order breaking ties.
struct App {
routes : Array[Route]
middlewares : Array[MiddlewareEntry]
mut not_found_handler : Handler?
mut error_handler : ((Request, Error) -> Response)?
}
///|
pub fn App::new() -> App {
{ routes: [], middlewares: [], not_found_handler: None, error_handler: None, }
}
///|
/// Registers `handler` for `http_method` + `path`. `path` may be a pattern with
/// literal segments, `:name` parameters and at most one trailing `*` wildcard.
/// `ANY` matches every method. Registering the exact same method and pattern
/// twice prints a warning to stderr, because the second route can never be
/// reached — the first one always wins.
pub fn App::route(
self : App,
http_method : String,
path : String,
handler : Handler,
) -> Unit {
for existing in self.routes {
if existing.http_method == http_method && existing.path == path {
eprintln(
"onho: duplicate route \{http_method} \{path} — only the first registration is reachable",
)
}
}
self.routes.push({ http_method, path, handler, })
}
///|
pub fn App::get(self : App, path : String, handler : Handler) -> Unit {
self.route("GET", path, handler)
}
///|
pub fn App::post(self : App, path : String, handler : Handler) -> Unit {
self.route("POST", path, handler)
}
///|
pub fn App::put(self : App, path : String, handler : Handler) -> Unit {
self.route("PUT", path, handler)
}
///|
pub fn App::patch(self : App, path : String, handler : Handler) -> Unit {
self.route("PATCH", path, handler)
}
///|
pub fn App::delete(self : App, path : String, handler : Handler) -> Unit {
self.route("DELETE", path, handler)
}
///|
/// Registers `handler` for `path` under every method, recorded as `ANY`.
pub fn App::all(self : App, path : String, handler : Handler) -> Unit {
self.route("ANY", path, handler)
}
///|
/// Appends `middleware` to the chain, applied to requests whose path matches
/// `path` (a pattern like a route's, defaulting to every path). The first
/// registered middleware is outermost.
pub fn App::use_middleware(
self : App,
middleware : Middleware,
path~ : String = "*",
) -> Unit {
self.middlewares.push({ path, mw: middleware, })
}
///|
/// Replaces the default 404 for requests that match no route.
pub fn App::not_found(self : App, handler : Handler) -> Unit {
self.not_found_handler = Some(handler)
}
///|
/// Replaces the default 500 for an `Error` raised out of a handler or
/// middleware. The default answers plain-text 500 and leaks nothing.
pub fn App::on_error(
self : App,
handler : (Request, Error) -> Response,
) -> Unit {
self.error_handler = Some(handler)
}
///|
/// Splits a path or pattern on `/`, dropping the empty segment a leading slash
/// produces, so `/health` and `health` compare the same while `/health/` keeps
/// its trailing empty segment.
fn pattern_segments(s : String) -> Array[String] {
let out : Array[String] = []
let mut at_start = true
for view in s.split("/") {
let seg = view.to_owned()
if at_start {
at_start = false
if seg.length() == 0 {
continue
}
}
out.push(seg)
}
out
}
///|
/// Tests a pattern against `path` and returns the captured parameters, or `None`
/// when nothing matches:
/// - literal segments must be equal;
/// - a segment of the form `:name` (longer than one character) captures one
/// non-empty path segment;
/// - a final segment that is exactly `*` matches zero or more remaining segments
/// and captures them, joined by `/`, under the name `*`.
/// Segments are compared as handed over: the WCGI gateway decodes `PATH_INFO`, so
/// percent decoding is not repeated here.
fn pattern_match(pattern : String, path : String) -> Array[(String, String)]? {
let ps = pattern_segments(pattern)
let xs = pattern_segments(path)
let has_star = ps.length() > 0 && ps[ps.length() - 1] == "*"
let anchored = pattern.has_prefix("/")
// Paths handed over by the boundary always start with a slash; a slash-anchored
// pattern therefore never matches an unprefixed tail like `static`.
if anchored && !path.has_prefix("/") {
return None
}
let fixed = if has_star { ps.length() - 1 } else { ps.length() }
if !has_star && xs.length() != ps.length() {
return None
}
if xs.length() < fixed {
return None
}
let out : Array[(String, String)] = []
for i = 0; i < fixed; i = i + 1 {
let pseg = ps[i]
let xseg = xs[i]
if pseg.has_prefix(":") && pseg.length() > 1 {
if xseg.length() == 0 {
return None
}
out.push((pseg[1:].to_owned(), xseg))
} else if pseg != xseg {
return None
}
}
if has_star {
let mut rest = ""
for i = fixed; i < xs.length(); i = i + 1 {
if i > fixed {
rest = rest + "/"
}
rest = rest + xs[i]
}
out.push(("*", rest))
}
Some(out)
}
///|
/// Specificity score of a pattern: fewer wildcards, then more literal segments,
/// then fewer `:params`. Used with registration order as the final tie-break.
fn route_score(pattern : String) -> (Int32, Int32, Int32) {
let mut wildcards = 0
let mut literals = 0
let mut params_count = 0
for seg in pattern_segments(pattern) {
if seg == "*" {
wildcards += 1
} else if seg.has_prefix(":") && seg.length() > 1 {
params_count += 1
} else {
literals += 1
}
}
(wildcards, literals, params_count)
}
///|
fn push_unique_string(arr : Array[String], value : String) -> Unit {
for v in arr {
if v == value {
return
}
}
arr.push(value)
}
///|
fn join_comma(arr : Array[String]) -> String {
let buf = StringBuilder()
for i, v in arr.iter2() {
if i > 0 {
buf.write_string(", ")
}
buf.write_string(v)
}
buf.to_string()
}
///|
/// Resolves a request. Routes are tested by pattern; among routes whose patterns
/// match the path, the most specific route whose method matches (or `ANY`) wins.
/// A path that only exists under different methods yields 405 with an `allow`
/// header listing them, so "no route" stays distinguishable from "wrong route".
///
/// Registered middleware wraps the outcome, a `HEAD` request falls back to the
/// `GET` handler for its path with the body dropped, and an `Error` raised out
/// of the chain becomes a 500 through the app's `on_error` handler.
pub fn App::handle(self : App, req : Request) -> Response {
let mut best_index = -1
let mut best_params : Array[(String, String)] = []
let mut best_wildcards = 0
let mut best_literals = 0
let mut best_params_count = 0
let allowed : Array[String] = []
for i, route in self.routes.iter2() {
match pattern_match(route.path, req.path) {
None => continue
Some(params) =>
if method_matches(route.http_method, req.http_method) {
let (wildcards, literals, params_count) = route_score(route.path)
let better = best_index < 0 ||
wildcards < best_wildcards ||
(
wildcards == best_wildcards &&
(
literals > best_literals ||
(literals == best_literals && params_count < best_params_count)
)
)
if better {
best_index = i
best_params = params
best_wildcards = wildcards
best_literals = literals
best_params_count = params_count
}
} else {
push_unique_string(allowed, route.http_method)
}
}
}
// A HEAD with no handler of its own is served by the GET route, body dropped.
let fall_back_to_get = req.http_method == "HEAD" && best_index < 0
if fall_back_to_get {
for i, route in self.routes.iter2() {
if route.http_method == "GET" || route.http_method == "ANY" {
match pattern_match(route.path, req.path) {
Some(params) => {
best_index = i
best_params = params
break
}
None => ()
}
}
}
}
let endpoint : Next = req2 => {
if best_index >= 0 {
let route = self.routes[best_index]
let res = (route.handler)({ ..req2, params: best_params, })
// Only the GET fallback fakes a HEAD reply; a route registered for HEAD
// owns its body.
if fall_back_to_get {
{ ..res, body: "", }
} else {
res
}
} else if allowed.length() > 0 {
response_with(405, [("allow", join_comma(allowed))], "method not allowed")
} else {
match self.not_found_handler {
Some(handler) => handler(req2)
None => response(404, "not found: \{req2.path}")
}
}
}
let chained = fold_middlewares(self.middlewares, 0, req, endpoint)
chained(req) catch {
err =>
match self.error_handler {
Some(handler) => handler(req, err)
None => response(500, "internal server error")
}
}
}
///|
/// `ANY` routes serve every method.
fn method_matches(route_method : String, request_method : String) -> Bool {
route_method == "ANY" || route_method == request_method
}
///|
/// Folds the middlewares that match `req.path` around `endpoint`, registration
/// order wrapping outward: entry 0 ends up outermost.
fn fold_middlewares(
entries : Array[MiddlewareEntry],
index : Int32,
req : Request,
endpoint : Next,
) -> Next {
if index >= entries.length() {
return endpoint
}
let entry = entries[index]
if pattern_match(entry.path, req.path) is None {
return fold_middlewares(entries, index + 1, req, endpoint)
}
let rest = fold_middlewares(entries, index + 1, req, endpoint)
req2 => (entry.mw)(req2, rest)
}
///|
/// Builds a logging middleware. Each handled request produces one line on the
/// standard error channel — ` -> ` — which is where a
/// CGI runner collects guest diagnostics, keeping stdout exclusively for the
/// response itself.
///
/// The line records the status as it arrived at this layer, so middleware
/// further out that rewrites the response is not reflected here.
pub fn logger() -> Middleware {
(req, next) => {
let res = next(req)
eprintln(
"\{req.http_method} \{req.path} -> \{res.status} \{reason_phrase(res.status)}",
)
res
}
}