Skip to content

Commit c2bc02f

Browse files
authored
fix(server): normalize trailing slashes so /health/ returns 200 (#21)
1 parent 33cdf39 commit c2bc02f

3 files changed

Lines changed: 224 additions & 2 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 Thomson Reuters
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package middlewares
16+
17+
import (
18+
"net/http"
19+
"net/url"
20+
)
21+
22+
// NormalizePath is an HTTP middleware that strips trailing slashes from
23+
// r.URL.Path (and r.URL.RawPath, when set) before the request reaches
24+
// subsequent middleware or handlers. The root path "/" is preserved.
25+
//
26+
// Why this exists: chi's middleware.StripSlashes only rewrites the chi
27+
// RouteContext's RoutePath; it does not modify r.URL.Path. Middleware that
28+
// reads r.URL.Path directly (for example middleware.Heartbeat, structured
29+
// loggers, or OpenTelemetry span-name formatters) therefore continues to see
30+
// the unnormalized path. Registering NormalizePath at the top of the chain
31+
// closes that gap so every downstream observer sees a single canonical path.
32+
//
33+
// NormalizePath does not mutate the caller's *http.Request. If normalization
34+
// is required it performs a shallow clone of the request and its URL (mirroring
35+
// net/http.StripPrefix); otherwise it forwards the request unchanged.
36+
func NormalizePath(next http.Handler) http.Handler {
37+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
path, pathChanged := stripTrailingSlashes(r.URL.Path)
39+
raw, rawChanged := stripTrailingSlashes(r.URL.RawPath)
40+
if !pathChanged && !rawChanged {
41+
next.ServeHTTP(w, r)
42+
return
43+
}
44+
45+
r2 := new(http.Request)
46+
*r2 = *r
47+
r2.URL = new(url.URL)
48+
*r2.URL = *r.URL
49+
r2.URL.Path = path
50+
r2.URL.RawPath = raw
51+
next.ServeHTTP(w, r2)
52+
})
53+
}
54+
55+
// stripTrailingSlashes removes every trailing '/' from p, except when p itself
56+
// is "/". It returns the cleaned path and whether any change was made so the
57+
// caller can avoid unnecessary allocations on already-canonical paths.
58+
func stripTrailingSlashes(p string) (string, bool) {
59+
end := len(p)
60+
for end > 1 && p[end-1] == '/' {
61+
end--
62+
}
63+
if end == len(p) {
64+
return p, false
65+
}
66+
return p[:end], true
67+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright 2026 Thomson Reuters
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package middlewares
16+
17+
import (
18+
"net/http"
19+
"net/http/httptest"
20+
"net/url"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
)
25+
26+
func TestStripTrailingSlashes(t *testing.T) {
27+
t.Parallel()
28+
29+
tests := []struct {
30+
name string
31+
in string
32+
want string
33+
wantChanged bool
34+
}{
35+
{name: "empty", in: "", want: "", wantChanged: false},
36+
{name: "root preserved", in: "/", want: "/", wantChanged: false},
37+
{name: "no trailing slash", in: "/foo", want: "/foo", wantChanged: false},
38+
{name: "single trailing slash", in: "/foo/", want: "/foo", wantChanged: true},
39+
{name: "multiple trailing slashes", in: "/foo///", want: "/foo", wantChanged: true},
40+
{name: "nested path", in: "/api/v1/info/", want: "/api/v1/info", wantChanged: true},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
t.Parallel()
46+
47+
got, changed := stripTrailingSlashes(tt.in)
48+
assert.Equal(t, tt.want, got)
49+
assert.Equal(t, tt.wantChanged, changed)
50+
})
51+
}
52+
}
53+
54+
func TestNormalizePath(t *testing.T) {
55+
t.Parallel()
56+
57+
tests := []struct {
58+
name string
59+
path string
60+
rawPath string
61+
wantPath string
62+
wantRawPath string
63+
}{
64+
{name: "trailing slash stripped", path: "/health/", wantPath: "/health"},
65+
{name: "multiple trailing slashes stripped", path: "/health///", wantPath: "/health"},
66+
{name: "no trailing slash", path: "/health", wantPath: "/health"},
67+
{name: "root preserved", path: "/", wantPath: "/"},
68+
{name: "nested trailing slash", path: "/api/v1/info/", wantPath: "/api/v1/info"},
69+
{
70+
name: "raw path stripped in lock-step with path",
71+
path: "/api/v1/foo bar/",
72+
rawPath: "/api/v1/foo%20bar/",
73+
wantPath: "/api/v1/foo bar",
74+
wantRawPath: "/api/v1/foo%20bar",
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
t.Parallel()
81+
82+
var (
83+
gotPath string
84+
gotRawPath string
85+
)
86+
87+
handler := NormalizePath(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
88+
gotPath = r.URL.Path
89+
gotRawPath = r.URL.RawPath
90+
}))
91+
92+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
93+
req.URL = &url.URL{Path: tt.path, RawPath: tt.rawPath}
94+
w := httptest.NewRecorder()
95+
96+
handler.ServeHTTP(w, req)
97+
98+
assert.Equal(t, tt.wantPath, gotPath)
99+
assert.Equal(t, tt.wantRawPath, gotRawPath)
100+
})
101+
}
102+
}
103+
104+
func TestNormalizePath_PreservesCallerRequest(t *testing.T) {
105+
t.Parallel()
106+
107+
handler := NormalizePath(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
108+
109+
originalURL := &url.URL{Path: "/health/", RawPath: "/health/", RawQuery: "x=1"}
110+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
111+
req.URL = originalURL
112+
113+
handler.ServeHTTP(httptest.NewRecorder(), req)
114+
115+
assert.Equal(t, "/health/", originalURL.Path, "caller's URL.Path must not be mutated")
116+
assert.Equal(t, "/health/", originalURL.RawPath, "caller's URL.RawPath must not be mutated")
117+
assert.Same(t, originalURL, req.URL, "caller's *http.Request must still reference the original *url.URL")
118+
}
119+
120+
func TestNormalizePath_PreservesQueryAndOtherFields(t *testing.T) {
121+
t.Parallel()
122+
123+
var got *http.Request
124+
handler := NormalizePath(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
125+
got = r
126+
}))
127+
128+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", nil)
129+
req.URL = &url.URL{Path: "/api/v1/exchange/", RawQuery: "code=abc&state=xyz", Fragment: "frag"}
130+
req.Header.Set("X-Test", "preserved")
131+
132+
handler.ServeHTTP(httptest.NewRecorder(), req)
133+
134+
assert.Equal(t, "/api/v1/exchange", got.URL.Path)
135+
assert.Equal(t, "code=abc&state=xyz", got.URL.RawQuery, "query string must be preserved")
136+
assert.Equal(t, "frag", got.URL.Fragment, "fragment must be preserved")
137+
assert.Equal(t, http.MethodPost, got.Method, "method must be preserved")
138+
assert.Equal(t, "preserved", got.Header.Get("X-Test"), "headers must be preserved")
139+
}
140+
141+
func TestNormalizePath_FastPathSkipsClone(t *testing.T) {
142+
t.Parallel()
143+
144+
var observed *http.Request
145+
handler := NormalizePath(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
146+
observed = r
147+
}))
148+
149+
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
150+
req.URL = &url.URL{Path: "/health"}
151+
152+
handler.ServeHTTP(httptest.NewRecorder(), req)
153+
154+
assert.Same(t, req, observed, "when no normalization is required the original request must be forwarded as-is")
155+
}

cmd/server/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ func (s *Server) Init() error {
9494
}
9595

9696
s.router = chi.NewRouter()
97+
s.router.Use(middlewares.NormalizePath)
98+
s.router.Use(middleware.CleanPath)
9799
s.router.Use(middleware.Heartbeat(healthPath))
98100
s.router.Use(otelhttp.NewMiddleware(constants.ProgramIdentifier))
99101
s.router.Use(middlewares.ChiRouteLabeler)
@@ -106,8 +108,6 @@ func (s *Server) Init() error {
106108
},
107109
}))
108110
s.router.Use(middleware.Timeout(s.cfg.Server.RequestTimeout))
109-
s.router.Use(middleware.StripSlashes)
110-
s.router.Use(middleware.CleanPath)
111111
s.router.Use(middleware.RequestSize(maxBodyBytes))
112112
s.router.Use(middlewares.SecurityHeaders)
113113

0 commit comments

Comments
 (0)