Skip to content

Commit 0190aef

Browse files
committed
fix(httpx): wildcard route params keep gin's leading slash on all backends
After the seam migration c.Param("rest") for a *rest route returned "app.js" on stdlib (ServeMux drops the slash) and "" on chi (capture stored under "*"), where gin gave "/app.js". The dashboard builds "assets"+c.Param("filepath"), so its assets 404'd and JS modules were served with an empty MIME type and blocked by the browser. Normalize the wildcard capture to gin's leading-slash form (new httpx.WildcardName helper) so c.Param is identical on gin/chi/stdlib.
1 parent 3411324 commit 0190aef

6 files changed

Lines changed: 134 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
## [1.20.4] - 2026-06-19
10+
11+
### Fixed — wildcard route params now match gin's convention on every backend
12+
13+
- **`c.Param("rest")` for a `*rest` route again returns a leading-slash suffix
14+
on the stdlib and chi backends.** gin exposes a `*filepath` capture as
15+
`/app.js` (leading slash); after the router-seam migration the stdlib backend
16+
returned `app.js` (ServeMux's `{rest...}` drops the slash) and the chi backend
17+
returned `""` (chi stores the capture under the key `*`, so the original name
18+
missed entirely). Handlers that build a path from the capture — notably the
19+
dashboard's `"assets" + c.Param("filepath")` — resolved to `assetsapp.js` /
20+
`assets`, 404'd, and served assets with an **empty MIME type**, so browsers
21+
blocked the dashboard's own JS module (`/__nexus/assets/index-*.js`). The
22+
seam now normalizes the wildcard capture to gin's leading-slash form via the
23+
new `httpx.WildcardName` helper, so `c.Param` behaves identically on gin,
24+
chi, and stdlib. Named (`:id`) params are unaffected.
25+
926
## [1.20.3] - 2026-06-19
1027

1128
### Fixed — `stdrouter` treated `GET /` as a catch-all, swallowing assets

httpx/chirouter/chi.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ func New() *Router { return &Router{mux: chi.NewRouter()} }
2424
func (r *Router) Handle(method, path string, chain ...httpx.HandlerFunc) {
2525
r.routes = append(r.routes, httpx.RouteInfo{Method: method, Path: path})
2626
full := append([]httpx.HandlerFunc{}, chain...)
27+
wild := httpx.WildcardName(path)
2728
r.mux.Method(method, toChi(path), http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
28-
httpx.Serve(full, w, req, path, func(k string) string { return chi.URLParam(req, k) })
29+
httpx.Serve(full, w, req, path, paramFn(req, wild))
2930
}))
3031
}
3132

