SagaSaga
Edda

Routing

Edda's router is a handful of combinators that compose ordinary functions into an app. Each route is a Request -> Response function that either produces a response or signals "I don't match — try the next one."

The shape of a route

A route is just a function:

fun show_user : Request -> Response
show_user req = text 200 "ok"

Wrapped with route, it becomes a matcher:

route GET "/users/:id" show_user

route returns a function that runs the handler if the method and path pattern match, and skip!s otherwise. The Skip effect is what the router uses to walk past non-matching routes; choose discharges it.

choose

fun app : Request -> Response
app req = choose [
  route GET  "/",         home,
  route GET  "/users",    list_users,
  route POST "/users",    create_user,
  route GET  "/users/:id", show_user,
] req

choose tries each route in order. The first one that doesn't skip! wins. If all of them skip, you get the default not_found (404).

Routes can declare effects they need. The router lets each route declare its own set — they don't have to agree:

choose [
  route GET  "/health",  health,       # pure
  route GET  "/me",      me,           # needs {Auth}
  route GET  "/items/:id", show_item,  # needs {Db, Log}
]

choose widens the effect row across the list, so the surrounding context has to discharge the union of everything any route inside it needs. See the middleware guide for how to install those handlers.

Path parameters

Segments starting with : capture into the request's params dict:

route GET "/users/:id/posts/:post_id" show_post

fun show_post : Request -> Response
show_post req = case (param "id" req, param "post_id" req) {
  (Just uid, Just pid) -> text 200 $"user {uid}, post {pid}"
  _ -> text 400 "missing params"
}

param is String -> Request -> Maybe String. Captured values are strings; convert as needed at the call site.

group: inline nesting

group matches a path prefix and runs inner routes against the remainder. The sub-routes share the parent's effect row, so this is the right tool when you want to factor out a common URL prefix without changing how handlers are wired.

choose [
  group "/users" [
    route GET  "/",      list_users,
    route POST "/",      create_user,
    route GET  "/:id",   show_user,
  ],
]

Captured params accumulate across nesting, so:

group "/users/:id" [
  route GET "/posts", list_user_posts,   # can read `id`
]

works as expected.

mount: attach a sub-app

mount attaches an already-pure Request -> Response at a prefix. Use it when a sub-app has its own effect stack that should stay encapsulated — typically because it lives in its own module and discharges its own handlers internally.

mount "/admin" admin_app
mount "/api"   api_app

A mounted sub-app sees the path with the prefix stripped. Inside admin_app, requests to /admin/users arrive with req.path = "/users". The unmodified path stays available as req.original_path for things like logging.

When to use which

  • group when sub-routes need the parent's effects and should stay in the same file.
  • mount when a sub-app is its own thing — different effect stack, its own module, its own tests. Real-world example: a public API and an admin API that need different DB pools.

Where the prefix goes

group and mount both strip the matched prefix from req.path before handing the request down. Sub-app authors think in relative paths.

FieldWhat it has
req.pathThe path the current matcher sees (stripped).
req.original_pathThe full path as it came in off the wire.

Fallthrough and not_found

If nothing in a choose matches, the request falls through to Edda's not_found (a 404 with body "not found"). To customize, add a catch-all at the bottom:

choose [
  route GET "/health", health,
  ...
  fun req -> text 404 $"no route for {method_str req.method} {req.original_path}",
]

A plain lambda is a perfectly valid route — it doesn't have a method or pattern, so it always matches. The router treats Request -> Response as the most general possible route signature.