-
Notifications
You must be signed in to change notification settings - Fork 10
Proposal: Allow Static and Param Wildcard Siblings at the Same Path Level
This proposal extends the radix tree router to support registering both static child routes and a :param wildcard route under the same parent node, by enforcing a priority ordering at insertion time: static children are always placed before the param child in n.children. The existing match-first-wins traversal logic in Route and FindCaseInsensitivePath then requires no changes — static routes naturally win because they appear first.
Currently, registering the following three routes (in any order) panics:
/dev/document-api/apiReference/model/videoModels
/dev/document-api/apiReference/model/imageModels
/dev/document-api/apiReference/model/:model
panic: wildcard segment ':model' conflicts with existing children
in path '/dev/document-api/apiReference/model/:model'
The restriction is overly conservative. Static segments are unambiguous — a request for /model/videoModels can only match the literal videoModels child or fall through to :model; there is no ambiguity. The natural precedence rule static beats wildcard resolves every lookup deterministically, without any change to traversal logic.
Go's http.ServeMux, chi, gin, and echo all permit this pattern. Users migrating from those routers reasonably expect the same behaviour here.
The existing Route walk already iterates n.children in order and returns on the first match. The only thing needed to implement static-over-param priority is to guarantee that static children are inserted before the param child.
This means:
-
Route,Route[T], andfindCaseInsensitivePathRecare unchanged. -
incrementChildPrioreordering is unchanged (it only reorders within the static-index slice, see §Changes). - The diff is confined to
insertChildand one guard inAddRoute.
| Request path | Matched route |
|---|---|
/model/videoModels |
/model/videoModels (static, exact) |
/model/imageModels |
/model/imageModels (static, exact) |
/model/textModels |
/model/:model, model=textModels (param) |
/model/anything |
/model/:model, model=anything (param) |
Priority rule (no ambiguity, resolved purely by children ordering):
static > param > catchAll
catchAll siblings remain forbidden: a catch-all consumes the entire remainder of the path, making any sibling permanently unreachable regardless of ordering.
parent node (path = "/model/")
├── children[0] static "videoModels" ← static children first, ordered by priority
├── children[1] static "imageModels"
└── children[2] param ":model" ← param child always last
n.indices continues to index only static children (as it does today). The param child sits at children[len(static_children)] and is not represented in n.indices. The traversal loop for i, c := range []byte(n.indices) therefore never accidentally matches the param child — it is tried separately only after all static children miss, exactly as the current n.wildChild branch does today.
wildChild bool retains its current meaning: "the last child is a wildcard (param or catchAll)". The only difference is that there may now be static children in front of it.
// Before
if len(n.children) > 0 {
panic("wildcard segment '" + wildcard +
"' conflicts with existing children in path '" + fullPath + "'")
}
// After
if len(n.children) > 0 {
// A :param wildcard may coexist with existing static children.
// catchAll is still forbidden: it would shadow every sibling.
if wildcard[0] != ':' {
panic("wildcard segment '" + wildcard +
"' conflicts with existing children in path '" + fullPath + "'")
}
// A second param wildcard at the same level is also forbidden.
for _, child := range n.children {
if child.nType == param {
panic("a param wildcard is already registered for path '" + fullPath + "'")
}
}
// Existing static children stay in place; the param child is appended last.
// n.wildChild = true is set below, as before.
}The param child is appended to n.children after all existing static children, so children[len(static)-1] is the param node. n.wildChild is set to true as it is today.
In the walk loop, when n.wildChild is true the current code immediately descends into n.children[0]. With mixed children this must only happen when the incoming segment is itself a wildcard. For static incoming segments, fall through to the normal index-scan / new-child insertion path:
// Before
if n.wildChild {
n = n.children[0]
n.priority++
// … wildcard conflict checks …
continue walk
}
// After
if n.wildChild {
idxc := path[0]
if idxc == ':' || idxc == '*' {
// Incoming segment is a wildcard — descend into the existing wildcard child.
n = n.children[len(n.children)-1] // param child is always last
n.priority++
// … existing wildcard conflict checks unchanged …
continue walk
}
// Incoming segment is static — fall through to the index scan below.
// The static child will be inserted before the param child (see step 4).
}When a new static child is appended to n.children and n.wildChild is true, the param child must be moved to remain last:
// Before (simplified)
n.indices += string([]byte{idxc})
child := &Node[H]{}
n.children = append(n.children, child)
n.incrementChildPrio(len(n.indices) - 1)
n = child
// After
n.indices += string([]byte{idxc})
child := &Node[H]{}
if n.wildChild {
// Insert the new static child before the param child to maintain ordering.
// n.children layout: [...static..., param]
// After insert: [...static..., newStatic, param]
// then swap newStatic and param so param stays last.
n.children = append(n.children, child) // append placeholder
last := len(n.children) - 1
n.children[last], n.children[last-1] = // swap: static before param
n.children[last-1], n.children[last]
n.incrementChildPrio(last - 1)
n = child
} else {
n.children = append(n.children, child)
n.incrementChildPrio(len(n.indices) - 1)
n = child
}incrementChildPrio reorders static children among themselves by priority but will never bubble a static child past the param child, because the param child has no entry in n.indices and the reorder loop is bounded by n.indices length.
The existing code already:
- Scans
n.indicesfor a static match and descends if found. - Falls through to the
n.wildChildbranch (which readsn.children[0]) if no static match.
After this proposal n.children[0] is no longer guaranteed to be the param child — static children come first. The n.wildChild branch must instead read the last child:
// Before (in Route walk and findCaseInsensitivePathRec)
n = n.children[0]
// After
n = n.children[len(n.children)-1]This is the only change required in the lookup path, and it is a one-liner. All switch/case logic for param and catchAll node types is unchanged.
| Location | Change |
|---|---|
insertChild |
Relax children-conflict guard; allow :param alongside static children; append param child last |
AddRoute (walk) |
When n.wildChild && path[0] is static, skip the wildcard-descent branch |
AddRoute (child insertion) |
When n.wildChild, insert new static child before param child and swap |
Route / Route[T]
|
n.children[0] → n.children[len(n.children)-1] in the wildChild branch |
findCaseInsensitivePathRec |
Same one-liner change as above |
All other logic — priority reordering, TSR detection, catchAll handling, case-insensitive recursion — is untouched.
-
CatchAll (
*name) siblings remain forbidden. - Multiple param wildcards at the same level remain forbidden.
-
Param vs param name mismatch (e.g.
:idand:nameas siblings) remains forbidden.
Fully backward compatible. All previously valid route sets are unaffected. The previously forbidden pattern of mixing static children with a :param sibling becomes valid and deterministic.
var root Node[http.Handler]
root.AddRoute("/model/videoModels", videoHandler)
root.AddRoute("/model/imageModels", imageHandler)
root.AddRoute("/model/:model", genericHandler) // no longer panics
h, _, _ := root.Route("/model/videoModels") // → videoHandler
h, _, _ = root.Route("/model/imageModels") // → imageHandler
h, _, _ = root.Route("/model/textModels") // → genericHandler, model="textModels"-
httprouterissue #329 — upstream discussion on the same limitation - Go
net/httpServeMux routing precedence — longer/more-specific patterns win -
chirouter — static segments evaluated before wildcards by construction