~/bend-docscommunity

air/router.bend source

air/router.bend on the hub · documented module

import Baseimport ./text.bend as Textimport ./http.bend as Http# Router# ======## Routes pair a method and a path pattern with a handler. `dispatch`# runs the first route that matches the request. A pattern is parsed# once, when the route is made: `/users/:id` binds `id`, `/files/*path`# binds the rest of the path. There is no tree: handlers are affine# closures rebuilt per request, so a tree would be rebuilt too.def Handler() -> Type:  Http.Request -> IO(Http.Response)# One segment of a route pattern. `Rest` is `*name`: it must be last and# binds the remaining path, joined with "/", possibly "".type Seg is Data:  Lit{text: String}  Param{name: String}  Rest{name: String}def classify(+s: String) -> Seg:  match s:    case SCon{':', name}:      Param{name}    case SCon{'*', name}:      Rest{name}    case _:      Lit{s}def parse_pattern.go(segs: List<&2, String>, acc: List<&2, Seg>) -> List<&2, Seg>:  match segs:    case Nil{}:      List.reverse(&2, Seg, acc)    case Con{h, t}:      parse_pattern.go(t, Con{classify(h), acc})def parse_pattern(+path: String) -> List<&2, Seg>:  parse_pattern.go(Text.segments(path), Nil{})# Whether a path ends with a slash that is not the root's.def has_slash(+path: String) -> Bool:  Bool.and(Text.last_is(path, '/'), Bool.not(String.eq(path, "/")))# `slash` remembers whether the pattern ended with a slash, for the# strict policy.type Route is Type:  Route{method: Http.Method, pattern: List<&2, Seg>, slash: Bool, handler: Handler()}def new(method: Http.Method, +path: String, handler: Handler()) -> Route:  Route{method, parse_pattern(path), has_slash(path), handler}def get(path: String, handler: Handler()) -> Route:  new(Http.GET{}, path, handler)def post(path: String, handler: Handler()) -> Route:  new(Http.POST{}, path, handler)def put(path: String, handler: Handler()) -> Route:  new(Http.PUT{}, path, handler)def delete(path: String, handler: Handler()) -> Route:  new(Http.DELETE{}, path, handler)def patch(path: String, handler: Handler()) -> Route:  new(Http.PATCH{}, path, handler)# A HEAD route is optional: without one, HEAD runs the GET route and the# server drops the body.def head(path: String, handler: Handler()) -> Route:  new(Http.HEAD{}, path, handler)# An OPTIONS route is optional: without one, OPTIONS answers 204 with# the methods the path allows.def options(path: String, handler: Handler()) -> Route:  new(Http.OPTIONS{}, path, handler)# Mounting# --------# Prefixes every route with `prefix`: "/api" + "/users/:id" is# "/api/users/:id". The prefix may hold params; a wildcard in it would# make every route under it unreachable.def mount.go(+prefix: List<&2, Seg>, routes: List<Route>) -> List<Route>:  match routes:    case Nil{}:      Nil{}    case Con{Route{m, pat, slash, h}, rest}:      Con{Route{m, List.append(&2, Seg, prefix, pat), slash, h}, mount.go(prefix, rest)}def mount(prefix: String, routes: List<Route>) -> List<Route>:  mount.go(parse_pattern(prefix), routes)def append_routes(xs: List<Route>, ys: List<Route>) -> List<Route>:  match xs:    case Nil{}:      ys    case Con{h, t}:      Con{h, append_routes(t, ys)}# Joins route groups in order: `all([pages(), mount("/api", api())])`.def all(groups: List<List<Route>>) -> List<Route>:  match groups:    case Nil{}:      Nil{}    case Con{g, rest}:      append_routes(g, all(rest))# Conflicts# ---------def seg_shape(seg: Seg) -> String:  match seg:    case Lit{text}:      "/" ++ text    case Param{name}:      "/:"    case Rest{name}:      "/*"def shape.go(pat: List<&2, Seg>, rev: String) -> String:  match pat:    case Nil{}:      String.reverse(rev)    case Con{h, t}:      shape.go(t, Text.rev_onto(seg_shape(h), rev))# The shape of a pattern, params and wildcards anonymized: "/users/:/*".# Two routes with the same method and shape conflict.def shape(pat: List<&2, Seg>) -> String:  match pat:    case Nil{}:      "/"    case Con{h, t}:      shape.go(Con{h, t}, SNil{})# The shape with the pattern's trailing slash, so "/s" and "/s/" are# two keys: the strict policy tells them apart, and under the others# the first registered wins.def shape_with_slash(pat: List<&2, Seg>, slash: Bool) -> String:  match slash:    case True{}:      shape(pat) ++ "/"    case False{}:      shape(pat)# A wildcard anywhere but last can never match.def rest_misplaced(pat: List<&2, Seg>) -> Bool:  match pat:    case Nil{}:      False{}    case Con{Rest{n}, Nil{}}:      False{}    case Con{Rest{n}, Con{h, t}}:      True{}    case Con{p, t}:      rest_misplaced(t)def conflicts.note(acc: List<&2, String>, key: String, misplaced: Bool, dup: Bool) -> List<&2, String>:  match misplaced dup:    case True{} _:      Con{"wildcard not last: " ++ key, acc}    case False{} True{}:      Con{"duplicate route: " ++ key, acc}    case False{} False{}:      accdef conflicts.go(routes: List<Route>, +seen: Map<&2, String>, acc: List<&2, String>) -> List<&2, String>:  match routes:    case Nil{}:      List.reverse(&2, String, acc)    case Con{Route{m, +pat, slash, h}, rest}:      +key = Http.Method.show(m) ++ " " ++ shape_with_slash(pat, slash)      conflicts.go(        rest,        Map.set(&2, String, seen, key, "x"),        conflicts.note(acc, key, rest_misplaced(pat), String.eq(Text.map_get(seen, key, ""), "x")))# Every problem in a route list, one line each, in list order:# "duplicate route: GET /users/:" or "wildcard not last: GET /a/*/b".# Empty when the list is clean. The list is consumed.def conflicts(routes: List<Route>) -> List<&2, String>:  conflicts.go(routes, Map.new(&2, String), Nil{})def check.report(problems: List<&2, String>) -> IO(Unit):  match problems:    case Nil{}:      IO.pure(Unit, Unit{})    case Con{h, t}:      do IO<Unit>:        IO.print("air: " ++ h)        check.report(t)def check.fin(problems: List<&2, String>, clean: Bool) -> IO(Unit):  match clean:    case True{}:      IO.pure(Unit, Unit{})    case False{}:      do IO<Unit>:        check.report(problems)        IO.die(Unit, 1, "air: routes conflict")# Prints each conflict and ends the process with code 1; returns quietly# when the routes are clean. Call it in `main` before serving.def check(routes: List<Route>) -> IO(Unit):  +problems = conflicts(routes)  check.fin(problems, List.is_empty(&2, String, problems))# The patterns of a route list, handlers dropped; for laws.def pattern_of(routes: List<Route>) -> List<&2, List<&2, Seg>>:  match routes:    case Nil{}:      Nil{}    case Con{Route{m, pat, slash, h}, rest}:      Con{pat, pattern_of(rest)}# Matching# --------def bind_segment.lit(m: Map<&2, String>, same: Bool) -> Maybe<&2, Map<&2, String>>:  match same:    case True{}:      Some{m}    case False{}:      None{}# A `Param` binds the request segment; a `Lit` must equal it.def bind_segment(m: Map<&2, String>, seg: Seg, +s: String) -> Maybe<&2, Map<&2, String>>:  match seg:    case Lit{text}:      bind_segment.lit(m, String.eq(text, s))    case Param{name}:      Some{Map.set(&2, String, m, name, s)}    case Rest{name}:      Some{Map.set(&2, String, m, name, s)}# The remaining segments joined with "/": ["a", "b"] is "a/b", [] is "".def join_rest(segs: List<&2, String>, rev: String) -> String:  match segs:    case Nil{}:      String.drop(String.reverse(rev), 1n)    case Con{h, t}:      join_rest(t, Text.rev_onto("/" ++ h, rev))def match_path(pat: List<&2, Seg>, segs: List<&2, String>, params: Maybe<&2, Map<&2, String>>) -> Maybe<&2, Map<&2, String>>:  match pat segs params:    case _ _ None{}:      None{}    case Nil{} Nil{} Some{m}:      Some{m}    case Nil{} Con{h, t} Some{m}:      None{}    case Con{Rest{name}, pt} _ Some{m}:      Some{Map.set(&2, String, m, name, join_rest(segs, SNil{}))}    case Con{p, pt} Nil{} Some{m}:      None{}    case Con{p, pt} Con{+s, st} Some{m}:      match_path(pt, st, bind_segment(m, p, s))# Slash policy# ------------# What a trailing slash means. `Ignore` (the default) treats "/a/" and# "/a" as the same path. `Strict` matches a route only when the request# and the pattern agree on the slash. `Redirect` answers 308 to the path# without the slash, query kept, and never redirects "/".type Slash is Data:  Ignore{}  Strict{}  Redirect{}def is_strict(policy: Slash) -> Bool:  match policy:    case Strict{}:      True{}    case Ignore{}:      False{}    case Redirect{}:      False{}# Under the strict policy a route is only considered when its slash# agrees with the request's.def slash_ok(strict: Bool, route_slash: Bool, path_slash: Bool) -> Bool:  Bool.or(Bool.not(strict), Bool.not(Bool.xor(route_slash, path_slash)))def gate(ok: Bool, m: Maybe<&2, Map<&2, String>>) -> Maybe<&2, Map<&2, String>>:  match ok:    case True{}:      m    case False{}:      None{}def drop_slashes(+s: String) -> String:  match s:    case SCon{'/', t}:      drop_slashes(t)    case _:      sdef with_query(path: String, qs: String) -> String:  match qs:    case SNil{}:      path    case SCon{h, t}:      Text.append(path, SCon{'?', SCon{h, t}})def redirect_target.fin(pq: Text.Two()) -> String:  (path, qs) = pq  with_query(String.reverse(drop_slashes(String.reverse(path))), qs)# Where a redirected request goes: the raw target, undecoded, minus its# trailing slashes, query kept.def redirect_target(+target: String) -> String:  redirect_target.fin(Text.split_at(target, '?'))# Dispatch# --------# The outcome of walking the routes. `Hit` is an exact match. `Fallback`# is a GET route found while serving HEAD, kept unless an explicit HEAD# route turns up later. `Miss` carries the methods routes offered for# this path, so 405 and OPTIONS can list them.type Pick is Type:  Hit{handler: Handler(), params: Map<&2, String>}  Fallback{handler: Handler(), params: Map<&2, String>, allowed: Map<&2, String>}  Miss{allowed: Map<&2, String>}def mark(m: Map<&2, String>, name: String) -> Map<&2, String>:  Map.set(&2, String, m, name, "x")def marked(+m: Map<&2, String>, name: String) -> Bool:  String.eq(Text.map_get(m, name, ""), "x")def with_head(+m: Map<&2, String>, has_get: Bool) -> Map<&2, String>:  match has_get:    case True{}:      mark(m, "HEAD")    case False{}:      m# The `Allow` value: the methods offered, plus HEAD when GET is, plus# OPTIONS, sorted and joined with ", ".def render_allow(+allowed: Map<&2, String>) -> String:  String.join(Map.keys(&2, String, mark(with_head(allowed, marked(allowed, "GET")), "OPTIONS")), ", ")# Folds one path-matching route into the pick. `same` is an exact method# match, `head_get` a GET route seen by a HEAD request.def consider.fin(acc: Pick, handler: Handler(), name: String, same: Bool, head_get: Bool, matched: Maybe<&2, Map<&2, String>>) -> Pick:  match acc same head_get matched:    case _ _ _ None{}:      acc    case Hit{h, p} _ _ Some{params}:      Hit{h, p}    case Miss{allowed} True{} _ Some{params}:      Hit{handler, params}    case Fallback{h, p, allowed} True{} _ Some{params}:      Hit{handler, params}    case Miss{allowed} False{} True{} Some{params}:      Fallback{handler, params, mark(allowed, name)}    case Miss{allowed} False{} False{} Some{params}:      Miss{mark(allowed, name)}    case Fallback{h, p, allowed} False{} _ Some{params}:      Fallback{h, p, mark(allowed, name)}# `OPTIONS *` asks about the server, not a path: every route counts as# matching and none is run.def star_match(star: Bool, matched: Maybe<&2, Map<&2, String>>) -> Maybe<&2, Map<&2, String>>:  match star:    case True{}:      Some{Map.new(&2, String)}    case False{}:      matched# What the walk needs from the request, computed once: its path split# into segments, its method's name, and the slash and star flags.type Ask is Data:  Ask{segs: List<&2, String>, method: String, strict: Bool, path_slash: Bool, star: Bool}def consider.route(acc: Pick, route: Route, +ask: Ask) -> Pick:  match route ask:    case Route{m, pat, slash, handler} Ask{segs, method, strict, path_slash, star}:      +name = Http.Method.show(m)      consider.fin(        acc,        handler,        name,        Bool.and(Bool.not(star), String.eq(name, method)),        Bool.and(Bool.not(star), Bool.and(String.eq(method, "HEAD"), String.eq(name, "GET"))),        star_match(star,          gate(slash_ok(strict, slash, path_slash),            match_path(pat, segs, Some{Map.new(&2, String)}))))def consider(acc: Pick, route: Route, +ask: Ask) -> Pick:  match acc:    case Hit{handler, params}:      Hit{handler, params}    case Fallback{h, p, allowed}:      consider.route(Fallback{h, p, allowed}, route, ask)    case Miss{allowed}:      consider.route(Miss{allowed}, route, ask)def select(routes: List<Route>, acc: Pick, +ask: Ask) -> Pick:  match routes:    case Nil{}:      acc    case Con{route, rest}:      select(rest, consider(acc, route, ask), ask)# Walks the routes for the request under the slash policy.def pick(+policy: Slash, routes: List<Route>, +req: Http.Request) -> Pick:  +path = Http.Request.path(req)  select(routes, Miss{Map.new(&2, String)},    Ask{Text.segments(path), Http.Method.show(Http.Request.method(req)), is_strict(policy), has_slash(path), String.eq(path, "*")})# A path no route knows is 404. One that routes know under other methods# is 405 with `Allow`, or 204 with `Allow` when the request is OPTIONS.def miss.pick(+allowed: Map<&2, String>, options: Bool, none: Bool) -> Http.Response:  match options none:    case _ True{}:      Http.Response.not_found()    case True{} False{}:      Http.Response.options(render_allow(allowed))    case False{} False{}:      Http.Response.method_not_allowed(render_allow(allowed))def miss(+allowed: Map<&2, String>, options: Bool) -> Http.Response:  miss.pick(allowed, options, List.is_empty(&2, String, Map.keys(&2, String, allowed)))def run(pick: Pick, +req: Http.Request) -> IO(Http.Response):  match pick:    case Hit{handler, params}:      handler(Http.Request.with_params(req, params))    case Fallback{handler, params, allowed}:      handler(Http.Request.with_params(req, params))    case Miss{+allowed}:      IO.pure(Http.Response, miss(allowed, Http.Method.is_eq(Http.Request.method(req), Http.OPTIONS{})))def describe.miss(+allowed: Map<&2, String>, none: Bool) -> String:  match none:    case True{}:      "none"    case False{}:      "miss: " ++ render_allow(allowed)# What a pick would do, in a word; for laws. A miss lists its `Allow`.def describe(pick: Pick) -> String:  match pick:    case Hit{handler, params}:      "hit"    case Fallback{handler, params, allowed}:      "fallback"    case Miss{+allowed}:      describe.miss(allowed, List.is_empty(&2, String, Map.keys(&2, String, allowed)))def dispatch.go(+policy: Slash, routes: List<Route>, +req: Http.Request, redirect: Bool) -> IO(Http.Response):  match redirect:    case True{}:      IO.pure(Http.Response,        Http.Response.with_header(Http.Response.empty(308), "location", redirect_target(Http.Request.target(req))))    case False{}:      run(pick(policy, routes, req), req)def is_redirect(policy: Slash) -> Bool:  match policy:    case Redirect{}:      True{}    case Ignore{}:      False{}    case Strict{}:      False{}# Runs the first route whose method and path match the request under the# slash policy. HEAD runs the GET route when no HEAD route matches. A path# that matches only under other methods answers 405 with `Allow`, or 204# with `Allow` to OPTIONS; no match answers 404.def dispatch_with(+policy: Slash, routes: List<Route>, +req: Http.Request) -> IO(Http.Response):  dispatch.go(policy, routes, req, Bool.and(is_redirect(policy), has_slash(Http.Request.path(req))))# `dispatch_with` under the default policy: a trailing slash is ignored.def dispatch(routes: List<Route>, +req: Http.Request) -> IO(Http.Response):  dispatch_with(Ignore{}, routes, req)