Skip to content

Commit 7236990

Browse files
committed
feat: add an option to allow trailing slash insensitive matching
Signed-off-by: Charlie Chiang <charlie_c_0129@outlook.com>
1 parent a4ac275 commit 7236990

File tree

2 files changed

+161
-26
lines changed

2 files changed

+161
-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,20 @@ 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+
*c.params = (*c.params)[:0] // reset params to avoid overflowing params
710+
value = root.getValue(addOrRemoveTrailingSlash(rPath), c.params, c.skippedNodes, unescape)
711+
if value.handlers != nil {
712+
c.handlers = value.handlers
713+
c.fullPath = value.fullPath
714+
c.Next()
715+
c.writermem.WriteHeaderNow()
716+
return
717+
}
718+
}
694719
if value.tsr && engine.RedirectTrailingSlash {
695720
redirectTrailingSlash(c)
696721
return
@@ -745,6 +770,13 @@ func serveError(c *Context, code int, defaultMessage []byte) {
745770
c.writermem.WriteHeaderNow()
746771
}
747772

773+
func addOrRemoveTrailingSlash(p string) string {
774+
if strings.HasSuffix(p, "/") {
775+
return p[:len(p)-1]
776+
}
777+
return p + "/"
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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,112 @@ 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+
router.GET("/:id", func(c *Context) { c.String(http.StatusOK, c.Param("id")) })
256+
257+
// Test that trailing slash insensitivity works.
258+
w := PerformRequest(router, http.MethodGet, "/a/")
259+
assert.Equal(t, http.StatusOK, w.Code)
260+
assert.Equal(t, "a", w.Body.String())
261+
262+
w = PerformRequest(router, http.MethodGet, "/path/")
263+
assert.Equal(t, http.StatusOK, w.Code)
264+
assert.Equal(t, "path", w.Body.String())
265+
266+
w = PerformRequest(router, http.MethodGet, "/path")
267+
assert.Equal(t, http.StatusOK, w.Code)
268+
assert.Equal(t, "path", w.Body.String())
269+
270+
w = PerformRequest(router, http.MethodGet, "/path2/")
271+
assert.Equal(t, http.StatusOK, w.Code)
272+
assert.Equal(t, "path2", w.Body.String())
273+
274+
w = PerformRequest(router, http.MethodGet, "/path2")
275+
assert.Equal(t, http.StatusOK, w.Code)
276+
assert.Equal(t, "path2", w.Body.String())
277+
278+
// If handlers for `/path` and `/path/` are different, the request should not be redirected.
279+
router.GET("/path3", func(c *Context) { c.String(http.StatusOK, "path3") })
280+
router.GET("/path3/", func(c *Context) { c.String(http.StatusOK, "path3/") })
281+
282+
w = PerformRequest(router, http.MethodGet, "/path3")
283+
assert.Equal(t, http.StatusOK, w.Code)
284+
assert.Equal(t, "path3", w.Body.String())
285+
286+
w = PerformRequest(router, http.MethodGet, "/path3/")
287+
assert.Equal(t, http.StatusOK, w.Code)
288+
assert.Equal(t, "path3/", w.Body.String())
289+
290+
// Should no longer match.
291+
router.TrailingSlashInsensitivity = false
292+
293+
w = PerformRequest(router, http.MethodGet, "/path2")
294+
assert.Equal(t, http.StatusNotFound, w.Code)
295+
296+
w = PerformRequest(router, http.MethodGet, "/path/")
297+
assert.Equal(t, http.StatusNotFound, w.Code)
298+
}
299+
300+
func BenchmarkRouteTrailingSlashInsensitivity(b *testing.B) {
301+
b.Run("Insensitive", func(b *testing.B) {
302+
router := New()
303+
router.RedirectTrailingSlash = false
304+
router.TrailingSlashInsensitivity = true
305+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
306+
307+
b.ResetTimer()
308+
b.ReportAllocs()
309+
310+
for i := 0; i < b.N; i++ {
311+
// Cause an insensitive match. Test if the retry logic is causing
312+
// slowdowns.
313+
w := PerformRequest(router, http.MethodGet, "/path/")
314+
if w.Code != http.StatusOK || w.Body.String() != "path" {
315+
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
316+
}
317+
}
318+
})
319+
320+
b.Run("Exact", func(b *testing.B) {
321+
router := New()
322+
router.RedirectTrailingSlash = false
323+
router.TrailingSlashInsensitivity = false
324+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
325+
326+
b.ResetTimer()
327+
b.ReportAllocs()
328+
329+
for i := 0; i < b.N; i++ {
330+
w := PerformRequest(router, http.MethodGet, "/path") // Exact match.
331+
if w.Code != http.StatusOK || w.Body.String() != "path" {
332+
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
333+
}
334+
}
335+
})
336+
337+
b.Run("Redirect", func(b *testing.B) {
338+
router := New()
339+
router.RedirectTrailingSlash = true
340+
router.TrailingSlashInsensitivity = false
341+
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
342+
343+
b.ResetTimer()
344+
b.ReportAllocs()
345+
346+
for i := 0; i < b.N; i++ {
347+
w := PerformRequest(router, http.MethodGet, "/path/") // Redirect.
348+
if w.Code != http.StatusMovedPermanently {
349+
b.Fatalf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code)
350+
}
351+
}
352+
})
353+
}
354+
249355
func TestRouteRedirectFixedPath(t *testing.T) {
250356
router := New()
251357
router.RedirectFixedPath = true

0 commit comments

Comments
 (0)