Build it yourself

URL router with path parameters

hardRouting and distribution

Problem statement

Build the request router at the heart of an HTTP framework or API gateway: register routes like GET /users/:id, then map an incoming method and path to a handler and its extracted parameters.

API (Java: Router<H>, match returns a Match<H> with status, handler, params)

◈ DIAGRAM
Router()
add(method: str, pattern: str, handler) -> None
match(method: str, path: str) -> (status, handler, params)

Pattern syntax

  • A literal segment such as users matches only itself.
  • :name matches any single segment and captures it as params["name"].
  • *name matches one or more remaining segments, joined by /. It must be the last segment of the pattern.

Rules

  • Paths are split on /, ignoring empty segments, so /users/42/ and /users//42 both mean users, 42.
  • Precedence at each segment: literal first, then :param, then *wildcard. If the preferred branch fails deeper in the path, backtrack and try the next one. With /files/latest and /files/:name/raw registered, /files/latest/raw must reach the param route.
  • match returns (200, handler, params) on success. If some route matches the path but not the method, return (405, None, {}). If no route matches the path at all, return (404, None, {}).
  • Methods are case-insensitive.
  • add raises ValueError for a duplicate method and pattern, for a wildcard that is not last, and for two different parameter names at the same position (/files/:name/raw plus /files/:id/meta), since that would make params ambiguous.

Outputs show status handler params for 200 responses and the bare status otherwise.

Examples

Example 1

Input: add GET /users list_users add GET /users/:id get_user add GET /users/me get_me add DELETE /users/:id delete_user add GET /users/:id/posts/:post_id get_post add GET /static/*path static_files match GET /users/42 match GET /users/me match GET /users/42/posts/7/ match GET /static/css/site.css match POST /users/42 match GET /admin match GET /users

Output: 200 get_user {id=42} 200 get_me {} 200 get_post {id=42, post_id=7} 200 static_files {path=css/site.css} 405 404 200 list_users {}

Explanation: The literal "me" beats ":id". /users/42 exists for GET and DELETE, so POST gets 405, not 404.

Example 2

Input: add GET /files/latest latest_file add GET /files/:name/raw raw_file match GET /files/latest/raw match GET /files/latest add GET /files/:id/meta file_meta add GET /files/latest other

Output: 200 raw_file {name=latest} 200 latest_file {} error: conflicting parameter names :name and :id error: duplicate route GET /files/latest

Explanation: The literal branch "latest" has no "raw" child, so the router backtracks into ":name".

Hints

Approach

Optimal

Build a segment trie. Each node has static (literal segment to child), param (name and child), wildcard (name and a method-to-handler map), and handlers (method to handler for routes ending exactly at this node).

add: walk the pattern's segments, creating nodes as needed, checking for conflicting param names, and requiring * to be last. Store the handler under the method in the final node's handlers (or the wildcard's map), and reject duplicates.

match: a depth-first search over (node, segment index, params):

  1. At the end of the path, the candidate is node.handlers.
  2. Otherwise try the literal child for this segment, then the param child with the segment captured, then the wildcard, which consumes the rest of the path.
  3. The Python version is a generator yielding candidates in precedence order. match takes the first candidate that has the method. If candidates existed but none had the method, the answer is 405. If there were no candidates, 404.

Params are copied per branch ({**params, name: value}), so a failed branch cannot leak captures into the one that succeeds. Backtracking only happens when a literal and a param both exist at the same level, so typical lookups touch one node per segment.

ComplexityTime O(segments) typical; worse only with deep literal/param ambiguitySpace O(total pattern segments)
Python
class _Node:
def __init__(self):
self.static = {} # literal segment -> _Node
self.param = None # (name, _Node) for ":name"
self.wildcard = None # (name, {method: handler}) for "*name"
self.handlers = {} # method -> handler for a route ending here
def _split(path):
return [s for s in path.split("/") if s] # "/a//b/" -> ["a", "b"]
class Router:
def __init__(self):
self.root = _Node()
def add(self, method, pattern, handler):
method = method.upper()
node, segs = self.root, _split(pattern)
handlers = None
for i, seg in enumerate(segs):
if seg.startswith("*"):
if i != len(segs) - 1:
raise ValueError("wildcard must be the last segment")
if node.wildcard is None:
node.wildcard = (seg[1:], {})
elif node.wildcard[0] != seg[1:]:
raise ValueError(f"conflicting wildcard names *{node.wildcard[0]} and {seg}")
handlers = node.wildcard[1]
elif seg.startswith(":"):
if node.param is None:
node.param = (seg[1:], _Node())
elif node.param[0] != seg[1:]:
raise ValueError(f"conflicting parameter names :{node.param[0]} and {seg}")
node = node.param[1]
else:
node = node.static.setdefault(seg, _Node())
if handlers is None:
handlers = node.handlers
if method in handlers:
raise ValueError(f"duplicate route {method} {pattern}")
handlers[method] = handler
def _walk(self, node, segs, i, params):
"""Yield (handlers, params) for every route matching the path, best first."""
if i == len(segs):
if node.handlers:
yield node.handlers, params
else:
child = node.static.get(segs[i])
if child is not None: # 1. literal segment
yield from self._walk(child, segs, i + 1, params)
if node.param is not None: # 2. :param, then backtrack if needed
name, nxt = node.param
yield from self._walk(nxt, segs, i + 1, {**params, name: segs[i]})
if node.wildcard is not None: # 3. *rest (at least one segment)
name, handlers = node.wildcard
yield handlers, {**params, name: "/".join(segs[i:])}
def match(self, method, path):
"""Return (status, handler, params): 200, 404 (no route) or 405 (wrong method)."""
method, path_exists = method.upper(), False
for handlers, params in self._walk(self.root, _split(path), 0, {}):
if method in handlers:
return 200, handlers[method], params
path_exists = True
return (405 if path_exists else 404), None, {}

Follow-up questions

  • Include an Allow header listing the valid methods in the 405 response.
  • Add typed params (:id<int>) that only match digits.
  • Support hot-reloading routes while requests are being served. How do you swap the tree safely?

Frequently asked questions

It works, and some small frameworks do it, but matching is O(number of routes) per request, and the result depends on registration order rather than on specificity. A trie makes precedence a property of the structure (literal beats param beats wildcard) and turns lookup into a walk proportional to path length.

Go 1.22's net/http.ServeMux supports patterns like GET /users/{id} and {path...}, with most-specific-wins precedence, and routers like httprouter and chi use radix trees, which are tries with compressed single-child chains. A hand-written version is the same node struct with map[string]*node, a param child and a wildcard child. Build the tree at startup and treat it as read-only afterwards, so request goroutines can match concurrently without locks.