@@ -42,11 +43,26 @@ func (r *Router) HEAD(path string, chain ...httpx.HandlerFunc) { r.Handle("HEAD"
4243
func (r *Router) Any(path string, chain ...httpx.HandlerFunc) {
4344
r.routes = append(r.routes, httpx.RouteInfo{Method: "ANY", Path: path})
4445
full := append([]httpx.HandlerFunc{}, chain...)
46+
wild := httpx.WildcardName(path)
4547
r.mux.HandleFunc(toChi(path), func(w http.ResponseWriter, req *http.Request) {
46-
httpx.Serve(full, w, req, path, func(k string) string { return chi.URLParam(req, k) })
48+
httpx.Serve(full, w, req, path, paramFn(req, wild))
4749
})
4850
}
4951

52+
// paramFn returns a path-param lookup that matches gin's convention. chi rewrites
53+
// "*rest" to its "*" catch-all and stores the suffix under the key "*" without a
54+
// leading slash; gin exposes it under the original name WITH a leading slash
55+
// ("/app.js"). Map the route's wildcard name onto chi's "*" key and re-add the
56+
// slash so c.Param("rest") behaves identically on every backend.
57+
func paramFn(req *http.Request, wild string) func(string) string {
58+
return func(k string) string {
59+
if wild != "" && k == wild {
60+
return "/" + chi.URLParam(req, "*")
61+
}
62+
return chi.URLParam(req, k)
63+
}
64+
}
65+
5066
func (r *Router) Use(mw ...httpx.HandlerFunc) { r.global = append(r.global, mw...) }
5167

5268
func (r *Router) Group(prefix string, mw ...httpx.HandlerFunc) httpx.Group {

httpx/chirouter/chi_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package chirouter
2+
3+
import (
4+
"net/http/httptest"
5+
"testing"
6+
7+
"github.com/paulmanoni/nexus/httpx"
8+
)
9+
10+
// chi stores the catch-all under the key "*"; the seam must expose it under the
11+
// route's wildcard name WITH gin's leading slash, so c.Param("filepath") on
12+
// /assets/*filepath returns "/index.js" (not "" and not "index.js").
13+
func TestWildcardParamGinCompatible(t *testing.T) {
14+
r := New()
15+
var named, suffix string
16+
r.Handle("GET", "/assets/*filepath", func(c *httpx.Ctx) {
17+
named = c.Param("filepath")
18+
suffix = "assets" + named
19+
c.String(200, suffix)
20+
})
21+
req := httptest.NewRequest("GET", "/assets/index-Cv7AL3WY.js", nil)
22+
r.ServeHTTP(httptest.NewRecorder(), req)
23+
if named != "/index-Cv7AL3WY.js" {
24+
t.Fatalf(`c.Param("filepath") = %q, want /index-Cv7AL3WY.js`, named)
25+
}
26+
if suffix != "assets/index-Cv7AL3WY.js" {
27+
t.Fatalf("joined path = %q, want assets/index-Cv7AL3WY.js", suffix)
28+
}
29+
}
30+
31+
// Named (non-wildcard) params still resolve normally.
32+
func TestNamedParam(t *testing.T) {
33+
r := New()
34+
var id string
35+
r.Handle("GET", "/users/:id", func(c *httpx.Ctx) {
36+
id = c.Param("id")
37+
c.String(200, id)
38+
})
39+
req := httptest.NewRequest("GET", "/users/42", nil)
40+
r.ServeHTTP(httptest.NewRecorder(), req)
41+
if id != "42" {
42+
t.Fatalf(`c.Param("id") = %q, want 42`, id)
43+
}
44+
}

httpx/httpx.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ func (c *Ctx) Param(key string) string {
223223
return c.param(key)
224224
}
225225

226+
// WildcardName returns the name of the trailing wildcard segment in a canonical
227+
// route path ("/assets/*filepath" -> "filepath"), or "" when the path has no
228+
// wildcard. Router adapters use it to normalize the wildcard param value to
229+
// gin's convention — a leading-slash suffix (gin's c.Param("filepath") for
230+
// /assets/app.js is "/app.js") — on every backend, so handlers that build paths
231+
// like "assets"+c.Param("filepath") behave identically on gin, chi, and stdlib.
232+
func WildcardName(path string) string {
233+
for _, seg := range strings.Split(path, "/") {
234+
if strings.HasPrefix(seg, "*") {
235+
return seg[1:]
236+
}
237+
}
238+
return ""
239+
}
240+
226241
func (c *Ctx) Query(key string) string { return c.Request.URL.Query().Get(key) }
227242

228243
func (c *Ctx) DefaultQuery(key, def string) string {

httpx/stdrouter/std.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ func New() *Router { return &Router{mux: http.NewServeMux()} }
2323
func (r *Router) Handle(method, path string, chain ...httpx.HandlerFunc) {
2424
r.routes = append(r.routes, httpx.RouteInfo{Method: method, Path: path})
2525
full := append([]httpx.HandlerFunc{}, chain...)
26+
wild := httpx.WildcardName(path)
2627
r.mux.HandleFunc(method+" "+toStd(path), func(w http.ResponseWriter, req *http.Request) {
27-
httpx.Serve(full, w, req, path, req.PathValue)
28+
httpx.Serve(full, w, req, path, paramFn(req, wild))
2829
})
2930
}
3031

@@ -44,11 +45,29 @@ func (r *Router) Any(path string, chain ...httpx.HandlerFunc) {
4445
// matching HEAD, so an explicit "GET /x" + "HEAD /x" pair conflicts.
4546
r.routes = append(r.routes, httpx.RouteInfo{Method: "ANY", Path: path})
4647
full := append([]httpx.HandlerFunc{}, chain...)
48+
wild := httpx.WildcardName(path)
4749
r.mux.HandleFunc(toStd(path), func(w http.ResponseWriter, req *http.Request) {
48-
httpx.Serve(full, w, req, path, req.PathValue)
50+
httpx.Serve(full, w, req, path, paramFn(req, wild))
4951
})
5052
}
5153

54+
// paramFn returns a path-param lookup that matches gin's convention. ServeMux's
55+
// "{rest...}" wildcard yields the matched suffix WITHOUT a leading slash
56+
// ("app.js"); gin's "*rest" includes it ("/app.js"). For the wildcard param we
57+
// re-add the slash so handlers that do "assets"+c.Param("rest") work the same on
58+
// every backend. Non-wildcard params pass straight through.
59+
func paramFn(req *http.Request, wild string) func(string) string {
60+
if wild == "" {
61+
return req.PathValue
62+
}
63+
return func(k string) string {
64+
if k == wild {
65+
return "/" + req.PathValue(k)
66+
}
67+
return req.PathValue(k)
68+
}
69+
}
70+
5271
func (r *Router) Use(mw ...httpx.HandlerFunc) { r.global = append(r.global, mw...) }
5372

5473
func (r *Router) Group(prefix string, mw ...httpx.HandlerFunc) httpx.Group {

httpx/stdrouter/std_test.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ func TestTrailingSlashExactVsWildcard(t *testing.T) {
4646

4747
cases := map[string]string{
4848
"/admin/": "admin-root",
49-
"/admin/users": "miss", // exact: sub-path is NOT swallowed
50-
"/files/a/b.js": "file:a/b.js", // ServeMux {rest...} drops the leading slash
49+
"/admin/users": "miss", // exact: sub-path is NOT swallowed
50+
"/files/a/b.js": "file:/a/b.js", // wildcard param keeps gin's leading slash
5151
}
5252
for path, want := range cases {
5353
req := httptest.NewRequest("GET", path, nil)
@@ -59,6 +59,23 @@ func TestTrailingSlashExactVsWildcard(t *testing.T) {
5959
}
6060
}
6161

62+
// The wildcard param must carry gin's leading slash so the dashboard's
63+
// `name := "assets" + c.Param("filepath")` resolves to "assets/index.js", not
64+
// "assetsindex.js" (which 404s and serves the JS with an empty MIME type).
65+
func TestWildcardParamLeadingSlash(t *testing.T) {
66+
r := New()
67+
var got string
68+
r.Handle("GET", "/assets/*filepath", func(c *httpx.Ctx) {
69+
got = "assets" + c.Param("filepath")
70+
c.String(200, got)
71+
})
72+
req := httptest.NewRequest("GET", "/assets/index-Cv7AL3WY.js", nil)
73+
r.ServeHTTP(httptest.NewRecorder(), req)
74+
if got != "assets/index-Cv7AL3WY.js" {
75+
t.Fatalf(`c.Param("filepath") path = %q, want assets/index-Cv7AL3WY.js`, got)
76+
}
77+
}
78+
6279
// Static must coexist with a catch-all "GET /" route. A method-less static
6380
// registration ("/media/") panics against "GET /" under Go 1.22's ServeMux
6481
// (more specific path but more methods → ambiguous). Scoping Static to GET

0 commit comments

Comments
 (0)