Core Concepts & Behavior

Understanding how Fist processes requests, executes guards, and generates reverse paths will help you design predictable, secure, and high-performance APIs.


1. The Radix Trie Engine

At its core, Fist uses a Radix Trie (Prefix Tree) to store and match route patterns:


2. Path Normalization & Defensive Security (RFC 3986)

Before traversing the Trie, Fist sanitizes and canonicalizes incoming paths according to RFC 3986 Section 5.2.4:


3. Precedence & Priority

When an incoming URL could theoretically match multiple registered routes, Fist resolves conflicts using a deterministic 4-tier hierarchy:

Precedence Hierarchy:

Static  >  Guarded Dynamic  >  Unguarded Dynamic  >  Wildcard (*param)

Deterministic Sibling Resolution

When a node contains multiple dynamic children (e.g. :id with an integer guard alongside generic :username):

  1. Guards First: Children equipped with guard predicates (fist.guard) take precedence over unguarded children.
  2. Order of Declaration: Among children with equal guard status, evaluation proceeds in the order they were registered.
  3. Dynamic Fallthrough (Backtracking): If an incoming request matches a dynamic segment token, but that token fails the branch’s guard predicate, Fist does not fail with 404 immediately. It seamlessly falls through to the next candidate sibling branch.

Step-by-Step Traversal Example

Consider a router configured with these routes:

fist.new()
|> fist.get("/posts/new", new_post_handler)                  // Tier 1: Static
|> fist.get("/posts/:id", show_post_by_id)                   // Tier 2: Guarded Dynamic
|> fist.guard("id", when: extract.is_int)
|> fist.get("/posts/:slug", show_post_by_slug)               // Tier 3: Unguarded Dynamic
|> fist.get("/posts/*rest", catch_all_handler)               // Tier 4: Wildcard

Fist resolves requests as follows:

  1. Request GET /posts/new:

    • Matches the exact static segment /new. Handled by new_post_handler.
  2. Request GET /posts/42:

    • /42 does not match static /new.
    • Evaluates guarded branch :id. extract.is_int("42") returns True. Handled by show_post_by_id with params = [#("id", "42")].
  3. Request GET /posts/announcements:

    • /announcements does not match static /new.
    • Evaluates guarded branch :id. extract.is_int("announcements") returns False.
    • Fallthrough: Fist falls through to the unguarded :slug branch. Handled by show_post_by_slug with params = [#("slug", "announcements")].
  4. Request GET /posts/2026/archive:

    • Multi-segment path. Neither /posts/new, /posts/:id, nor /posts/:slug can consume two segments.
    • Matches the wildcard catch-all *rest. Handled by catch_all_handler with params = [#("rest", "2026/archive")].
  5. Ancestor Wildcard Backtracking:

    • Suppose a request arrives for GET /posts/new/download.
    • The engine follows the static branch to /posts/new, but finds no child segment named /download.
    • Instead of failing with 404, Fist backtracks up the tree to the /posts node and checks for an ancestor wildcard catch-all.
    • It successfully delegates the request to *rest with rest = "new/download".

4. Route Guards & Fallthrough Engine

Route guards are pure Gleam predicate functions fn(String) -> Bool evaluated against raw parameter tokens during path matching.


5. Reverse Routing & Bidirectional Soundness

Reverse routing translates semantic route identifiers and parameters back into canonical URL paths via fist.path or fist.path_from.

Bidirectional Soundness Invariant

A web router should never generate a URL that its own routing engine would reject or misroute. Fist enforces Bidirectional Soundness:

Parameter Encoding & Path Injection Defense

Query Parameter Auto-Serialization

Any parameters passed in the with argument that are not consumed by path segments (:param or *param) are automatically formatted as standard URL query parameters (RFC 3986):

fist.path(router, for: "search", with: [
  #("category", "books"),
  #("q", "gleam"),
  #("page", "2"),
])
// Yields: Ok("/search/books?q=gleam&page=2")

6. Route Names, Collisions & Idempotency Rules

Fist enforces strict rules around route naming to eliminate ambiguity:

1. Route Name Collisions on Conflicting Paths

If two different path patterns are assigned the same name, Fist panics immediately at startup:

// 💥 Panics: Route name collision: route 'profile' is already registered to '/users/:id', cannot redefine as '/orgs/:id'
router
|> fist.get("/users/:id", get_user)
|> fist.name("profile")
|> fist.get("/orgs/:id", get_org)
|> fist.name("profile")

2. Idempotent Name Sharing Across HTTP Methods

In RESTful architectures, multiple HTTP methods often share the exact same resource path (e.g. GET /users/:id and PUT /users/:id).

3. Route Aliases

Assigning multiple names to the same route is fully supported:

router
|> fist.get("/members/:id", show_member)
|> fist.name("member_show")
|> fist.name("user_show")

Both member_show and user_show resolve to /members/:id.

4. Mount and Merge Collision Detection


7. Middleware Execution & Static Wrapping

Middlewares in Fist use Static Wrapping rather than dynamic runtime dispatch:


8. Common Pitfalls & How Fist Reacts

Here is how Fist defends against common mistakes and edge-case errors:

Mistake / Edge CaseFist BehaviorHow to Resolve
Orphan fist.guard call (calling guard without preceding route)💥 Immediate panic at startup: Invalid guard: fist.guard must be called immediately after registering a routePlace fist.guard immediately after the route definition.
Unknown parameter in guard (guarding a param name that isn’t in the path)💥 Immediate panic: Invalid guard: parameter ':uuid' not found in the last added route pathEnsure the parameter name in fist.guard matches the :param token.
Empty route name (fist.name("") or fist.name(" "))💥 Immediate panic: Invalid route name: route name cannot be emptyProvide a meaningful non-empty string identifier.
Orphan fist.name call (calling name without preceding route)💥 Immediate panic: Invalid route name: fist.name must be called immediately after registering a routePlace fist.name immediately after the route definition.
Incomplete Registry Extraction (calling fist.path_registry(sub) before fist.mount)Generates paths without the mount prefix (e.g. /items/42 instead of /api/v1/items/42).Golden Rule: Always extract fist.path_registry(root_router) from your final root router after all mount and merge operations are complete.
Empty dynamic parameter value (with: [#("id", "")])Returns Error(InvalidParameter("route", "id", "")). Prevents generating broken URLs.Ensure IDs and tokens are non-empty before calling fist.path.
Dot-segment traversal in parameters (with: [#("id", "../../admin")])Returns Error(InvalidParameter(route, param, value)). Prevents path traversal escape in generated URLs.Provide valid segment values without .. or . traversals.
Attempted Path Injection (with: [#("id", "1/delete")])Encodes / to %2F (/users/1%2Fdelete). Does not alter route structure.Handled automatically. The URL remains safe and predictable.
Wildcard in mount prefix (fist.mount(r, "/api/*rest", ...))💥 Immediate panic: Wildcards cannot appear in mount prefixes.Mount prefixes must only contain static or dynamic segments.
Conflicting dynamic names at same level without guards💥 Immediate panic at startup: Prevents registering ambiguous dynamic branches without disambiguating guards.Add route guards (e.g. is_int) or use consistent parameter names.
✨ Search Document