diff --git a/route.go b/route.go index 2468a8816..af3fd1f26 100644 --- a/route.go +++ b/route.go @@ -9,6 +9,7 @@ import ( "fmt" "reflect" "runtime" + "strings" ) // Route contains information to adding/registering new route with the router. @@ -81,24 +82,29 @@ func (r RouteInfo) Clone() RouteInfo { // Reverse reverses route to URL string by replacing path parameters with given params values. func (r RouteInfo) Reverse(pathValues ...any) string { uri := new(bytes.Buffer) - ln := len(pathValues) - n := 0 - for i, l := 0, len(r.Path); i < l; i++ { - hasBackslash := r.Path[i] == '\\' - if hasBackslash && i+1 < l && r.Path[i+1] == ':' { - i++ // backslash before colon escapes that colon. in that case skip backslash - } - if n < ln && (r.Path[i] == anyLabel || (!hasBackslash && r.Path[i] == paramLabel)) { - // in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path - for ; i < l && r.Path[i] != '/'; i++ { + nextValue := 0 + walkRoutePath(r.Path, func(part routePathPart) { + switch part.kind { + case staticKind: + uri.WriteString(part.value) + case paramKind: + if nextValue < len(pathValues) { + fmt.Fprint(uri, pathValues[nextValue]) + nextValue++ + } else { + // placeholder for a missing value. An escaped colon in a param name is written without its backslash. + uri.WriteByte(paramLabel) + uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":")) + } + case anyKind: + if nextValue < len(pathValues) { + fmt.Fprint(uri, pathValues[nextValue]) + nextValue++ + } else { + uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":")) } - fmt.Fprintf(uri, "%v", pathValues[n]) - n++ - } - if i < l { - uri.WriteByte(r.Path[i]) } - } + }) return uri.String() } diff --git a/route_path.go b/route_path.go new file mode 100644 index 000000000..05bccb291 --- /dev/null +++ b/route_path.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import "strings" + +// routePathPart is one parsed piece of a route pattern. A backslash before a +// colon makes the colon static. After a parameter name it starts an inline verb +// (`/:name\:cancel`) when the rest of that path segment is static. +type routePathPart struct { + kind kind + value string +} + +func parseRoutePath(path string) []routePathPart { + var parts []routePathPart + walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) }) + return parts +} + +// walkRoutePath is the common syntax scanner. Reverse uses it directly to +// avoid allocating a parts slice for each URL it builds. +func walkRoutePath(path string, emit func(routePathPart)) { + for i := 0; i < len(path); { + if isEscapedColon(path, i) { + emit(routePathPart{kind: staticKind, value: ":"}) + i += 2 + } else if path[i] == ':' { + start := i + 1 + i = start + plainName := true // an escaped colon only starts an inline verb after a name without ':' or '*' + for i < len(path) && path[i] != '/' { + if isEscapedColon(path, i) { + if plainName && isInlineVerb(path[i+2:]) { + break + } + // not an inline verb: the rest of the segment is the param name, as before inline verbs + for i < len(path) && path[i] != '/' { + i++ + } + break + } + if path[i] == ':' || path[i] == '*' { + plainName = false + } + i++ + } + emit(routePathPart{kind: paramKind, value: path[start:i]}) + } else if path[i] == '*' { + start := i + for i < len(path) && path[i] != '/' { + i++ + } + emit(routePathPart{kind: anyKind, value: path[start:i]}) + } else { + start := i + for i < len(path) && path[i] != ':' && path[i] != '*' && !isEscapedColon(path, i) { + i++ + } + emit(routePathPart{kind: staticKind, value: path[start:i]}) + } + } +} + +func isEscapedColon(path string, i int) bool { + return path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' +} + +// isInlineVerb reports whether the route text after an escaped colon stays +// static up to the end of its path segment. Only then can the router find where +// the parameter value ends by trying the colons in the requested segment. Other +// escaped colons keep the older meaning and remain part of the parameter name. +func isInlineVerb(rest string) bool { + for i := 0; i < len(rest) && rest[i] != '/'; i++ { + switch rest[i] { + case '*': + return false + case ':': + if i == 0 || rest[i-1] != '\\' { + return false + } + } + } + return true +} + +func routeTreePath(parts []routePathPart) (string, []int) { + var path strings.Builder + var paramMarkers []int + for _, part := range parts { + switch part.kind { + case staticKind: + path.WriteString(part.value) + case paramKind: + paramMarkers = append(paramMarkers, path.Len()) + path.WriteByte(paramLabel) + case anyKind: + path.WriteByte(anyLabel) + return path.String(), paramMarkers + } + } + return path.String(), paramMarkers +} diff --git a/route_syntax_test.go b/route_syntax_test.go new file mode 100644 index 000000000..34d606a37 --- /dev/null +++ b/route_syntax_test.go @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func assertRouteResponse(t *testing.T, e *Echo, path string, want string) { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if !assert.NotPanics(t, func() { e.ServeHTTP(rec, req) }) { + return + } + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, want, rec.Body.String()) +} + +func TestRouterInlineVerbRoutes(t *testing.T) { + for _, order := range [][]string{{"cancel", "get"}, {"get", "cancel"}} { + e := New() + for _, verb := range order { + e.GET("/r/:name\\:"+verb, func(c *Context) error { + return c.String(http.StatusOK, verb+":"+c.Param("name")) + }) + } + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertRouteResponse(t, e, "/r/foo:get", "get:foo") + assertRouteResponse(t, e, "/r/foo:bar:cancel", "cancel:foo:bar") + } +} + +func TestRouterInlineVerbLongestSuffix(t *testing.T) { + e := New() + e.GET(`/r/:name\:foo\:bar`, func(c *Context) error { + return c.String(http.StatusOK, "long:"+c.Param("name")) + }) + e.GET(`/r/:name\:bar`, func(c *Context) error { + return c.String(http.StatusOK, "short:"+c.Param("name")) + }) + assertRouteResponse(t, e, "/r/a:foo:bar", "long:a") + assertRouteResponse(t, e, "/r/a:bar", "short:a") +} + +func TestRouterInlineVerbWithFollowingParam(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/:action`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("action")) + }) + assertRouteResponse(t, e, "/r/foo:cancel/bar", "foo:bar") +} + +func TestRouterInlineVerbWithWildcard(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/foo:cancel/bar", "foo:bar") + assertRouteResponse(t, e, "/r/foo:cancel/", "foo:") +} + +func TestRouterInlineVerbAndGenericParam(t *testing.T) { + e := New() + e.GET("/r/:name", func(c *Context) error { + return c.String(http.StatusOK, "generic:"+c.Param("name")) + }) + e.GET(`/r/:name\:cancel`, func(c *Context) error { + return c.String(http.StatusOK, "cancel:"+c.Param("name")) + }) + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") +} + +func TestRouterRemoveEscapedColonAndReadd(t *testing.T) { + e := New() + static := func(c *Context) error { return c.String(http.StatusOK, "static") } + e.GET(`/a\:b`, static) + e.GET("/a:id", func(c *Context) error { + return c.String(http.StatusOK, "param:"+c.Param("id")) + }) + assertRouteResponse(t, e, "/a:b", "static") + assert.NoError(t, e.Router().Remove(http.MethodGet, `/a\:b`)) + assertRouteResponse(t, e, "/a:b", "param::b") + e.GET(`/a\:b`, static) + assertRouteResponse(t, e, "/a:b", "static") +} + +func TestRouterRemoveInlineVerbAndReadd(t *testing.T) { + e := New() + cancel := func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) } + e.GET(`/r/:name\:cancel`, cancel) + e.GET(`/r/:name\:get`, func(c *Context) error { + return c.String(http.StatusOK, "get:"+c.Param("name")) + }) + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:name\:cancel`)) + assertRouteResponse(t, e, "/r/foo:get", "get:foo") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + e.GET(`/r/:name\:cancel`, cancel) + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") +} + +func TestRouteInfoReverseInlineVerb(t *testing.T) { + ri := RouteInfo{Path: `/r/:name\:cancel`} + assert.Equal(t, "/r/foo:cancel", ri.Reverse("foo")) + assert.Equal(t, "/r/:name:cancel", ri.Reverse()) +} + +func TestRouterInlineVerbBacktracksToGenericRoute(t *testing.T) { + e := New() + e.GET(`/r/:name\:ab/:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + assertRouteResponse(t, e, "/r/q:abc/info", "q:abc") + assertRouteResponse(t, e, "/r/q:ab/info", "q:ab") + + e = New() + e.GET(`/r/:name\:y/:p/z`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + assertRouteResponse(t, e, "/r/q:y/info", "q:y") +} + +func TestRouterInlineVerbMethodFallback(t *testing.T) { + e := New() + e.GET(`/r/:id`, func(c *Context) error { return c.String(http.StatusOK, c.Param("id")) }) + e.POST(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + assertRouteResponse(t, e, "/r/foo:cancel", "foo:cancel") +} + +func TestRouterInlineVerbRequiresNonemptyParameter(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, c.Param("name")) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/:cancel", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRouterInlineVerbAndStaticSibling(t *testing.T) { + e := New() + e.GET(`/r/:name\:x/:id`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+":"+c.Param("id")) }) + e.GET(`/r/:name/q`, func(c *Context) error { return c.String(http.StatusOK, "static:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/a:x/q", "verb:a:q") + assertRouteResponse(t, e, "/r/a:y/q", "static:a:y") +} + +func TestRouterInlineVerbMustEndPathSegment(t *testing.T) { + // An escaped colon that is followed by a param or wildcard in the same segment keeps its older meaning: it is part + // of the param name. Trying every colon in a request segment for such routes could not be bounded. + e := New() + ri, err := e.AddRoute(Route{Method: http.MethodGet, Path: `/r/:name\:x:id`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`name\:x:id`}, ri.Parameters) + ri, err = e.AddRoute(Route{Method: http.MethodGet, Path: `/s/:name\:x*`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`name\:x*`}, ri.Parameters) + // the first escaped colon decides, so a later one that is followed only by static text does not start a verb + ri, err = e.AddRoute(Route{Method: http.MethodGet, Path: `/t/:a\:x:y\:z`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`a\:x:y\:z`}, ri.Parameters) + assert.Equal(t, "/t/:a:x:y:z", ri.Reverse()) +} + +func TestRouterInlineVerbBeforeWholeSegment(t *testing.T) { + // a matching inline verb split is tried before the whole segment, also when a wildcard follows the verb + e := New() + e.GET(`/r/:name\:x/*`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+"|"+c.Param("*")) }) + e.GET(`/r/:id/info`, func(c *Context) error { return c.String(http.StatusOK, "info:"+c.Param("id")) }) + assertRouteResponse(t, e, "/r/a:x/info", "verb:a|info") + assertRouteResponse(t, e, "/r/a:y/info", "info:a:y") +} + +func TestRouterInlineVerbRetriedAfterWildcard(t *testing.T) { + // a wildcard ends the search, but the split above it is still retried with the next split and the whole segment + e := New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n/*`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")+"|"+c.Param("*")) }) + assertRouteResponse(t, e, "/r/a:v/q", "get:a:v|q") + + e = New() + e.POST(`/r/:n\:a\:b/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:b/x`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")) }) + assertRouteResponse(t, e, "/r/q:a:b/x", "get:q:a") + + e = New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a:v/q", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestRouterRemoveInlineVerbsSharingPrefix(t *testing.T) { + e := New() + h := func(c *Context) error { return c.String(http.StatusOK, c.Path()+"|"+c.Param("n")) } + e.GET(`/r/:n\:cancel`, h) + e.GET(`/r/:n\:close`, h) + e.GET(`/r/:n/x`, h) + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:n\:cancel`)) + assertRouteResponse(t, e, "/r/a:close", `/r/:n\:close|a`) + assertRouteResponse(t, e, "/r/a:b/x", "/r/:n/x|a:b") + assert.NoError(t, e.Router().Remove(http.MethodGet, `/r/:n\:close`)) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a:close", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + e.GET(`/r/:n\:close`, h) + assertRouteResponse(t, e, "/r/a:close", `/r/:n\:close|a`) + assertRouteResponse(t, e, "/r/a:b/x", "/r/:n/x|a:b") +} + +func TestRouterInlineVerbLeafParamAfterVerb(t *testing.T) { + e := New() + e.GET(`/r/:name\:x/:rest`, func(c *Context) error { return c.String(http.StatusOK, c.Param("name")+"|"+c.Param("rest")) }) + assertRouteResponse(t, e, "/r/a:x/b/c", "a|b/c") +} + +func TestRouterInlineVerbWithGroupMiddlewareAndCatchAll(t *testing.T) { + e := New() + g := e.Group("/r", func(next HandlerFunc) HandlerFunc { return next }) + g.GET("/:name", func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + g.GET(`/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") + + e = New() + e.GET("/r/:name", func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET("/*", func(c *Context) error { return c.String(http.StatusOK, "any") }) + assertRouteResponse(t, e, "/r/foo:other", "generic:foo:other") + assertRouteResponse(t, e, "/r/foo:cancel", "cancel:foo") +} + +func TestRouterInlineVerbManyColons(t *testing.T) { + // Every colon in the segment is a possible split. Each is tried at most once, so a long run of colons is routed + // in linear time. + e := New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET(`/r/:name\:c`, func(c *Context) error { return c.String(http.StatusOK, "c:"+c.Param("name")) }) + e.GET(`/r/:name\:x/:a\:y/z`, func(c *Context) error { return c.String(http.StatusOK, "nested") }) + colons := strings.Repeat(":", 1<<16) + start := time.Now() + defer func() { + // linear routing takes milliseconds here; trying splits quadratically would take minutes + assert.Less(t, time.Since(start), 10*time.Second) + }() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"x/b"+colons+"y/nope", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // every ":c" enters the shared ":c" verb node before failing, so each split is retried + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+strings.Repeat(":c", 1<<15)+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + assertRouteResponse(t, e, "/r/a"+colons+"cancel", "cancel:a"+colons[1:]) +} + +func TestRouterInlineVerbMethodNotAllowedWithoutFallback(t *testing.T) { + e := New() + e.POST(`/r/:name\:cancel`, func(c *Context) error { return c.NoContent(http.StatusOK) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestRouterInlineVerbWildcardBacktracksBelowSplit(t *testing.T) { + // after a wildcard below a split fails, the other routes below that split are tried before the next split + e := New() + e.POST(`/r/:n\:v/a/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:v/:p/b`, func(c *Context) error { return c.String(http.StatusOK, "verb:"+c.Param("n")+"|"+c.Param("p")) }) + e.GET(`/r/:n/a/b`, func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("n")) }) + assertRouteResponse(t, e, "/r/q:v/a/b", "verb:q|a") + + // nested splits: the nearest pending split is retried first, then the outer one + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b\:y/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/p:x/q:y/z", "p:x|q|z") + + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a\:x/:b/*`, func(c *Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertRouteResponse(t, e, "/r/p:x/q:y/z", "p|q:y|z") + + // a RouteNotFound wildcard below a split handles the request like any other RouteNotFound route + e = New() + e.RouteNotFound(`/r/:a\:x/*`, func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("a")) }) + e.GET(`/r/:a/k`, func(c *Context) error { return c.String(http.StatusOK, "k") }) + assertRouteResponse(t, e, "/r/p:x/k", "not found:p") +} + +func TestRouterRemoveRouteSharingNode(t *testing.T) { + e := New() + e.GET("/u/:id", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("id")) }) + e.POST("/u/:uid", func(c *Context) error { return c.String(http.StatusOK, "post:"+c.Param("uid")) }) + assert.Error(t, e.Router().Remove(http.MethodGet, "/u/:uid")) + assertRouteResponse(t, e, "/u/1", "get:1") + assert.NoError(t, e.Router().Remove(http.MethodGet, "/u/:id")) + assert.Len(t, e.Router().Routes(), 1) + + e.GET("x", func(c *Context) error { return c.NoContent(http.StatusOK) }) + assert.NoError(t, e.Router().Remove(http.MethodGet, "x")) + assert.Len(t, e.Router().Routes(), 1) +} + +func TestRouterInlineVerbMisc(t *testing.T) { + e := New() + e.POST(`/r/:n\:v/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.RouteNotFound(`/r/:n/*`, func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("n")) }) + // the whole segment reaches the RouteNotFound route, as a static sibling would + assertRouteResponse(t, e, "/r/a:v/q", "not found:a:v") + + // with routing on the escaped path, an encoded colon is not a verb delimiter + e = New() + e.GET(`/r/:name\:cancel`, func(c *Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name`, func(c *Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + assertRouteResponse(t, e, "/r/foo%3Acancel", "generic:foo%3Acancel") + + ri := RouteInfo{Path: `/r/:n\:v/*`} + assert.Equal(t, "/r/:n:v/*", ri.Reverse()) + assert.Equal(t, "/r/a:v/b/c", ri.Reverse("a", "b/c")) + + // a param name with ':' keeps an escaped colon as part of the name + ri, err := e.AddRoute(Route{Method: http.MethodGet, Path: `/s/:a:b\:v`, Handler: func(c *Context) error { return nil }}) + assert.NoError(t, err) + assert.Equal(t, []string{`a:b\:v`}, ri.Parameters) +} + +func TestRouterInlineVerbKeepsLeafParam(t *testing.T) { + // a param with only an inline verb child still takes the rest of the path when no split matches + e := New() + e.GET("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload:"+c.Param("name")) }) + assertRouteResponse(t, e, "/files/a/b", "get:a/b") + assertRouteResponse(t, e, "/files/a:upload/b", "get:a:upload/b") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/files/a:upload", nil)) + assert.Equal(t, "upload:a", rec.Body.String()) + + // with another child the param stops at the slash, as before + e.GET("/files/:path/meta", func(c *Context) error { return c.String(http.StatusOK, "meta:"+c.Param("path")) }) + assertRouteResponse(t, e, "/files/a/meta", "meta:a") +} + +func TestRouterInlineVerbPendingAboveParam(t *testing.T) { + // the pending split is found above a param without a split + e := New() + e.POST(`/r/:a\:v/:b/*`, func(c *Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b/q`, func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("a")+"|"+c.Param("b")) }) + assertRouteResponse(t, e, "/r/x:v/y/q", "get:x:v|y") +} + +func TestRouterInlineVerbKeepsLeafParamFallbacks(t *testing.T) { + e := New() + e.GET("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, "/files/a/b", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow)) + + e = New() + e.RouteNotFound("/files/:path", func(c *Context) error { return c.String(http.StatusOK, "not found:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + assertRouteResponse(t, e, "/files/a/b", "not found:a/b") + + e = New() + e.POST(`/files/:name\:upload`, func(c *Context) error { return c.String(http.StatusOK, "upload") }) + e.GET("/files/*", func(c *Context) error { return c.String(http.StatusOK, "any:"+c.Param("*")) }) + assertRouteResponse(t, e, "/files/a/b", "any:a/b") +} diff --git a/router.go b/router.go index c30c8aa4f..599c561a5 100644 --- a/router.go +++ b/router.go @@ -8,6 +8,8 @@ import ( "errors" "net/http" "net/url" + "slices" + "strings" ) // Router is interface for routing request contexts to registered routes. @@ -167,12 +169,13 @@ type node struct { // scLabels holds the first byte (label) of each staticChildren entry in the // same order. Scanning this contiguous byte slice during routing is more // cache-friendly than dereferencing each *node to read its label. - scLabels []byte - paramsCount int - kind kind - label byte - isLeaf bool - isHandler bool + scLabels []byte + paramsCount int + kind kind + label byte + isLeaf bool + isHandler bool + hasColonChild bool } type kind uint8 @@ -387,40 +390,27 @@ func (r *DefaultRouter) Remove(method string, path string) error { return errors.New("router has no routes to remove") } - if path == "" { - path = "/" - } - if path[0] != '/' { - path = "/" + path - } + path = normalizePathSlash(path) + treePath, paramMarkers := routeTreePath(parseRoutePath(path)) var nodeToRemove *node - prefixLen := 0 + search := treePath + searchOffset := 0 for { - if currentNode.originalPath == path && currentNode.isHandler { - nodeToRemove = currentNode + if !strings.HasPrefix(search, currentNode.prefix) { break } - if currentNode.kind == staticKind { - prefixLen = prefixLen + len(currentNode.prefix) - } else { - prefixLen = len(currentNode.originalPath) - } + search = search[len(currentNode.prefix):] + searchOffset += len(currentNode.prefix) - if prefixLen >= len(path) { + if search == "" { + if currentNode.isHandler { + nodeToRemove = currentNode + } break } - next := path[prefixLen] - switch next { - case paramLabel: - currentNode = currentNode.paramChild - case anyLabel: - currentNode = currentNode.anyChild - default: - currentNode = currentNode.findStaticChild(next) - } - + currentNode = currentNode.findChildWithLabel(search[0], slices.Contains(paramMarkers, searchOffset)) if currentNode == nil { break } @@ -434,18 +424,23 @@ func (r *DefaultRouter) Remove(method string, path string) error { return errors.New("could not find route to remove by given path") } - if mh := nodeToRemove.methods.find(method, false, false); mh == nil { + // routes with different param names can share a node, so the path must be the one registered for this method + mh := nodeToRemove.methods.find(method, false, false) + if mh == nil || mh.Path != path { return errors.New("could not find route to remove by given path and method") } - nodeToRemove.setHandler(method, nil) - var rIndex int + rIndex := -1 for i, rr := range r.routes { - if rr.Method == method && rr.Path == path { + if rr.Method == method && rr.Path == mh.orgRouteInfo.Path { rIndex = i break } } + if rIndex < 0 { + return errors.New("could not find route to remove by given path and method") + } + nodeToRemove.setHandler(method, nil) r.routes = append(r.routes[:rIndex], r.routes[rIndex+1:]...) if !nodeToRemove.isHandler && nodeToRemove.isLeaf { @@ -467,6 +462,9 @@ func (r *DefaultRouter) Remove(method string, path string) error { } parent.staticChildren = append(parent.staticChildren[:index], parent.staticChildren[index+1:]...) parent.scLabels = append(parent.scLabels[:index], parent.scLabels[index+1:]...) + if current.label == ':' { + parent.hasColonChild = false + } case paramKind: parent.paramChild = nil case anyKind: @@ -535,72 +533,48 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { headH = wrapHeadHandler(h) } - paramNames := make([]string, 0) - // Positions of parameter markers after names are removed. Literal colons - // remain ordinary path bytes, so no sentinel byte is reserved. - paramMarkers := make([]int, 0) - originalPath := path - wasAdded := false - var ri RouteInfo - for i, lcpIndex := 0, len(path); i < lcpIndex; i++ { - if path[i] == paramLabel { - if i > 0 && path[i-1] == '\\' { - path = path[:i-1] + path[i:] - i-- - lcpIndex-- - continue - } - j := i + 1 - - r.insert(staticKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) - for ; i < lcpIndex && path[i] != '/'; i++ { - } - - paramNames = append(paramNames, path[j:i]) - paramMarkers = append(paramMarkers, j-1) - path = path[:j] + path[i:] - i, lcpIndex = j, len(path) - - if i == lcpIndex { - // path node is last fragment of route path. ie. `/users/:id` - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, - } - r.insert(paramKind, path[:i], method, rm, paramMarkers) - wasAdded = true - break - } else { - r.insert(paramKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) - } - } else if path[i] == anyLabel { - r.insert(staticKind, path[:i], method, routeMethod{RouteInfo: &RouteInfo{Method: method}}, paramMarkers) + parts := parseRoutePath(path) + var paramNames []string + for _, part := range parts { + if part.kind == paramKind { + paramNames = append(paramNames, part.value) + } else if part.kind == anyKind { paramNames = append(paramNames, "*") - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, - } - r.insert(anyKind, path[:i+1], method, rm, paramMarkers) - wasAdded = true break } } - - if !wasAdded { - ri = route.ToRouteInfo(paramNames) - rm := routeMethod{ - RouteInfo: &RouteInfo{Method: method, Path: originalPath, Parameters: paramNames, Name: route.Name}, - handler: h, - orgRouteInfo: ri, - wrappedHeadHandler: headH, + ri := route.ToRouteInfo(paramNames) + rm := routeMethod{ + RouteInfo: &RouteInfo{Method: method, Path: path, Parameters: paramNames, Name: route.Name}, + handler: h, + orgRouteInfo: ri, + wrappedHeadHandler: headH, + } + partial := routeMethod{RouteInfo: &RouteInfo{Method: method}} + treePath, paramMarkers := routeTreePath(parts) + pathEnd := 0 +addParts: + for i, part := range parts { + switch part.kind { + case staticKind: + pathEnd += len(part.value) + if i == len(parts)-1 { + r.insert(staticKind, treePath[:pathEnd], method, rm, paramMarkers) + } + case paramKind: + r.insert(staticKind, treePath[:pathEnd], method, partial, paramMarkers) + pathEnd++ + if i == len(parts)-1 { + r.insert(paramKind, treePath[:pathEnd], method, rm, paramMarkers) + } else { + r.insert(paramKind, treePath[:pathEnd], method, partial, paramMarkers) + } + case anyKind: + r.insert(staticKind, treePath[:pathEnd], method, partial, paramMarkers) + pathEnd++ + r.insert(anyKind, treePath[:pathEnd], method, rm, paramMarkers) + break addParts } - r.insert(staticKind, path, method, rm, paramMarkers) } r.storeRouteInfo(ri) @@ -724,14 +698,7 @@ func (r *DefaultRouter) insert(t kind, path string, method string, ri routeMetho } else if lcpLen < searchLen { searchOffset += lcpLen search = search[lcpLen:] - isParamMarker := false - for _, marker := range paramMarkers { - if marker == searchOffset { - isParamMarker = true - break - } - } - c := currentNode.findChildWithLabel(search[0], isParamMarker) + c := currentNode.findChildWithLabel(search[0], slices.Contains(paramMarkers, searchOffset)) if c != nil { // Go deeper currentNode = c @@ -812,6 +779,56 @@ func (n *node) refreshLeaf() { func (n *node) addStaticChild(c *node) { n.staticChildren = append(n.staticChildren, c) n.scLabels = append(n.scLabels, c.label) + // param nodes are never split (their prefix is a single byte), so this is where their inline verb child is set + if n.kind == paramKind && c.label == ':' { + n.hasColonChild = true + } +} + +// hasPendingInlineVerbSplit reports whether a param node from n up to the root has a value in pathValues that ended at +// an inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. It does +// not change that state, so a request that ends here keeps its param values. +func hasPendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, pathValues PathValues) bool { + for ; n != nil; n = n.parent { + if n.hasColonChild && searchIndex < len(path) && path[searchIndex] == ':' { + return true + } + if n.kind == staticKind { + searchIndex -= len(n.prefix) + } else { + paramIndex-- + searchIndex -= len(pathValues[paramIndex].Value) + } + } + return false +} + +// inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this +// node's inline verb child could match, otherwise at the end of the path segment (or of the path when that child is the +// node's only child). A split value is never empty. The scan stops at the next slash, so trying every split of a +// segment in turn is linear in its length. +// +// A split is only chosen when the whole prefix of the inline verb child matches. Routing therefore never backtracks +// into the param node from a prefix mismatch of that child, and the split only needs to be retried when backtracking +// from within the child's subtree. +func (n *node) inlineVerbSplit(search string, from int) int { + verbs := n.findStaticChild(':') + for i := from; i < len(search); i++ { + switch search[i] { + case '/': + if len(n.staticChildren) == 1 { + // the inline verb child is the only child (a param node never has a param or any child): without a + // split the param takes the rest of the path, as a leaf param does + return len(search) + } + return i + case ':': + if i > 0 && verbs != nil && strings.HasPrefix(search[i:], verbs.prefix) { + return i + } + } + } + return len(search) } func (n *node) findStaticChild(l byte) *node { @@ -1037,9 +1054,12 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { // when param node does not have any children (path param is last piece of route path) then param node should // act similarly to any node - consider all remaining search as match i = l - } else { - for ; i < l && search[i] != '/'; i++ { - } + } else if currentNode.hasColonChild { + // an inline verb (`/:name\:verb`) can end the param value at a literal colon. Start with the first + // possible split, the param node is retried with the next one before backtracking (see below). + i = currentNode.inlineVerbSplit(search, 0) + } else if i = strings.IndexByte(search, '/'); i < 0 { + i = l } pathValues[paramIndex].Value = search[:i] @@ -1076,16 +1096,36 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { // Let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(anyKind) + Backtracked: if !ok { break // No other possibilities on the decision path } else if nk == paramKind { + if currentNode.hasColonChild && search != "" && search[0] == ':' { + goto InlineVerbSplit + } goto Param } else if nk == anyKind { goto Any + } else if hasPendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, pathValues) { + // A wildcard ends the search, except below a param value that ended at an inline verb split: keep + // backtracking, so the other routes below that split and then the next split are still tried. + nk, ok = backtrackToNextNodeKind(anyKind) + goto Backtracked } else { // Not found break } + + InlineVerbSplit: + // A param value that ended at an inline verb split is a decision point of the param node itself. When its + // inline verb child fails, retry the node with the next split, and finally with the whole path segment, + // before backtracking to its parent. + pv := &pathValues[paramIndex-1] + start := searchIndex - len(pv.Value) + searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(pv.Value)+1) + pv.Value = path[start:searchIndex] + search = path[searchIndex:] + continue } if currentNode == nil && previousBestMatchNode == nil {