From c9ab7eb86ff747a0552bef1af4376f7e0405bc38 Mon Sep 17 00:00:00 2001 From: Charlie Chiang Date: Fri, 25 Jul 2025 15:42:13 +0800 Subject: [PATCH] feat: add an option to allow trailing slash insensitive matching Signed-off-by: Charlie Chiang --- gin.go | 80 ++++++++++++++++++++++++++------------- routes_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 26 deletions(-) diff --git a/gin.go b/gin.go index 1965a429..009b79a9 100644 --- a/gin.go +++ b/gin.go @@ -112,6 +112,15 @@ type Engine struct { // RedirectTrailingSlash is independent of this option. RedirectFixedPath bool + // TrailingSlashInsensitivity makes the router insensitive to trailing + // slashes. It works like RedirectTrailingSlash, but instead of generating a + // redirection response to the path with or without the trailing slash, it + // will just go to the corresponding handler. + // + // Enabling this option will make RedirectTrailingSlash ineffective since + // no redirection will be performed. + TrailingSlashInsensitivity bool + // HandleMethodNotAllowed if enabled, the router checks if another method is allowed for the // current route, if the current request can not be routed. // If this is the case, the request is answered with 'Method Not Allowed' @@ -184,12 +193,13 @@ var _ IRouter = (*Engine)(nil) // New returns a new blank Engine instance without any middleware attached. // By default, the configuration is: -// - RedirectTrailingSlash: true -// - RedirectFixedPath: false -// - HandleMethodNotAllowed: false -// - ForwardedByClientIP: true -// - UseRawPath: false -// - UnescapePathValues: true +// - RedirectTrailingSlash: true +// - RedirectFixedPath: false +// - TrailingSlashInsensitivity: false +// - HandleMethodNotAllowed: false +// - ForwardedByClientIP: true +// - UseRawPath: false +// - UnescapePathValues: true func New(opts ...OptionFunc) *Engine { debugPrintWARNINGNew() engine := &Engine{ @@ -198,22 +208,23 @@ func New(opts ...OptionFunc) *Engine { basePath: "/", root: true, }, - FuncMap: template.FuncMap{}, - RedirectTrailingSlash: true, - RedirectFixedPath: false, - HandleMethodNotAllowed: false, - ForwardedByClientIP: true, - RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"}, - TrustedPlatform: defaultPlatform, - UseRawPath: false, - RemoveExtraSlash: false, - UnescapePathValues: true, - MaxMultipartMemory: defaultMultipartMemory, - trees: make(methodTrees, 0, 9), - delims: render.Delims{Left: "{{", Right: "}}"}, - secureJSONPrefix: "while(1);", - trustedProxies: []string{"0.0.0.0/0", "::/0"}, - trustedCIDRs: defaultTrustedCIDRs, + FuncMap: template.FuncMap{}, + RedirectTrailingSlash: true, + RedirectFixedPath: false, + TrailingSlashInsensitivity: false, + HandleMethodNotAllowed: false, + ForwardedByClientIP: true, + RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"}, + TrustedPlatform: defaultPlatform, + UseRawPath: false, + RemoveExtraSlash: false, + UnescapePathValues: true, + MaxMultipartMemory: defaultMultipartMemory, + trees: make(methodTrees, 0, 9), + delims: render.Delims{Left: "{{", Right: "}}"}, + secureJSONPrefix: "while(1);", + trustedProxies: []string{"0.0.0.0/0", "::/0"}, + trustedCIDRs: defaultTrustedCIDRs, } engine.engine = engine engine.pool.New = func() any { @@ -691,6 +702,19 @@ func (engine *Engine) handleHTTPRequest(c *Context) { return } if httpMethod != http.MethodConnect && rPath != "/" { + // TrailingSlashInsensitivity has precedence over RedirectTrailingSlash. + if value.tsr && engine.TrailingSlashInsensitivity { + // Retry with the path with or without the trailing slash. + // It should succeed because tsr is true. + value = root.getValue(addOrRemoveTrailingSlash(rPath), c.params, c.skippedNodes, unescape) + if value.handlers != nil { + c.handlers = value.handlers + c.fullPath = value.fullPath + c.Next() + c.writermem.WriteHeaderNow() + return + } + } if value.tsr && engine.RedirectTrailingSlash { redirectTrailingSlash(c) return @@ -745,6 +769,13 @@ func serveError(c *Context, code int, defaultMessage []byte) { c.writermem.WriteHeaderNow() } +func addOrRemoveTrailingSlash(p string) string { + if length := len(p); length > 1 && p[length-1] == '/' { + return p[:length-1] + } + return p + "/" +} + func redirectTrailingSlash(c *Context) { req := c.Request p := req.URL.Path @@ -754,10 +785,7 @@ func redirectTrailingSlash(c *Context) { p = prefix + "/" + req.URL.Path } - req.URL.Path = p + "/" - if length := len(p); length > 1 && p[length-1] == '/' { - req.URL.Path = p[:length-1] - } + req.URL.Path = addOrRemoveTrailingSlash(p) redirectRequest(c) } diff --git a/routes_test.go b/routes_test.go index 1cae3fce..12657ef1 100644 --- a/routes_test.go +++ b/routes_test.go @@ -246,6 +246,107 @@ func TestRouteRedirectTrailingSlash(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +func TestRouteTrailingSlashInsensitivity(t *testing.T) { + router := New() + router.RedirectTrailingSlash = false + router.TrailingSlashInsensitivity = true + router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") }) + router.GET("/path2/", func(c *Context) { c.String(http.StatusOK, "path2") }) + + // Test that trailing slash insensitivity works. + w := PerformRequest(router, http.MethodGet, "/path/") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path", w.Body.String()) + + w = PerformRequest(router, http.MethodGet, "/path") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path", w.Body.String()) + + w = PerformRequest(router, http.MethodGet, "/path2/") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path2", w.Body.String()) + + w = PerformRequest(router, http.MethodGet, "/path2") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path2", w.Body.String()) + + // If handlers for `/path` and `/path/` are different, the request should not be redirected. + router.GET("/path3", func(c *Context) { c.String(http.StatusOK, "path3") }) + router.GET("/path3/", func(c *Context) { c.String(http.StatusOK, "path3/") }) + + w = PerformRequest(router, http.MethodGet, "/path3") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path3", w.Body.String()) + + w = PerformRequest(router, http.MethodGet, "/path3/") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "path3/", w.Body.String()) + + // Should no longer match. + router.TrailingSlashInsensitivity = false + + w = PerformRequest(router, http.MethodGet, "/path2") + assert.Equal(t, http.StatusNotFound, w.Code) + + w = PerformRequest(router, http.MethodGet, "/path/") + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func BenchmarkRouteTrailingSlashInsensitivity(b *testing.B) { + b.Run("Insensitive", func(b *testing.B) { + router := New() + router.RedirectTrailingSlash = false + router.TrailingSlashInsensitivity = true + router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") }) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + // Cause an insensitive match. Test if the retry logic is causing + // slowdowns. + w := PerformRequest(router, http.MethodGet, "/path/") + if w.Code != http.StatusOK || w.Body.String() != "path" { + b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code) + } + } + }) + + b.Run("Exact", func(b *testing.B) { + router := New() + router.RedirectTrailingSlash = false + router.TrailingSlashInsensitivity = false + router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") }) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + w := PerformRequest(router, http.MethodGet, "/path") // Exact match. + if w.Code != http.StatusOK || w.Body.String() != "path" { + b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code) + } + } + }) + + b.Run("Redirect", func(b *testing.B) { + router := New() + router.RedirectTrailingSlash = true + router.TrailingSlashInsensitivity = false + router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") }) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + w := PerformRequest(router, http.MethodGet, "/path/") // Redirect. + if w.Code != http.StatusMovedPermanently { + b.Fatalf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code) + } + } + }) +} + func TestRouteRedirectFixedPath(t *testing.T) { router := New() router.RedirectFixedPath = true