Segment type
pub type Segment = Lit{text: Str} | Param{name: Str, kind: Kind} | Rest{name: Str} derive Eq, ShowOne segment of a compiled pattern: literal text, a parameter of a kind, or the rest of the path.
Package
Path routing beyond svc: typed and wildcard parameters, most-specific matching, 404/405/OPTIONS, path cleaning, trailing slashes, groups, hosts, mounting and reverse URLs
0.1.0 MIT
sspur add router@^0.1.0or in sspur.toml:
router = { version = "^0.1.0" }A path router for SSPUR's HTTP server, for the cases a svc doesn't cover. A svc already binds {name} segments, routes by method and answers 404 and 405 with Allow; use it when that is enough. Reach for router when you need:
{id:int}, {id:uuid}, {name:alpha}, {slug:[a-z-]+};/assets/{path...};/users/me over /users/{id:int} over /users/{name}), with conflicting routes reported when the table is compiled;OPTIONS and HEAD, 405 with a correct Allow, and OPTIONS *;//, ., ..) and trailing-slash redirects (301 for GET and HEAD, 308 otherwise, keeping the query);api.example.com, *.tenant.example.com);svc or any handler under a prefix;Routes are data. Each route names an id of your own type, usually a sum type, and the router says which one matched and with which decoded parameters; your handler dispatches with a match. Server handlers can't keep function values built outside them, so a table of handler functions would have to be rebuilt on every request, while a compiled table of data is built once and shared by every handler thread.
$ sspur add router
use router.{Match, Table, Route, get, post, delete, group, compile, default_options, route_request, param_int, has_tag, url_for, describe}
type Page = Home | ShowUser | DeleteUser | NewUser | Me | Assets | AdminStats
fn routes() -> List[Route[Page]]
= [get("/", Home), get("/users/me", Me), get("/users/\{id:int}", ShowUser), delete("/users/\{id:int}", DeleteUser), post("/users", NewUser), get("/assets/\{path...}", Assets)] + group("/admin", ["admin"], [get("/stats", AdminStats)])
fn page(m: Match[Page]) -> Response
= match m.id
| ShowUser => http.text(200, "user {param_int(m, "id").or(0)}")
| Me => http.text(200, "me")
| other => http.text(200, "{other} {m.params}")
fn dispatch(t: Table[Page], req: Request) -> Response
= match route_request(t, req)
| ok(m) => if has_tag(m, "admin") and req.header("authorization").is_none then http.text(401, "sign in") else page(m)
| err(answer) => answer
fn rq(m: Str, url: Str) -> Request
= Request{method: m, url, headers: [], body: "", timeout_ms: 0, retries: 0, redirects: 0}
fn main() -> Unit ! log
= match compile(routes(), default_options())
| err(problems) => log.error(problems.join("; "))
| ok(t) => do
for line in describe(t)
log(line)
for (m, url) in [("GET", "/users/me"), ("GET", "/users/42"), ("PUT", "/users/42"), ("GET", "/users/42/"), ("GET", "/assets/css/site.css"), ("GET", "/admin/stats"), ("GET", "/nope")]
r = dispatch(t, rq(m, url))
log("{m} {url} -> {r.status} {r.header("allow").or(r.header("location").or(r.body))}")
log("url: {url_for(t, ShowUser, [("id", "7")], [("tab", "posts")])}")
// Serving it: http.serve_with(http.server(8080), req => dispatch(t, req))
$ sspur run main.ssp
GET / -> Home
GET /users/me -> Me
GET /users/{id:int} -> ShowUser
DELETE /users/{id:int} -> DeleteUser
POST /users -> NewUser
GET /assets/{path...} -> Assets
GET /admin/stats -> AdminStats
GET /users/me -> 200 me
GET /users/42 -> 200 user 42
PUT /users/42 -> 405 GET, HEAD, DELETE, OPTIONS
GET /users/42/ -> 301 /users/42
GET /assets/css/site.css -> 200 Assets [("path", "css/site.css")]
GET /admin/stats -> 401 sign in
GET /nope -> 404 {"type":"about:blank","title":"Not Found","status":404}
url: some("/users/7?tab=posts")
Note the \{ in patterns: in an SSPUR string literal {id} is interpolation, so write "/users/\{id}". ({id:int} happens to stay literal, but escaping every { is the safe habit.)
Mounting a svc under a prefix: route any_method("/api/\{rest...}", Api) and answer it with api(router.strip_prefix("/api", req).or(req)); the svc then sees /items/7 for /api/items/7 and keeps its own typed binding, 404 and 405.
/ and each segment is percent-decoded (a %2F stays inside its segment); a bad escape or invalid UTF-8 is 400.case_insensitive.{name} takes any non-empty segment, {name:int} decimal digits (up to 18, so it fits an Int), {name:uuid} the 8-4-4-4-12 hex form, {name:alpha} letters, and anything else after the colon is a regular expression that must match the whole segment. {name...} takes the rest of the path, slashes included, and may be empty only after a trailing slash (/assets/ gives ""; /assets redirects to /assets/).405 with every method those routes serve in Allow (with HEAD and OPTIONS when automatic), or 204 with Allow for OPTIONS.404 (SlashStrict) or route it (SlashIgnore).clean_path of it first, unless clean_paths is off.Route[T]: methods, pattern, host, tags and id. Entry[T] and Table[T]: a compiled table.Segment (Lit, Param, Rest) and Kind (AnyText, Digits, UuidText, Letters, Matching): parsed patterns.Options, Slash (SlashStrict, SlashRedirect, SlashIgnore), default_options().Match[T]: the id, pattern, tags and decoded parameters of the route that matched.Resolution[T]: Matched, Redirected, NoRoute, WrongMethod, OptionsAnswer, BadPath.route(methods, pattern, id), get, post, put, patch, delete, any_method.on_host(r, host), tagged(r, tag), group(prefix, tags, routes) (groups nest).parse_pattern(pattern): the segments, or what is wrong with the pattern.compile(routes, options): the table, or every problem (bad patterns, bad methods, routes that can never match).resolve(t, req): the full Resolution.route_request(t, req): ok(match) or err(response), the usual way to dispatch.answer(resolution): the response for everything but a match: problem details for 400 and 404, 405 and 204 with Allow, redirects.param(m, name), param_int(m, name), has_tag(m, tag).allowed_methods(t, path), describe(t): what a path serves, and one line per route.clean_path(path): remove empty, . and .. segments, never above the root.decode_segment(s), encode_segment(s): percent-encoding for one path segment.kind_accepts(kind, s): whether a decoded segment fits a parameter kind.strip_prefix(prefix, req): the request as a handler mounted at prefix sees it.url_for(t, id, params, query): the URL of a route with its parameters filled in.middleware package, and apply it per group with has_tag.svc does that with types; mount one under a prefix./files/{name}.{ext}): a segment is either text or one parameter; use a regex kind such as {file:[^/]+\.json}.MIT, see LICENSE.
pub type Segment = Lit{text: Str} | Param{name: Str, kind: Kind} | Rest{name: Str} derive Eq, ShowOne segment of a compiled pattern: literal text, a parameter of a kind, or the rest of the path.
pub type Kind = AnyText | Digits | UuidText | Letters | Matching{source: Str, re: Regex} derive Eq, ShowWhat a {name:kind} parameter accepts: any non-empty segment, decimal digits (a non-negative Int), a UUID in its 8-4-4-4-12 hex form, letters only, or a regular expression that must match the whole segment.
pub type Slash = SlashStrict | SlashRedirect | SlashIgnore derive Eq, ShowWhat to do when a path matches a route only with its trailing slash added or removed: SlashStrict answers 404, SlashRedirect redirects to the other form, SlashIgnore routes it.
pub type Options = {slash: Slash, clean_paths: Bool, case_insensitive: Bool, auto_options: Bool, auto_head: Bool} derive Eq, ShowHow the table behaves: trailing slashes, redirecting unclean paths (//, ., ..) to their clean form, literal segments compared case-insensitively, OPTIONS answered with Allow, and HEAD served by GET routes.
pub type Route[T] = {methods: List[Str], pattern: Str, host: Str, tags: List[Str], id: T} derive Eq, ShowA route: the methods it serves (empty for any), its pattern, the host it is limited to ("" for any, api.example.com, or *.example.com for subdomains), tags for grouping and the id the router answers with.
pub type Entry[T] = {route: Route[T], segments: List[Segment]} derive Eq, ShowA compiled route: the route and its parsed segments.
pub type Table[T] = {entries: List[Entry[T]], options: Options} derive Eq, ShowA compiled routing table.
pub type Match[T] = {id: T, pattern: Str, tags: List[Str], params: List[(Str, Str)]} derive Eq, ShowA route that matched: its id, its pattern, its tags and the decoded parameters in pattern order.
pub type Resolution[T] = Matched{found: Match[T]} | Redirected{status: Int, location: Str} | NoRoute | WrongMethod{allow: List[Str]} | OptionsAnswer{allow: List[Str]} | BadPath{why: Str} derive Eq, ShowWhere a request goes: a route, a redirect (to the clean path or the other trailing-slash form), no route, a route for other methods (with the methods to put in Allow), an automatic OPTIONS answer, or a malformed path.
fn default_options() -> OptionsThe defaults: redirect between trailing-slash forms and to clean paths, case-sensitive literal segments, automatic OPTIONS and HEAD.
fn route[T](methods: List[Str], pattern: Str, id: T) -> Route[T]A route for methods (empty for any method) at pattern, answering id. Methods are case-sensitive, as RFC 9110 says, so they are written in capitals; compile reports others.
fn get[T](pattern: Str, id: T) -> Route[T]A GET route (also serving HEAD when the table's auto_head is on).
fn post[T](pattern: Str, id: T) -> Route[T]A POST route.
fn put[T](pattern: Str, id: T) -> Route[T]A PUT route.
fn patch[T](pattern: Str, id: T) -> Route[T]A PATCH route.
fn delete[T](pattern: Str, id: T) -> Route[T]A DELETE route.
fn any_method[T](pattern: Str, id: T) -> Route[T]A route for every method.
fn on_host[T](r: Route[T], host: Str) -> Route[T]r limited to host: an exact host or *.example.com for its subdomains.
fn tagged[T](r: Route[T], tag: Str) -> Route[T]r with tag added.
fn group[T](prefix: Str, tags: List[Str], routes: List[Route[T]]) -> List[Route[T]]routes under prefix (/admin turns /users into /admin/users and / into /admin/), each tagged with tags. Groups nest.
fn parse_pattern(pattern: Str) -> Res[List[Segment], Str]The segments of pattern, or what is wrong with it: it must start with /; each segment is text or one {name}, {name:int}, {name:uuid}, {name:alpha}, {name:regex} parameter; {name...} takes the rest of the path and must be last; names are unique.
fn compile[T](routes: List[Route[T]], opts: Options) -> Res[Table[T], List[Str]]The table for routes under opts, or every problem found: bad patterns, bad methods, and routes that can never be chosen because an earlier route has the same shape, host and an overlapping method.
fn decode_segment(s: Str) -> Opt[Str]s with %XX escapes decoded (a + stays a +), or none for a bad escape or bytes that are not UTF-8.
fn encode_segment(s: Str) -> Strs percent-encoded as one path segment: everything but letters, digits and -._~!$&'()*+,;=:@ becomes %XX, so a / inside a parameter stays inside it.
fn kind_accepts(k: Kind, s: Str) -> BoolWhether the decoded segment s is acceptable to k.
fn clean_path(path: Str) -> Strpath with empty segments, . and .. removed (never above the root), keeping a trailing slash, as Go's path.Clean does for request paths.
fn resolve[T](t: Table[T], req: Request) -> Resolution[T]Where req goes in t. OPTIONS * lists every method the table serves.
fn answer[T](r: Resolution[T]) -> Opt[Response]The response for a resolution that is not a match: 404, 405 with Allow, 204 with Allow for OPTIONS, the redirect, or 400, as RFC 9457 problem details. A match gives none.
fn route_request[T](t: Table[T], req: Request) -> Res[Match[T], Response]The match for req, or the response to send when there is none (see answer), for a handler that dispatches with match router.route(table, req) | ok(m) => ... | err(r) => r.
fn param[T](m: Match[T], name: Str) -> Opt[Str]The value of the parameter name of m.
fn param_int[T](m: Match[T], name: Str) -> Opt[Int]The parameter name of m as an Int, or none when missing or not a number.
fn has_tag[T](m: Match[T], tag: Str) -> BoolWhether m's route carries tag, for applying a group's middleware.
fn allowed_methods[T](t: Table[T], path: Str) -> List[Str]The methods t serves at path (what Allow would say), empty when no route has the path.
fn describe[T](t: Table[T]) -> List[Str]One line per route, METHODS pattern [host] -> id, in table order, for logs and debugging.
fn strip_prefix(prefix: Str, req: Request) -> Opt[Request]req with prefix removed from its path (keeping the query), for passing a request on to a svc or another handler mounted there: /api/users?x=1 under /api is /users?x=1, and /api itself is /. none when the path is not under the prefix.
fn url_for[T](t: Table[T], id: T, params: List[(Str, Str)], query: List[(Str, Str)]) -> Opt[Str]The path of the first route in t with id id, with params filled in (each checked against its kind and percent-encoded; a rest parameter keeps its slashes) and query appended. none when there is no such route or a parameter is missing or doesn't fit.
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.1.0 | 2026-10-16 | ca65280a49 | snapshot |