Skip to content

Commit 9cee7fa

Browse files
committed
feat: add an option to allow trailing slash insensitive matching
Signed-off-by: Charlie Chiang <[email protected]>
1 parent a4ac275 commit 9cee7fa

File tree

2 files changed

+156
-26
lines changed

2 files changed

+156
-26
lines changed

gin.go

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ type Engine struct {
112112
// RedirectTrailingSlash is independent of this option.
113113
RedirectFixedPath bool
114114

115+
// TrailingSlashInsensitivity makes the router insensitive to trailing
116+
// slashes. It works like RedirectTrailingSlash, but instead of generating a
117+
// redirection response to the path with or without the trailing slash, it
118+
// will just go to the corresponding handler.
119+
//
120+
// Enabling this option will make RedirectTrailingSlash ineffective since
121+
// no redirection will be performed.
122+
TrailingSlashInsensitivity bool
123+
115124
// HandleMethodNotAllowed if enabled, the router checks if another method is allowed for the
116125
// current route, if the current request can not be routed.
117126
// If this is the case, the request is answered with 'Method Not Allowed'
@@ -184,12 +193,13 @@ var _ IRouter = (*Engine)(nil)
184193

185194
// New returns a new blank Engine instance without any middleware attached.
186195
// By default, the configuration is:
187-
// - RedirectTrailingSlash: true
188-
// - RedirectFixedPath: false
189-
// - HandleMethodNotAllowed: false
190-
// - ForwardedByClientIP: true
191-
// - UseRawPath: false
192-
// - UnescapePathValues: true
196+
// - RedirectTrailingSlash: true
197+
// - RedirectFixedPath: false
198+
// - TrailingSlashInsensitivity: false
199+
// - HandleMethodNotAllowed: false
200+
// - ForwardedByClientIP: true
201+
// - UseRawPath: false
202+
// - UnescapePathValues: true
193203
func New(opts ...OptionFunc) *Engine {
194204
debugPrintWARNINGNew()
195205
engine := &Engine{
@@ -198,22 +208,23 @@ func New(opts ...OptionFunc) *Engine {
198208
basePath: "/",
199209
root: true,
200210
},
201-
FuncMap: template.FuncMap{},
202-
RedirectTrailingSlash: true,
203-
RedirectFixedPath: false,
204-
HandleMethodNotAllowed: false,
205-
ForwardedByClientIP: true,
206-
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
207-
TrustedPlatform: defaultPlatform,
208-
UseRawPath: false,
209-
RemoveExtraSlash: false,
210-
UnescapePathValues: true,
211-
MaxMultipartMemory: defaultMultipartMemory,
212-
trees: make(methodTrees, 0, 9),
213-
delims: render.Delims{Left: "{{", Right: "}}"},
214-
secureJSONPrefix: "while(1);",
215-
trustedProxies: []string{"0.0.0.0/0", "::/0"},
216-
trustedCIDRs: defaultTrustedCIDRs,
211+
FuncMap: template.FuncMap{},
212+
RedirectTrailingSlash: true,
213+
RedirectFixedPath: false,
214+
TrailingSlashInsensitivity: false,
215+
HandleMethodNotAllowed: false,
216+
ForwardedByClientIP: true,
217+
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
218+
TrustedPlatform: defaultPlatform,
219+
UseRawPath: false,
220+
RemoveExtraSlash: false,
221+
UnescapePathValues: true,
222+
MaxMultipartMemory: defaultMultipartMemory,
223+
trees: make(methodTrees, 0, 9),
224+
delims: render.Delims{Left: "{{", Right: "}}"},
225+
secureJSONPrefix: "while(1);",
226+
trustedProxies: []string{"0.0.0.0/0", "::/0"},
227+
trustedCIDRs: defaultTrustedCIDRs,
217228
}
218229
engine.engine = engine
219230
engine.pool.New = func() any {
@@ -691,6 +702,19 @@ func (engine *Engine) handleHTTPRequest(c *Context) {
691702
return
692703
}
693704
if httpMethod != http.MethodConnect && rPath != "/" {
705+
// TrailingSlashInsensitivity has precedence over RedirectTrailingSlash.
706+
if value.tsr && engine.TrailingSlashInsensitivity {
707+
// Retry with the path with or without the trailing slash.
708+
// It should succeed because tsr is true.
709+
value = root.getValue(addOrRemoveTrailingSlash(rPath), c.params, c.skippedNodes, unescape)
710+
if value.handlers != nil {
711+
c.handlers = value.handlers
712+
c.fullPath = value.fullPath
713+
c.Next()
714+
c.writermem.WriteHeaderNow()
715+
return
716+
}
717+
}
694718
if value.tsr && engine.RedirectTrailingSlash {
695719
redirectTrailingSlash(c)
696720
return
@@ -745,6 +769,14 @@ func serveError(c *Context, code int, defaultMessage []byte) {
745769
c.writermem.WriteHeaderNow()
746770
}
747771

772+
func addOrRemoveTrailingSlash(p string) string {
773+
ret := p + "/"
774+
if length := len(p); length > 1 && p[length-1] == '/' {
775+
ret = p[:length-1]
776+
}
777+
return ret
778+
}
779+
748780
func redirectTrailingSlash(c *Context) {
749781
req := c.Request
750782
p := req.URL.Path
@@ -754,10 +786,7 @@ func redirectTrailingSlash(c *Context) {
754786

755787
p = prefix + "/" + req.URL.Path
756788
}
757-
req.URL.Path = p + "/"
758-
if length := len(p); length > 1 && p[length-1] == '/' {
759-
req.URL.Path = p[:length-1]
760-
}
789+
req.URL.Path = addOrRemoveTrailingSlash(p)
761790
redirectRequest(c)
762791
}
763792

routes_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,107 @@ func TestRouteRedirectTrailingSlash(t *testing.T) {
246246
assert.Equal(t, http.StatusNotFound, w.Code)
247247
}
248248

249+
func TestRouteTrailingSlashInsensitivity(t *testing.T) {
250+
router := New()
251+
router.RedirectTrailingSlash = false
252+
router.TrailingSlashInsensitivity = true
253+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
254+
router.GET("/path2/", func(c *Context) { c.String(http.StatusOK, "path2") })
255+
256+
// Test that trailing slash insensitivity works.
257+
w := PerformRequest(router, http.MethodGet, "/path/")
258+
assert.Equal(t, http.StatusOK, w.Code)
259+
assert.Equal(t, "path", w.Body.String())
260+
261+
w = PerformRequest(router, http.MethodGet, "/path")
262+
assert.Equal(t, http.StatusOK, w.Code)
263+
assert.Equal(t, "path", w.Body.String())
264+
265+
w = PerformRequest(router, http.MethodGet, "/path2/")
266+
assert.Equal(t, http.StatusOK, w.Code)
267+
assert.Equal(t, "path2", w.Body.String())
268+
269+
w = PerformRequest(router, http.MethodGet, "/path2")
270+
assert.Equal(t, http.StatusOK, w.Code)
271+
assert.Equal(t, "path2", w.Body.String())
272+
273+
// If handlers for `/path` and `/path/` are different, the request should not be redirected.
274+
router.GET("/path3", func(c *Context) { c.String(http.StatusOK, "path3") })
275+
router.GET("/path3/", func(c *Context) { c.String(http.StatusOK, "path3/") })
276+
277+
w = PerformRequest(router, http.MethodGet, "/path3")
278+
assert.Equal(t, http.StatusOK, w.Code)
279+
assert.Equal(t, "path3", w.Body.String())
280+
281+
w = PerformRequest(router, http.MethodGet, "/path3/")
282+
assert.Equal(t, http.StatusOK, w.Code)
283+
assert.Equal(t, "path3/", w.Body.String())
284+
285+
// Should no longer match.
286+
router.TrailingSlashInsensitivity = false
287+
288+
w = PerformRequest(router, http.MethodGet, "/path2")
289+
assert.Equal(t, http.StatusNotFound, w.Code)
290+
291+
w = PerformRequest(router, http.MethodGet, "/path/")
292+
assert.Equal(t, http.StatusNotFound, w.Code)
293+
}
294+
295+
func BenchmarkRouteTrailingSlashInsensitivity(b *testing.B) {
296+
b.Run("Insensitive", func(b *testing.B) {
297+
router := New()
298+
router.RedirectTrailingSlash = false
299+
router.TrailingSlashInsensitivity = true
300+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
301+
302+
b.ResetTimer()
303+
b.ReportAllocs()
304+
305+
for i := 0; i < b.N; i++ {
306+
// Cause an insensitive match. Test if the retry logic is causing
307+
// slowdowns.
308+
w := PerformRequest(router, http.MethodGet, "/path/")
309+
if w.Code != http.StatusOK || w.Body.String() != "path" {
310+
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
311+
}
312+
}
313+
})
314+
315+
b.Run("Exact", func(b *testing.B) {
316+
router := New()
317+
router.RedirectTrailingSlash = false
318+
router.TrailingSlashInsensitivity = false
319+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
320+
321+
b.ResetTimer()
322+
b.ReportAllocs()
323+
324+
for i := 0; i < b.N; i++ {
325+
w := PerformRequest(router, http.MethodGet, "/path") // Exact match.
326+
if w.Code != http.StatusOK || w.Body.String() != "path" {
327+
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
328+
}
329+
}
330+
})
331+
332+
b.Run("Redirect", func(b *testing.B) {
333+
router := New()
334+
router.RedirectTrailingSlash = true
335+
router.TrailingSlashInsensitivity = false
336+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
337+
338+
b.ResetTimer()
339+
b.ReportAllocs()
340+
341+
for i := 0; i < b.N; i++ {
342+
w := PerformRequest(router, http.MethodGet, "/path/") // Redirect.
343+
if w.Code != http.StatusMovedPermanently {
344+
b.Fatalf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code)
345+
}
346+
}
347+
})
348+
}
349+
249350
func TestRouteRedirectFixedPath(t *testing.T) {
250351
router := New()
251352
router.RedirectFixedPath = true

0 commit comments

Comments
 (0)