URL router with path parameters
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)
Router()add(method: str, pattern: str, handler) -> Nonematch(method: str, path: str) -> (status, handler, params)Pattern syntax
- A literal segment such as
usersmatches only itself. :namematches any single segment and captures it asparams["name"].*namematches 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//42both meanusers, 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/latestand/files/:name/rawregistered,/files/latest/rawmust reach the param route. matchreturns(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.
addraisesValueErrorfor a duplicate method and pattern, for a wildcard that is not last, and for two different parameter names at the same position (/files/:name/rawplus/files/:id/meta), since that would makeparamsambiguous.
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):
- At the end of the path, the candidate is
node.handlers. - 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.
- The Python version is a generator yielding candidates in precedence order.
matchtakes 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.
O(segments) typical; worse only with deep literal/param ambiguitySpace O(total pattern segments)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
Allowheader 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.