From 463577f85cdb651c5d929be43fd5a7bcfb5dc7c4 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 10:52:09 -0600 Subject: [PATCH 1/8] added initial tests --- .gitignore | 2 ++ router_test.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.gitignore b/.gitignore index 8365624..ba39ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ _testmain.go *.exe *.test + +.idea/ diff --git a/router_test.go b/router_test.go index 0c58296..9428cad 100644 --- a/router_test.go +++ b/router_test.go @@ -149,6 +149,29 @@ func testMethods(t *testing.T, newRequest RequestCreator, headCanUseGet bool, us testMethod("HEAD", "HEAD") } + +func TestCaseInsensitiveRouting(t *testing.T) { + router := New() + router.GET("/my-path", simpleHandler) + + w := httptest.NewRecorder() + r, _ := newRequest("GET", "/MY-PATH", nil) + router.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 response for case-insensitive request. Received: %d", w.Code) + } + + // Now try with case-insensitive routing + router.CaseInsensitive = true + + w = httptest.NewRecorder() + router.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Errorf("expected 200 response for case-insensitive request. Received: %d", w.Code) + } +} + func TestNotFound(t *testing.T) { calledNotFound := false From 3b4153cb2851a9631f187ea81688531ad8773a0b Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 10:52:32 -0600 Subject: [PATCH 2/8] added functionality to router --- router.go | 4 ++++ treemux_17.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/router.go b/router.go index b8063e4..729697a 100644 --- a/router.go +++ b/router.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) // The params argument contains the parameters parsed from wildcards and catch-alls in the URL. @@ -117,6 +118,9 @@ func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupR if rawQueryLen != 0 || path[pathLen-1] == '?' { // Remove any query string and the ?. path = path[:pathLen-rawQueryLen-1] + if t.CaseInsensitive { + path = strings.ToLower(path) + } pathLen = len(path) } } else { diff --git a/treemux_17.go b/treemux_17.go index a80a500..876c8d3 100644 --- a/treemux_17.go +++ b/treemux_17.go @@ -82,6 +82,9 @@ type TreeMux struct { // if you are going to add routes after the router has already begun serving requests. There is a potential // performance penalty at high load. SafeAddRoutesWhileRunning bool + + // CaseInsensitive determines if routes should be treated as case-insensitive. + CaseInsensitive bool } func (t *TreeMux) setDefaultRequestContext(r *http.Request) *http.Request { From d685567e05bae2b5cc2b26e752eabf4a5cc970b3 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 14:51:30 -0600 Subject: [PATCH 3/8] added group test --- group_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/group_test.go b/group_test.go index 9065d5c..9950fd1 100644 --- a/group_test.go +++ b/group_test.go @@ -53,6 +53,28 @@ func TestSubGroupEmptyMapping(t *testing.T) { } } +func TestGroupCaseInsensitiveRouting(t *testing.T) { + r := New() + r.NewGroup("/MY-path").GET("", func(w http.ResponseWriter, _ *http.Request, _ map[string]string) { + w.WriteHeader(200) + }) + + req, _ := http.NewRequest("GET", "/MY-PATH", nil) + recorder := httptest.NewRecorder() + r.ServeHTTP(recorder, req) + if recorder.Code != http.StatusNotFound { + t.Errorf("expected 404 response for case-insensitive request. Received: %d", recorder.Code) + } + + // Now try with case-insensitive routing + r.CaseInsensitive = true + recorder = httptest.NewRecorder() + r.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Errorf("expected 200 response for case-insensitive request. Received: %d", recorder.Code) + } +} + func TestGroupMethods(t *testing.T) { for _, scenario := range scenarios { t.Log(scenario.description) From 4767b32c185aec935ec9984ccab77882e1e02384 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 14:52:16 -0600 Subject: [PATCH 4/8] added case insensitivity for route definition --- router.go | 13 ++++++++----- router_test.go | 2 +- tree.go | 15 ++++++++++++--- tree_test.go | 10 +++++----- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/router.go b/router.go index 729697a..034fe52 100644 --- a/router.go +++ b/router.go @@ -110,7 +110,13 @@ func redirect(w http.ResponseWriter, r *http.Request, newPath string, statusCode func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupResult, found bool) { result.StatusCode = http.StatusNotFound path := r.RequestURI + if t.CaseInsensitive { + path = strings.ToLower(path) + } unescapedPath := r.URL.Path + if t.CaseInsensitive { + unescapedPath = strings.ToLower(unescapedPath) + } pathLen := len(path) if pathLen > 0 && t.PathSource == RequestURI { rawQueryLen := len(r.URL.RawQuery) @@ -118,9 +124,6 @@ func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupR if rawQueryLen != 0 || path[pathLen-1] == '?' { // Remove any query string and the ?. path = path[:pathLen-rawQueryLen-1] - if t.CaseInsensitive { - path = strings.ToLower(path) - } pathLen = len(path) } } else { @@ -136,13 +139,13 @@ func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupR unescapedPath = unescapedPath[:len(unescapedPath)-1] } - n, handler, params := t.root.search(r.Method, path[1:]) + n, handler, params := t.root.search(r.Method, path[1:], t.CaseInsensitive) if n == nil { if t.RedirectCleanPath { // Path was not found. Try cleaning it up and search again. // TODO Test this cleanPath := Clean(unescapedPath) - n, handler, params = t.root.search(r.Method, cleanPath[1:]) + n, handler, params = t.root.search(r.Method, cleanPath[1:], t.CaseInsensitive) if n == nil { // Still nothing found. return diff --git a/router_test.go b/router_test.go index 9428cad..f89b4ed 100644 --- a/router_test.go +++ b/router_test.go @@ -152,7 +152,7 @@ func testMethods(t *testing.T, newRequest RequestCreator, headCanUseGet bool, us func TestCaseInsensitiveRouting(t *testing.T) { router := New() - router.GET("/my-path", simpleHandler) + router.GET("/MY-path", simpleHandler) w := httptest.NewRecorder() r, _ := newRequest("GET", "/MY-PATH", nil) diff --git a/tree.go b/tree.go index 530d427..bf175c7 100644 --- a/tree.go +++ b/tree.go @@ -231,10 +231,13 @@ func (n *node) splitCommonPrefix(existingNodeIndex int, path string) (*node, int return newNode, i } -func (n *node) search(method, path string) (found *node, handler HandlerFunc, params []string) { +func (n *node) search(method, path string, caseInsensitive bool) (found *node, handler HandlerFunc, params []string) { // if test != nil { // test.Logf("Searching for %s in %s", path, n.dumpTree("", "")) // } + if caseInsensitive { + path = strings.ToLower(path) + } pathLen := len(path) if pathLen == 0 { if len(n.leafHandler) == 0 { @@ -247,12 +250,18 @@ func (n *node) search(method, path string) (found *node, handler HandlerFunc, pa // First see if this matches a static token. firstChar := path[0] for i, staticIndex := range n.staticIndices { + if caseInsensitive { + staticIndex = strings.ToLower(string(staticIndex))[0] + } if staticIndex == firstChar { child := n.staticChild[i] childPathLen := len(child.path) + if caseInsensitive { + child.path = strings.ToLower(child.path) + } if pathLen >= childPathLen && child.path == path[:childPathLen] { nextPath := path[childPathLen:] - found, handler, params = child.search(method, nextPath) + found, handler, params = child.search(method, nextPath, caseInsensitive) } break } @@ -275,7 +284,7 @@ func (n *node) search(method, path string) (found *node, handler HandlerFunc, pa nextToken := path[nextSlash:] if len(thisToken) > 0 { // Don't match on empty tokens. - wcNode, wcHandler, wcParams := n.wildcardChild.search(method, nextToken) + wcNode, wcHandler, wcParams := n.wildcardChild.search(method, nextToken, caseInsensitive) if wcHandler != nil || (found == nil && wcNode != nil) { unescaped, err := unescape(thisToken) if err != nil { diff --git a/tree_test.go b/tree_test.go index 1f757d1..3ba63e7 100644 --- a/tree_test.go +++ b/tree_test.go @@ -27,7 +27,7 @@ func testPath(t *testing.T, tree *node, path string, expectPath string, expected } t.Log("Testing", path) - n, foundHandler, paramList := tree.search("GET", path[1:]) + n, foundHandler, paramList := tree.search("GET", path[1:], false) if expectPath != "" && n == nil { t.Errorf("No match for %s, expected %s", path, expectPath) return @@ -317,7 +317,7 @@ func BenchmarkTreeNullRequest(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "") + tree.search("GET", "", false) } } @@ -333,7 +333,7 @@ func BenchmarkTreeOneStatic(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abc") + tree.search("GET", "abc", false) } } @@ -349,7 +349,7 @@ func BenchmarkTreeOneParam(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abc") + tree.search("GET", "abc", false) } } @@ -365,6 +365,6 @@ func BenchmarkTreeLongParams(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl") + tree.search("GET", "abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", false) } } From 19d0f4edeee1e28866aa2fa29f0fd9563e7a2098 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 15:09:18 -0600 Subject: [PATCH 5/8] added documentation --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 0d82f1d..4b8375f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ These are the values accepted for RedirectBehavior. You may also add these value * Redirect308 - RFC7538 Permanent Redirect * UseHandler - Don't redirect to the canonical path. Just call the handler instead. +### Case Insensitive Routing + +You can optionally allow case insensitive routing by setting the _CaseInsensitive_ property on the router to true. +This allows you to make all routes case insensitive. For example: +```go +router := httptreemux.New() +router.CaseInsensitive +router.GET("/My-RoUtE", pageHandler) +``` +In this example, performing a GET request to /my-route will match the route and execute the _pageHandler_ functionality. + #### Rationale/Usage On a POST request, most browsers that receive a 301 will submit a GET request to the redirected URL, meaning that any data will likely be lost. If you want to handle and avoid this behavior, you may use Redirect307, which causes most browsers to resubmit the request using the original method and request body. From 1af7c7dd2b3d7093d9ebd3cd4ef0dc7b75a697d1 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Tue, 28 Sep 2021 15:13:15 -0600 Subject: [PATCH 6/8] removed extra case conversions --- router.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/router.go b/router.go index 034fe52..aadb625 100644 --- a/router.go +++ b/router.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "net/url" - "strings" ) // The params argument contains the parameters parsed from wildcards and catch-alls in the URL. @@ -110,13 +109,7 @@ func redirect(w http.ResponseWriter, r *http.Request, newPath string, statusCode func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupResult, found bool) { result.StatusCode = http.StatusNotFound path := r.RequestURI - if t.CaseInsensitive { - path = strings.ToLower(path) - } unescapedPath := r.URL.Path - if t.CaseInsensitive { - unescapedPath = strings.ToLower(unescapedPath) - } pathLen := len(path) if pathLen > 0 && t.PathSource == RequestURI { rawQueryLen := len(r.URL.RawQuery) From 11211e80bfa583bde373601c760580f21d201672 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Wed, 6 Oct 2021 16:15:36 -0600 Subject: [PATCH 7/8] reverted search logic in favor of router level logic --- group.go | 4 ++++ group_test.go | 9 +-------- router.go | 9 +++++++-- router_test.go | 9 ++------- tree.go | 15 +++------------ tree_test.go | 10 +++++----- 6 files changed, 22 insertions(+), 34 deletions(-) diff --git a/group.go b/group.go index b10bc64..b01c265 100644 --- a/group.go +++ b/group.go @@ -144,6 +144,10 @@ func (g *Group) Handle(method string, path string, handler HandlerFunc) { func (g *Group) addFullStackHandler(method string, path string, handler HandlerFunc) { addSlash := false addOne := func(thePath string) { + if g.mux.CaseInsensitive { + thePath = strings.ToLower(thePath) + } + node := g.mux.root.addPath(thePath[1:], nil, false) if addSlash { node.addSlash = true diff --git a/group_test.go b/group_test.go index 9950fd1..2ad233f 100644 --- a/group_test.go +++ b/group_test.go @@ -55,6 +55,7 @@ func TestSubGroupEmptyMapping(t *testing.T) { func TestGroupCaseInsensitiveRouting(t *testing.T) { r := New() + r.CaseInsensitive = true r.NewGroup("/MY-path").GET("", func(w http.ResponseWriter, _ *http.Request, _ map[string]string) { w.WriteHeader(200) }) @@ -62,14 +63,6 @@ func TestGroupCaseInsensitiveRouting(t *testing.T) { req, _ := http.NewRequest("GET", "/MY-PATH", nil) recorder := httptest.NewRecorder() r.ServeHTTP(recorder, req) - if recorder.Code != http.StatusNotFound { - t.Errorf("expected 404 response for case-insensitive request. Received: %d", recorder.Code) - } - - // Now try with case-insensitive routing - r.CaseInsensitive = true - recorder = httptest.NewRecorder() - r.ServeHTTP(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected 200 response for case-insensitive request. Received: %d", recorder.Code) } diff --git a/router.go b/router.go index aadb625..5969d59 100644 --- a/router.go +++ b/router.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) // The params argument contains the parameters parsed from wildcards and catch-alls in the URL. @@ -125,6 +126,10 @@ func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupR path = r.URL.Path pathLen = len(path) } + if t.CaseInsensitive { + path = strings.ToLower(path) + unescapedPath = strings.ToLower(unescapedPath) + } trailingSlash := path[pathLen-1] == '/' && pathLen > 1 if trailingSlash && t.RedirectTrailingSlash { @@ -132,13 +137,13 @@ func (t *TreeMux) lookup(w http.ResponseWriter, r *http.Request) (result LookupR unescapedPath = unescapedPath[:len(unescapedPath)-1] } - n, handler, params := t.root.search(r.Method, path[1:], t.CaseInsensitive) + n, handler, params := t.root.search(r.Method, path[1:]) if n == nil { if t.RedirectCleanPath { // Path was not found. Try cleaning it up and search again. // TODO Test this cleanPath := Clean(unescapedPath) - n, handler, params = t.root.search(r.Method, cleanPath[1:], t.CaseInsensitive) + n, handler, params = t.root.search(r.Method, cleanPath[1:]) if n == nil { // Still nothing found. return diff --git a/router_test.go b/router_test.go index f89b4ed..b2f8e3f 100644 --- a/router_test.go +++ b/router_test.go @@ -152,19 +152,14 @@ func testMethods(t *testing.T, newRequest RequestCreator, headCanUseGet bool, us func TestCaseInsensitiveRouting(t *testing.T) { router := New() + // create case-insensitive route + router.CaseInsensitive = true router.GET("/MY-path", simpleHandler) w := httptest.NewRecorder() r, _ := newRequest("GET", "/MY-PATH", nil) router.ServeHTTP(w, r) - if w.Code != http.StatusNotFound { - t.Errorf("expected 404 response for case-insensitive request. Received: %d", w.Code) - } - - // Now try with case-insensitive routing - router.CaseInsensitive = true - w = httptest.NewRecorder() router.ServeHTTP(w, r) if w.Code != http.StatusOK { diff --git a/tree.go b/tree.go index bf175c7..530d427 100644 --- a/tree.go +++ b/tree.go @@ -231,13 +231,10 @@ func (n *node) splitCommonPrefix(existingNodeIndex int, path string) (*node, int return newNode, i } -func (n *node) search(method, path string, caseInsensitive bool) (found *node, handler HandlerFunc, params []string) { +func (n *node) search(method, path string) (found *node, handler HandlerFunc, params []string) { // if test != nil { // test.Logf("Searching for %s in %s", path, n.dumpTree("", "")) // } - if caseInsensitive { - path = strings.ToLower(path) - } pathLen := len(path) if pathLen == 0 { if len(n.leafHandler) == 0 { @@ -250,18 +247,12 @@ func (n *node) search(method, path string, caseInsensitive bool) (found *node, h // First see if this matches a static token. firstChar := path[0] for i, staticIndex := range n.staticIndices { - if caseInsensitive { - staticIndex = strings.ToLower(string(staticIndex))[0] - } if staticIndex == firstChar { child := n.staticChild[i] childPathLen := len(child.path) - if caseInsensitive { - child.path = strings.ToLower(child.path) - } if pathLen >= childPathLen && child.path == path[:childPathLen] { nextPath := path[childPathLen:] - found, handler, params = child.search(method, nextPath, caseInsensitive) + found, handler, params = child.search(method, nextPath) } break } @@ -284,7 +275,7 @@ func (n *node) search(method, path string, caseInsensitive bool) (found *node, h nextToken := path[nextSlash:] if len(thisToken) > 0 { // Don't match on empty tokens. - wcNode, wcHandler, wcParams := n.wildcardChild.search(method, nextToken, caseInsensitive) + wcNode, wcHandler, wcParams := n.wildcardChild.search(method, nextToken) if wcHandler != nil || (found == nil && wcNode != nil) { unescaped, err := unescape(thisToken) if err != nil { diff --git a/tree_test.go b/tree_test.go index 3ba63e7..1f757d1 100644 --- a/tree_test.go +++ b/tree_test.go @@ -27,7 +27,7 @@ func testPath(t *testing.T, tree *node, path string, expectPath string, expected } t.Log("Testing", path) - n, foundHandler, paramList := tree.search("GET", path[1:], false) + n, foundHandler, paramList := tree.search("GET", path[1:]) if expectPath != "" && n == nil { t.Errorf("No match for %s, expected %s", path, expectPath) return @@ -317,7 +317,7 @@ func BenchmarkTreeNullRequest(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "", false) + tree.search("GET", "") } } @@ -333,7 +333,7 @@ func BenchmarkTreeOneStatic(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abc", false) + tree.search("GET", "abc") } } @@ -349,7 +349,7 @@ func BenchmarkTreeOneParam(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abc", false) + tree.search("GET", "abc") } } @@ -365,6 +365,6 @@ func BenchmarkTreeLongParams(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - tree.search("GET", "abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", false) + tree.search("GET", "abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl") } } From cff09fa4677c9614a5e087d620d0d78f557dd365 Mon Sep 17 00:00:00 2001 From: Russell Laboe Date: Wed, 6 Oct 2021 16:25:37 -0600 Subject: [PATCH 8/8] updated documentation --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b8375f..4dde584 100644 --- a/README.md +++ b/README.md @@ -191,14 +191,15 @@ These are the values accepted for RedirectBehavior. You may also add these value ### Case Insensitive Routing -You can optionally allow case insensitive routing by setting the _CaseInsensitive_ property on the router to true. -This allows you to make all routes case insensitive. For example: +You can optionally allow case-insensitive routing by setting the _CaseInsensitive_ property on the router to true. +This allows you to make all routes case-insensitive. For example: ```go router := httptreemux.New() router.CaseInsensitive router.GET("/My-RoUtE", pageHandler) ``` -In this example, performing a GET request to /my-route will match the route and execute the _pageHandler_ functionality. +In this example, performing a GET request to /my-route will match the route and execute the _pageHandler_ functionality. +It's important to note that when using case-insensitive routing, the CaseInsensitive property must be set before routes are defined or there may be unexpected side effects. #### Rationale/Usage On a POST request, most browsers that receive a 301 will submit a GET request to the redirected URL, meaning that any data will likely be lost. If you want to handle and avoid this behavior, you may use Redirect307, which causes most browsers to resubmit the request using the original method and request body.