Package

router

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

Install

sspur add router@^0.1.0

or in sspur.toml:

router = { version = "^0.1.0" }

README

router

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:

  • typed and constrained parameters: {id:int}, {id:uuid}, {name:alpha}, {slug:[a-z-]+};
  • catch-all wildcards: /assets/{path...};
  • the most specific route winning whatever the order (/users/me over /users/{id:int} over /users/{name}), with conflicting routes reported when the table is compiled;
  • automatic OPTIONS and HEAD, 405 with a correct Allow, and OPTIONS *;
  • cleaned paths (//, ., ..) and trailing-slash redirects (301 for GET and HEAD, 308 otherwise, keeping the query);
  • route groups with prefixes and tags, for per-group middleware such as an admin check;
  • host constraints (api.example.com, *.tenant.example.com);
  • mounting a svc or any handler under a prefix;
  • building URLs back from routes, with every parameter checked and percent-encoded.

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.

Install

$ sspur add router

Example

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.

Matching rules

  • A path is split on / and each segment is percent-decoded (a %2F stays inside its segment); a bad escape or invalid UTF-8 is 400.
  • Literal segments compare exactly, or case-insensitively with 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/).
  • Among the routes whose path, host and method match, the most specific wins, segment by segment from the left: literal, then constrained parameter, then plain parameter, then rest; then a route with methods over one for any method. Equal routes are compile errors.
  • No route serves the method but some have the path: 405 with every method those routes serve in Allow (with HEAD and OPTIONS when automatic), or 204 with Allow for OPTIONS.
  • No route has the path but one has it with the trailing slash toggled: redirect (default), 404 (SlashStrict) or route it (SlashIgnore).
  • An unclean path is redirected to clean_path of it first, unless clean_paths is off.

API

Types

  • 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.

Building routes

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

Routing

  • 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.

Paths and URLs

  • 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.

Out of scope

  • Storing handler functions in the table (the language doesn't allow effect-generic records, and handlers can't share function values); dispatch on the id instead.
  • Middleware: see the middleware package, and apply it per group with has_tag.
  • Query-string and body binding: a svc does that with types; mount one under a prefix.
  • Parameters inside a segment (/files/{name}.{ext}): a segment is either text or one parameter; use a regex kind such as {file:[^/]+\.json}.

License

MIT, see LICENSE.

API

Segment type

pub type Segment = Lit{text: Str} | Param{name: Str, kind: Kind} | Rest{name: Str} derive Eq, Show

One segment of a compiled pattern: literal text, a parameter of a kind, or the rest of the path.

Kind type

pub type Kind = AnyText | Digits | UuidText | Letters | Matching{source: Str, re: Regex} derive Eq, Show

What 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.

Slash type

pub type Slash = SlashStrict | SlashRedirect | SlashIgnore derive Eq, Show

What 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.

Options type

pub type Options = {slash: Slash, clean_paths: Bool, case_insensitive: Bool, auto_options: Bool, auto_head: Bool} derive Eq, Show

How 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.

Route type

pub type Route[T] = {methods: List[Str], pattern: Str, host: Str, tags: List[Str], id: T} derive Eq, Show

A 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.

Entry type

pub type Entry[T] = {route: Route[T], segments: List[Segment]} derive Eq, Show

A compiled route: the route and its parsed segments.

Table type

pub type Table[T] = {entries: List[Entry[T]], options: Options} derive Eq, Show

A compiled routing table.

Match type

pub type Match[T] = {id: T, pattern: Str, tags: List[Str], params: List[(Str, Str)]} derive Eq, Show

A route that matched: its id, its pattern, its tags and the decoded parameters in pattern order.

Resolution type

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, Show

Where 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.

default_options fn

fn default_options() -> Options

The defaults: redirect between trailing-slash forms and to clean paths, case-sensitive literal segments, automatic OPTIONS and HEAD.

route fn

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.

get fn

fn get[T](pattern: Str, id: T) -> Route[T]

A GET route (also serving HEAD when the table's auto_head is on).

post fn

fn post[T](pattern: Str, id: T) -> Route[T]

A POST route.

put fn

fn put[T](pattern: Str, id: T) -> Route[T]

A PUT route.

patch fn

fn patch[T](pattern: Str, id: T) -> Route[T]

A PATCH route.

delete fn

fn delete[T](pattern: Str, id: T) -> Route[T]

A DELETE route.

any_method fn

fn any_method[T](pattern: Str, id: T) -> Route[T]

A route for every method.

on_host fn

fn on_host[T](r: Route[T], host: Str) -> Route[T]

r limited to host: an exact host or *.example.com for its subdomains.

tagged fn

fn tagged[T](r: Route[T], tag: Str) -> Route[T]

r with tag added.

group fn

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.

parse_pattern fn

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.

compile fn

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.

decode_segment fn

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.

encode_segment fn

fn encode_segment(s: Str) -> Str

s percent-encoded as one path segment: everything but letters, digits and -._~!$&'()*+,;=:@ becomes %XX, so a / inside a parameter stays inside it.

kind_accepts fn

fn kind_accepts(k: Kind, s: Str) -> Bool

Whether the decoded segment s is acceptable to k.

clean_path fn

fn clean_path(path: Str) -> Str

path with empty segments, . and .. removed (never above the root), keeping a trailing slash, as Go's path.Clean does for request paths.

resolve fn

fn resolve[T](t: Table[T], req: Request) -> Resolution[T]

Where req goes in t. OPTIONS * lists every method the table serves.

answer fn

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.

route_request fn

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.

param fn

fn param[T](m: Match[T], name: Str) -> Opt[Str]

The value of the parameter name of m.

param_int fn

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.

has_tag fn

fn has_tag[T](m: Match[T], tag: Str) -> Bool

Whether m's route carries tag, for applying a group's middleware.

allowed_methods fn

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.

describe fn

fn describe[T](t: Table[T]) -> List[Str]

One line per route, METHODS pattern [host] -> id, in table order, for logs and debugging.

strip_prefix fn

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.

url_for fn

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.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-16ca65280a49snapshot