The Handler Received %2C: chi Does Not Decode Path Params
A slug with a comma returns 404 while the data exists: chi keeps path params encoded and decoding is the API owner's contract.
TL;DR
Special characters in slug path params broke lookups because chi stores route params in encoded form, so handlers queried the DB with %2C and got empty results. Fix: a DecodePathParams middleware wired with r.With runs after routing, once, with strict rules. Handlers stay clean and the failing slugs now return 200.
A curl to the entity detail endpoint came back 404. The odd part: the slug was copied exactly from a table row in the KotaPortal database. No typo, no missing character. The pattern repeated itself too. Every slug containing a comma or a curly apostrophe failed the same way, while plain slugs without special characters were all alive.
The first suspicion went to two places: an old data import that supposedly left corrupted slugs behind, or double-encoding on the client side. Both were wrong. What pointed at the real cause was the nature of the 404 itself: the router found the route, the handler ran, and only the database lookup came back empty. If the route did not exist, the 404 would have come from the router, not from the handler. Reading the source of chi v5.1.0, the Go router this API uses, the pattern became clear: chi matches routes on the encoded form of the path, which is r.URL.RawPath whenever that field is set. The route parameters themselves are only poured into the request context after the route search finishes [2]. As a result the handler received the comma as %2C and the apostrophe as %E2%80%99, verbatim. The database query used that encoded string, so it obviously missed.
Decode at the API Boundary, Not in Handlers
The router will not change, and copying decode logic into every handler means maintaining the same contract in a dozen places. The decoding belongs in one spot: the DecodePathParams middleware on the main API subrouter, wired with r.With. This choice is not a matter of taste. In the chi source, middlewares registered through Use execute before the router searches for a route, when the route parameters are still empty. Registering through r.With, on the other hand, bakes the middleware into each matched endpoint, so it runs after matching and before the handler [2]. Reading two functions in mux.go and tree.go of version 5.1.0 settled the debate.
Once this middleware is active, chi.URLParam reads already-decoded values. Handlers never touch decoding logic, and one contract covers every path parameter in the API.
A Strict Middleware Contract
Indiscriminate decoding opens holes of its own, so the contract is pinned by four rules:
url.PathUnescape, notQueryUnescape: a plus sign in a path must stay a plus, not turn into a space [1].- The
*wildcard is skipped. Upload paths must stay raw; decoding could corrupt filenames or synthesize traversal characters. - Decode exactly once, per RFC 3986 section 2.4: the same string must never be decoded twice [3], so a doubly-encoded comma still reads as a single layer of escaping.
- Invalid escapes and non-UTF-8 results stay raw. The lookup then misses to a clean 404 instead of a 500 from MySQL strict mode.
These rules also settle the client side. The standard URI-encoding function in JavaScript deliberately escapes URI syntax characters and is recommended for user-entered fields [4]. A client that follows the documentation is precisely the client that sends encoded paths. Removing decoding from the API contract punishes the client that did the right thing.
curl -i "https://api-kotaportal.example.com/api/v1/entities/sarana-olahraga/gor-bulutangkis%2C-tenis-meja-dan-futsal"
func DecodePathParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rctx := chi.RouteContext(r.Context()); rctx != nil {
keys, values := rctx.URLParams.Keys, rctx.URLParams.Values
for i, v := range values {
if keys[i] == "*" || !strings.Contains(v, "%") {
continue
}
d, err := url.PathUnescape(v)
if err != nil || !utf8.ValidString(d) {
continue
}
values[i] = d
}
}
next.ServeHTTP(w, r)
})
}
Evidence from a Real Router
Two layers of testing close out the fix. A table-driven unit test runs against a real chi router: an escaped comma, a Unicode apostrophe, a percent-free path that must stay identical, and a wildcard that must stay raw. An integration probe reproduced the handler-level 404 with the middleware bypassed, then went green once it was wired back in. Finally, a direct request to the dev stack flipped from 404 to 200 on the same slug that had been failing.
The pattern is easy to recognize in other APIs: a lookup that fails while the data exists, and URLs in the logs still carrying %2C. If the router stores route parameters in encoded form, decoding before the lookup is the API owner's job. Waiting for the router to do it means waiting for a feature that was never promised.
Sources
[1] Go net/url package documentation
[2] chi v5.1.0 source code
[3] RFC 3986 section 2.4
[4] MDN encodeURIComponent documentation