Skip to content
36 changes: 20 additions & 16 deletions route.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,28 @@ 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 {
uri.WriteByte(paramLabel)
uri.WriteString(part.value)
}
case anyKind:
if nextValue < len(pathValues) {
fmt.Fprint(uri, pathValues[nextValue])
nextValue++
} else {
uri.WriteString(part.value)
}
fmt.Fprintf(uri, "%v", pathValues[n])
n++
}
if i < l {
uri.WriteByte(r.Path[i])
}
}
})
return uri.String()
}

Expand Down
82 changes: 82 additions & 0 deletions route_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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, including when it follows a parameter name.
type routePathPart struct {
kind kind
value string
}

func hasInlineVerbPart(parts []routePathPart) bool {
for i := 1; i < len(parts); i++ {
if parts[i-1].kind == paramKind && parts[i].kind == staticKind && parts[i].value == ":" {
return true
}
}
return false
}

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 path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' {
emit(routePathPart{kind: staticKind, value: ":"})
i += 2
} else if path[i] == ':' {
start := i + 1
i = start
for i < len(path) && path[i] != '/' {
if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' {
break
}
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] != '*' {
if path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' {
break
}
i++
}
emit(routePathPart{kind: staticKind, value: path[start:i]})
}
}
}

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
}
157 changes: 157 additions & 0 deletions route_syntax_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors

package echo

import (
"net/http"
"net/http/httptest"
"testing"

"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 {
verb := verb
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")

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 TestRouterInlineVerbKeepsStaticSiblingPriority(t *testing.T) {
e := New()
e.GET(`/r/:name\:x:id`, func(c *Context) error { return c.String(http.StatusOK, "verb") })
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", "static:a:x")
}

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)
}
Loading
Loading