diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 202f00af..75d61b29 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,13 +3,16 @@ on: push: workflow_dispatch: +permissions: + contents: read + jobs: test: name: Test Fox runs-on: ubuntu-latest strategy: matrix: - go: [ '>=1.24' ] + go: [ '>=1.26' ] steps: - name: Set up Go uses: actions/setup-go@v6 @@ -19,6 +22,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Run tests run: go test -v -coverprofile=coverage.txt -covermode=atomic ./... @@ -37,7 +42,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [ '>=1.24' ] + go: [ '>=1.26' ] steps: - name: Set up Go uses: actions/setup-go@v6 @@ -47,6 +52,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Run linter uses: golangci/golangci-lint-action@v9 diff --git a/error.go b/error.go index 21650fa2..113eae4b 100644 --- a/error.go +++ b/error.go @@ -19,10 +19,7 @@ var ( ErrNoClientIPResolver = errors.New("no client ip resolver") ErrReadOnlyTxn = errors.New("write on read-only transaction") ErrSettledTxn = errors.New("transaction settled") - ErrParamKeyTooLarge = errors.New("parameter key too large") - ErrTooManyParams = errors.New("too many params") ErrTooManyMatchers = errors.New("too many matchers") - ErrRegexpNotAllowed = errors.New("regexp not allowed") ErrInvalidConfig = errors.New("invalid config") ErrInvalidMatcher = errors.New("invalid matcher") ) @@ -44,7 +41,7 @@ func (e *RouteConflictError) Error() string { routef(sb, e.New, 4, true) if e.isShadowed { - if e.New.catchEmpty { + if e.New.pattern.optionalCatchAll { sb.WriteString("\nis shadowed by") } else { sb.WriteString("\nwould shadow") @@ -96,3 +93,58 @@ func newRouteNotFoundError(route *Route) error { sb.WriteString("\nis not registered") return fmt.Errorf("%w: %s", ErrRouteNotFound, sb.String()) } + +type PatternError struct { + err error // wrapped error + Pattern string // provided pattern + Type string // hostname | path + Reason string // syntax | parameter | regexp | constraint + Hint string // hint + Start int // start offset of the offending segment + End int // end offset of the offending segment +} + +// Unwrap returns the underlying error, if any. +func (e *PatternError) Unwrap() error { + return e.err +} + +// Error returns a human-readable error message with a visual pointer to the offending segment. +func (e *PatternError) Error() string { + var sb strings.Builder + sb.WriteString("pattern: ") + if e.Type != "" { + sb.WriteString(e.Type) + sb.WriteString(": ") + } + sb.WriteString(e.Reason) + sb.WriteString(": ") + sb.WriteString(e.Hint) + if e.Pattern != "" { + sb.WriteByte('\n') + sb.WriteString(" ") + sb.WriteString(e.Pattern) + sb.WriteByte('\n') + sb.WriteString(" ") + for i := 0; i < e.Start; i++ { + sb.WriteByte(' ') + } + n := e.End - e.Start + if n <= 0 { + n = 1 + } + for i := 0; i < n; i++ { + sb.WriteByte('^') + } + } + return sb.String() +} + +func newPatternError(reason string, start, end int, msg string) *PatternError { + return &PatternError{ + Reason: reason, + Start: start, + End: end, + Hint: msg, + } +} diff --git a/fox.go b/fox.go index 4780eb37..3482080b 100644 --- a/fox.go +++ b/fox.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "path" - "regexp" "slices" "strings" "sync" @@ -195,9 +194,10 @@ func (fox *Router) MustAdd(methods []string, pattern string, handler HandlerFunc // Add registers a new route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteConflict]: If the route conflict with others. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // @@ -233,9 +233,10 @@ func (fox *Router) AddRoute(route *Route) error { // Update override an existing route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // @@ -273,8 +274,10 @@ func (fox *Router) UpdateRoute(route *Route) error { } // Delete deletes an existing route for the given methods, pattern and matchers. On success, it returns the deleted [Route]. +// If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid or the pattern is empty. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // // It's safe to delete a handler while the router is serving requests. This function is safe for concurrent use by @@ -326,7 +329,7 @@ func (fox *Router) Route(methods []string, pattern string, matchers ...Matcher) return nil } idx := slices.IndexFunc(matched.routes, func(r *Route) bool { - return r.pattern == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) + return r.pattern.str == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) }) if idx < 0 { return nil @@ -387,7 +390,7 @@ func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc * idx, n, tsr := tree.lookup(r.Method, r.Host, path, c, false) if n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params return c.route, c, tsr } @@ -398,7 +401,8 @@ func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc * // NewRoute create a new [Route], configured with the provided options. // If an error occurs, it returns one of the following: -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [*PatternError]: If the pattern syntax is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFunc, opts ...RouteOption) (*Route, error) { @@ -412,7 +416,7 @@ func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFun } } - parsed, err := fox.parseRoute(pattern) + pat, paramsCnt, err := fox.parsePattern(pattern) if err != nil { return nil, err } @@ -420,15 +424,12 @@ func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFun rte := &Route{ clientip: fox.clientip, hbase: handler, - pattern: pattern, + pattern: pat, handleSlash: fox.handleSlash, - hostEnd: parsed.endHost, - tokens: parsed.token, - catchEmpty: parsed.startCatchAll > 0 && pattern[parsed.startCatchAll] == starDelim, } - rte.params = make([]string, 0, parsed.paramCnt) - for _, tk := range parsed.token { + rte.params = make([]string, 0, paramsCnt) + for _, tk := range pat.tokens { if tk.typ != nodeStatic { rte.params = append(rte.params, tk.value) } @@ -597,7 +598,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { idx, n, tsr := tree.lookup(r.Method, r.Host, path, c, false) if !tsr && n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params c.route.hall(c) tree.pool.Put(c) @@ -609,7 +610,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { route := n.routes[idx] if route.handleSlash == RelaxedSlash { c.route = route - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params route.hall(c) tree.pool.Put(c) @@ -632,7 +633,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { *c.params = (*c.params)[:0] if idx, n, tsr := tree.lookup(r.Method, r.Host, CleanPath(path), c, false); n != nil && (!tsr || n.routes[idx].handleSlash == RelaxedSlash) { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params c.route.hall(c) tree.pool.Put(c) @@ -798,7 +799,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if !tsr && n != nil { c.route = n.routes[idx] *c.paramsKeys = append(*c.paramsKeys, c.route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str c.route.hall(c) return } @@ -809,7 +810,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if route.handleSlash == RelaxedSlash { c.route = route *c.paramsKeys = append(*c.paramsKeys, route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str route.hall(c) return } @@ -830,7 +831,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if idx, n, tsr := tree.lookupByPath(r.Method, CleanPath(path), c, false); n != nil && (!tsr || n.routes[idx].handleSlash == RelaxedSlash) { c.route = n.routes[idx] *c.paramsKeys = append(*c.paramsKeys, c.route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str c.route.hall(c) return } @@ -973,7 +974,7 @@ func Sub(router *Router) HandlerFunc { *subCtx.subPatterns = append(*subCtx.subPatterns, *c.subPatterns...) - lastTkType := route.tokens[len(route.tokens)-1].typ + lastTkType := route.pattern.tokens[len(route.pattern.tokens)-1].typ var p string switch lastTkType { case nodeWildcard: @@ -1075,401 +1076,6 @@ func internalFixedPathHandler(c *Context) { http.Redirect(c.Writer(), req, cleanedPath, code) } -const ( - stateDefault uint8 = iota - stateParam - stateCatchAll - stateRegex -) - -type parsedRoute struct { - token []token - paramCnt int - endHost int - startCatchAll int -} - -// parseRoute parse and validate the route in a single pass. -func (fox *Router) parseRoute(url string) (parsedRoute, error) { - endHost := strings.IndexByte(url, '/') - if endHost == -1 { - return parsedRoute{}, fmt.Errorf("%w: missing trailing '/' after hostname", ErrInvalidRoute) - } - if strings.HasPrefix(url, ".") { - return parsedRoute{}, fmt.Errorf("%w: illegal leading '.' in hostname label", ErrInvalidRoute) - } - if strings.HasPrefix(url, "-") { - return parsedRoute{}, fmt.Errorf("%w: illegal leading '-' in hostname label", ErrInvalidRoute) - } - - var delim byte - if endHost == 0 { - delim = slashDelim - } else { - delim = dotDelim - } - - state := stateDefault - previous := stateDefault - paramCnt := 0 - countStatic := 2 - startParam := 0 - inParam := false - nonNumeric := false // true once we've seen a letter or hyphen - startCatchAll := 0 // start index of +{foo} or *{foo} - partlen := 0 - totallen := 0 - last := dotDelim - tokens := make([]token, 0, 1) // At least one segment - sb := strings.Builder{} - - i := 0 - for i < len(url) { - switch state { - case stateParam: - if url[i] == '}' { - if !inParam { - return parsedRoute{}, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute) - } - inParam = false - - if i+1 < len(url) && url[i+1] != delim && url[i+1] != '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(url[i+1])) - } - - if i < endHost { - nonNumeric = true - } - - if previous != stateRegex { - tokens = append(tokens, token{ - typ: nodeParam, - value: url[startParam+1 : i], - }) - } - - countStatic = 1 - previous = state - state = stateDefault - i++ - continue - } - - if url[i] == ':' { - previous = state - state = stateRegex - i++ - continue - } - - if i-startParam > fox.maxParamKeyBytes { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) - } - - if url[i] == delim || url[i] == '/' || url[i] == '*' || url[i] == '+' || url[i] == '{' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in '{param}'", ErrInvalidRoute, string(url[i])) - } - inParam = true - i++ - case stateCatchAll: - if url[i] == '}' { - if !inParam { - return parsedRoute{}, fmt.Errorf("%w: missing parameter name between '%c{}'", ErrInvalidRoute, url[startCatchAll]) - } - inParam = false - - if i+1 < len(url) && url[i+1] != delim && url[i+1] != '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' after '%c{param}'", ErrInvalidRoute, string(url[i+1]), url[startCatchAll]) - } - - if previous == stateCatchAll && countStatic <= 1 { - return parsedRoute{}, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) - } - - if i < len(url)-1 { - if url[startCatchAll] == '*' { - return parsedRoute{}, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) - } - // reset - startCatchAll = 0 - } - - if i < endHost { - nonNumeric = true - } - - if previous != stateRegex { - tokens = append(tokens, token{ - typ: nodeWildcard, - value: url[startParam+1 : i], - }) - } - - countStatic = 0 - previous = state - state = stateDefault - i++ - continue - } - - if url[i] == ':' { - // Optional wildcards (*{param}) do not support regular expressions because they match - // empty strings, making it impossible to disambiguate routes with different regexps that - // both match the same empty-string path. - if url[startCatchAll] == '*' { - return parsedRoute{}, fmt.Errorf("%w: %w in optional wildcard", ErrInvalidRoute, ErrRegexpNotAllowed) - } - previous = state - state = stateRegex - i++ - continue - } - - if i-startParam > fox.maxParamKeyBytes { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) - } - - if url[i] == delim || url[i] == '/' || url[i] == '*' || url[i] == '+' || url[i] == '{' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in '%c{param}'", ErrInvalidRoute, string(url[i]), url[startCatchAll]) - } - inParam = true - i++ - case stateRegex: - if !fox.allowRegexp { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrRegexpNotAllowed) - } - if previous == stateCatchAll && countStatic <= 1 { - return parsedRoute{}, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) - } - - idx := braceIndice(url[i:], 1) - if idx == -1 { - return parsedRoute{}, fmt.Errorf("%w: unbalanced braces in regular expression", ErrInvalidRoute) - } - if idx == 0 { - return parsedRoute{}, fmt.Errorf("%w: missing regular expression", ErrInvalidRoute) - } - - pattern := url[i : i+idx] - re, err := regexp.Compile("^" + pattern + "$") - if err != nil { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, err) - } - - if re.NumSubexp() > 0 { - return parsedRoute{}, fmt.Errorf("%w: illegal capture group '%s': use (?:pattern) instead", ErrInvalidRoute, pattern) - } - - typ := nodeWildcard - if previous == stateParam { - typ = nodeParam - } - - tokens = append(tokens, token{ - typ: typ, - value: url[startParam+1 : i-1], - regexp: re, - }) - - // restore - state, previous = previous, state - i += idx - - default: - - if i == endHost { - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: true, - }) - sb.Reset() - } - delim = slashDelim - countStatic = 2 // reset - } - - switch url[i] { - case '{': - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: i < endHost, - }) - sb.Reset() - } - state = stateParam - startParam = i - paramCnt++ - case '*', '+': - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: i < endHost, - }) - sb.Reset() - } - state = stateCatchAll - startCatchAll = i - i++ - if i < len(url) && url[i] != '{' { - return parsedRoute{}, fmt.Errorf("%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, url[startCatchAll]) - } - startParam = i - paramCnt++ - default: - sb.WriteByte(url[i]) - countStatic++ - if i < endHost { - c := url[i] - switch { - case 'a' <= c && c <= 'z' || c == '_': - nonNumeric = true - partlen++ - case '0' <= c && c <= '9': - // fine - partlen++ - case c == '-': - // Byte before dash cannot be dot. - if last == '.' { - return parsedRoute{}, fmt.Errorf("%w: illegal '-' after '.' in hostname label", ErrInvalidRoute) - } - partlen++ - nonNumeric = true - case c == '.': - // Byte before dot cannot be dot. - if last == '.' && url[i-1] != '}' { - return parsedRoute{}, fmt.Errorf("%w: unexpected consecutive '.' in hostname", ErrInvalidRoute) - } - // Byte before dot cannot be dash. - if last == '-' { - return parsedRoute{}, fmt.Errorf("%w: illegal '-' before '.' in hostname label", ErrInvalidRoute) - } - if partlen > 63 { - return parsedRoute{}, fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) - } - totallen += partlen + 1 // +1 count the current dot - partlen = 0 - case 'A' <= c && c <= 'Z': - return parsedRoute{}, fmt.Errorf("%w: illegal uppercase character '%s' in hostname label", ErrInvalidRoute, string(c)) - default: - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in hostname label", ErrInvalidRoute, string(c)) - } - last = c - } else { - c := url[i] - // reject any ASCII control character. - if c < ' ' || c == 0x7f { - return parsedRoute{}, fmt.Errorf("%w: illegal control character in path", ErrInvalidRoute) - } - - // reject any consecutive slash - if i > endHost && c == '/' && url[i-1] == '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal consecutive slashes in path", ErrInvalidRoute) - } - - // reject dot-based traversal patterns - if i > endHost && c == '.' && url[i-1] == '/' { - nextIdx := i + 1 - if nextIdx < len(url) { - nextChar := url[nextIdx] - switch nextChar { - case '/': - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/./'", ErrInvalidRoute) - case '.': - nextNextIdx := nextIdx + 1 - if nextNextIdx < len(url) { - if url[nextNextIdx] == '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/../'", ErrInvalidRoute) - } - } else { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/..' at end", ErrInvalidRoute) - } - } - } else { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/.' at end", ErrInvalidRoute) - } - } - } - } - - if paramCnt > fox.maxParams { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) - } - i++ - } - } - - if endHost > 0 { - totallen += partlen - if last == '-' { - return parsedRoute{}, fmt.Errorf("%w: illegal trailing '-' in hostname label", ErrInvalidRoute) - } - if url[endHost-1] == '.' { - return parsedRoute{}, fmt.Errorf("%w: illegal trailing '.' in hostname label", ErrInvalidRoute) - } - if !nonNumeric { - return parsedRoute{}, fmt.Errorf("%w: invalid all numeric hostname", ErrInvalidRoute) - } - if partlen > 63 { - return parsedRoute{}, fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) - } - if totallen > 253 { - return parsedRoute{}, fmt.Errorf("%w: hostname exceed 253 characters", ErrInvalidRoute) - } - } - - if state == stateParam { - return parsedRoute{}, fmt.Errorf("%w: unclosed '{param}'", ErrInvalidRoute) - } - - if state == stateCatchAll { - prev := len(url) - 1 - if url[prev] == '*' || url[prev] == '+' { - return parsedRoute{}, fmt.Errorf("%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, url[prev]) - } - return parsedRoute{}, fmt.Errorf("%w: unclosed '%c{param}'", ErrInvalidRoute, url[prev]) - } - - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - }) - } - - return parsedRoute{ - token: tokens, - paramCnt: paramCnt, - endHost: endHost, - startCatchAll: startCatchAll, - }, nil -} - -// braceIndices returns the index of the closing brace that balances an opening -// brace. It starts at startLevel opened brace. -// -// Example: For pattern "{id:[0-9]{1,3}}", the caller would pass "[0-9]{1,3}}" and 1 -// (everything after the initial '{'), and this returns 10 (index of the final '}'). -func braceIndice(s string, startLevel int) int { - level := startLevel - - for i := 0; i < len(s); i++ { - switch s[i] { - case '{': - level++ - case '}': - if level--; level == 0 { - return i - } - } - } - return -1 -} - func routingPath(r *http.Request) string { if r.URL.RawPath == "" { return r.URL.EscapedPath() diff --git a/fox_test.go b/fox_test.go index d58c2df0..a40004e0 100644 --- a/fox_test.go +++ b/fox_test.go @@ -2049,7 +2049,7 @@ func TestInsertConflict(t *testing.T) { var conflict *RouteConflictError require.ErrorAs(t, got, &conflict) patterns := iterutil.Map(slices.Values(conflict.Conflicts), func(a *Route) string { - return a.pattern + return a.pattern.str }) assert.Equal(t, tc.wantMatch, slices.Collect(patterns)) }) @@ -2119,16 +2119,18 @@ func TestUpdateConflict(t *testing.T) { func TestInvalidRoute(t *testing.T) { f := MustRouter() + var pe *PatternError + // Invalid route on insert assert.ErrorIs(t, onlyError(f.Add([]string{"G\x00ET"}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Add([]string{""}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Add(MethodGet, "/foo", nil)), ErrInvalidRoute) - assert.ErrorIs(t, onlyError(f.Add(MethodGet, "/foo\x00", emptyHandler)), ErrInvalidRoute) + assert.ErrorAs(t, onlyError(f.Add(MethodGet, "/foo\x00", emptyHandler)), &pe) // Invalid route on update assert.ErrorIs(t, onlyError(f.Update([]string{""}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Update(MethodGet, "/foo", nil)), ErrInvalidRoute) - assert.ErrorIs(t, onlyError(f.Update(MethodGet, "/foo\x00", emptyHandler)), ErrInvalidRoute) + assert.ErrorAs(t, onlyError(f.Update(MethodGet, "/foo\x00", emptyHandler)), &pe) } func TestUpdateRoute(t *testing.T) { @@ -2195,1272 +2197,6 @@ func TestUpdateRoute(t *testing.T) { } } -func TestParseRoute(t *testing.T) { - f := MustRouter(AllowRegexpParam(true)) - - staticToken := func(v string, hsplit bool) token { - return token{ - value: v, - typ: nodeStatic, - hsplit: hsplit, - } - } - - paramToken := func(v, reg string) token { - tk := token{ - value: v, - typ: nodeParam, - } - if reg != "" { - tk.regexp = regexp.MustCompile("^" + reg + "$") - } - return tk - } - - wildcardToken := func(v, reg string) token { - tk := token{ - value: v, - typ: nodeWildcard, - } - if reg != "" { - tk.regexp = regexp.MustCompile("^" + reg + "$") - } - return tk - } - - cases := []struct { - wantErr error - name string - path string - wantN int - wantTokens []token - wantStartCatchAll int - }{ - { - name: "valid static route", - path: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), - }, - { - name: "top level domain param", - path: "{tld}/foo/bar", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("tld", ""), - staticToken("/foo/bar", false), - )), - }, - { - name: "top level domain wildcard", - path: "+{tld}/foo/bar", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("tld", ""), - staticToken("/foo/bar", false), - )), - }, - { - name: "valid catch all route", - path: "/foo/bar/+{arg}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 9, - }, - { - name: "valid param route", - path: "/foo/bar/{baz}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - paramToken("baz", ""), - )), - }, - { - name: "valid multi params route", - path: "/foo/{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "valid same params route", - path: "/foo/{bar}/{bar}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("bar", ""), - )), - }, - { - name: "valid multi params and catch all route", - path: "/foo/{bar}/{baz}/+{arg}", - wantN: 3, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - staticToken("/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 17, - }, - { - name: "valid inflight param", - path: "/foo/xyz:{bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - paramToken("bar", ""), - )), - }, - { - name: "valid inflight catchall", - path: "/foo/xyz:+{bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - wildcardToken("bar", ""), - )), - wantStartCatchAll: 9, - }, - { - name: "valid multi inflight param and catch all", - path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", - wantN: 3, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - paramToken("bar", ""), - staticToken("/abc:", false), - paramToken("bar", ""), - staticToken("/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 25, - }, - { - name: "catch all with arg in the middle of the route", - path: "/foo/bar/+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "multiple catch all suffix and inflight with arg in the middle of the route", - path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 4, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - { - name: "inflight catch all with arg in the middle of the route", - path: "/foo/bar/damn+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/damn", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "catch all with arg in the middle of the route and param after", - path: "/foo/bar/+{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "simple domain and path", - path: "foo/bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/bar", false), - )), - }, - { - name: "simple domain with trailing slash", - path: "foo/", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/", false), - )), - }, - { - name: "period in param path allowed", - path: "foo/{.bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/", false), - paramToken(".bar", ""), - )), - }, - { - name: "missing a least one slash", - path: "foo.com", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty parameter", - path: "/foo/bar{}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "missing arguments name after catch all", - path: "/foo/bar/*", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "missing arguments name after param", - path: "/foo/bar/{", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "catch all in the middle of the route", - path: "/foo/bar/*/baz", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty infix catch all", - path: "/foo/bar/+{}/baz", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty ending catch all", - path: "/foo/bar/baz/+{}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param", - path: "/foo/{{bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param", - path: "/foo/{*bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in catch-all", - path: "/foo/+{/bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "catch all not supported in hostname", - path: "a.b.c*/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in params hostname", - path: "a.b.c{/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in hostname label", - path: "a.b.c}/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param hostname", - path: "a.{.bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in wildcard hostname", - path: "a.+{.bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param hostname", - path: "a.{/bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in wildcard hostname", - path: "a.+{/bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "in flight catch-all after param in one route segment", - path: "/foo/{bar}+{baz}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "multiple param in one route segment", - path: "/foo/{bar}{baz}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "in flight param after catch all", - path: "/foo/+{args}{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all with no slash", - path: "/foo/+{args}+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all", - path: "/foo/+{args}/+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all with inflight", - path: "/foo/ab+{args}/+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected char after inflight catch all", - path: "/foo/ab+{args}a", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected char after catch all", - path: "/foo/+{args}a", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "prefix catch-all in hostname", - path: "+{any}.com/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("any", ""), - staticToken(".com", true), - staticToken("/foo", false), - )), - }, - { - name: "infix catch-all in hostname", - path: "a.+{any}.com/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.", true), - wildcardToken("any", ""), - staticToken(".com", true), - staticToken("/foo", false), - )), - }, - { - name: "illegal catch-all in hostname", - path: "a.b.+{any}/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.", true), - wildcardToken("any", ""), - staticToken("/foo", false), - )), - }, - { - name: "static hostname with catch-all path", - path: "a.b.com/+{any}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.com", true), - staticToken("/", false), - wildcardToken("any", ""), - )), - wantStartCatchAll: 8, - }, - { - name: "illegal control character in path", - path: "example.com/foo\x00", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal leading hyphen in hostname", - path: "-a.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal leading dot in hostname", - path: ".a.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing hyphen in hostname", - path: "a.com-/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing dot in hostname", - path: "a.com./", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing dot in hostname after param", - path: "{tld}./foo/bar", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal single dot in hostname", - path: "./", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal hyphen before dot", - path: "a-.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal hyphen after dot", - path: "a.-com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot", - path: "a..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot with param state", - path: "{b}..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot with inflight param state", - path: "a{b}..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "param not finishing with delimiter in hostname", - path: "{a}b{b}.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive parameter in hostname", - path: "{a}{b}.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "leading hostname label exceed 63 characters", - path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "middle hostname label exceed 63 characters", - path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "trailing hostname label exceed 63 characters", - path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in domain", - path: "a.b!.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid all-numeric label", - path: "123/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "all-numeric label with param", - path: "123.{a}.456/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("123.", true), - paramToken("a", ""), - staticToken(".456", true), - staticToken("/", false), - )), - }, - { - name: "all-numeric label with wildcard", - path: "123.+{a}.456/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("123.", true), - wildcardToken("a", ""), - staticToken(".456", true), - staticToken("/", false), - )), - }, - { - name: "all-numeric label with path wildcard", - path: "123.456/{abc}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "hostname exceed 255 character", - path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid all-numeric label", - path: "11.22.33/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid uppercase label", - path: "ABC/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "2 regular params in domain", - path: "{a}.{b}.com/", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".", true), - paramToken("b", ""), - staticToken(".com", true), - staticToken("/", false), - )), - }, - { - name: "253 character with .", - path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), - staticToken("/", false), - )), - }, - { - name: "param does not count at character", - path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), - staticToken("/", false), - )), - }, - { - name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", - path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 4, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - { - name: "hostname variant with inflight catch all with arg in the middle of the route", - path: "example.com/foo/bar/damn+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/damn", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "hostname variant catch all with arg in the middle of the route and param after", - path: "example.com/foo/bar/+{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "complex domain and path", - path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 7, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("ab", ""), - staticToken(".", true), - paramToken("c", ""), - staticToken(".de", true), - paramToken("f", ""), - staticToken(".com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - // Reject path with traversal pattern - { - name: "path with double slash", - path: "/foo//bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with > double slash", - path: "/foo///bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with slash dot slash", - path: "/foo/./bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with slash dot slash", - path: "/foo/././bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with double dot parent reference", - path: "/foo/../bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with double dot parent reference", - path: "/foo/../../bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash dot", - path: "/foo/.", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash double dot", - path: "/foo/..", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash dot", - path: "/.", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash double dot", - path: "/..", - wantErr: ErrInvalidRoute, - }, - // Allowed dot and slash combinaison - { - name: "last path segment starting with slash dot and text", - path: "/foo/.bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.bar", false), - )), - }, - { - name: "last path segment starting with slash dot and text", - path: "/foo/..bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..bar", false), - )), - }, - { - name: "path segment starting with slash dot and text", - path: "/foo/.bar/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.bar/baz", false), - )), - }, - { - name: "path segment starting with slash dot and param", - path: "/foo/.{foo}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.", false), - paramToken("foo", ""), - staticToken("/baz", false), - )), - }, - { - name: "path segment starting with slash dot and text", - path: "/foo/..bar/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..bar/baz", false), - )), - }, - { - name: "path segment starting with slash dot and param", - path: "/foo/..{foo}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..", false), - paramToken("foo", ""), - staticToken("/baz", false), - )), - }, - { - name: "path segment ending with dot slash", - path: "/foo/bar./baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar./baz", false), - )), - }, - { - name: "path segment ending with double dot slash", - path: "/foo/bar../baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar../baz", false), - )), - }, - { - name: "path segment with > double dot", - path: "/foo/.../baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.../baz", false), - )), - }, - { - name: "path segment ending with slash and > double dot", - path: "/foo/...", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/...", false), - )), - }, - { - name: "last path segment ending with dot", - path: "/foo/bar.", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar.", false), - )), - }, - { - name: "last path segment ending with double dot", - path: "/foo/bar..", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar..", false), - )), - }, - { - name: "path segment with dot", - path: "/foo/a.b.c", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/a.b.c", false), - )), - }, - { - name: "path segment with double dot", - path: "/foo/a..b..c", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/a..b..c", false), - )), - }, - // Regexp - { - name: "simple ending param with regexp", - path: "/foo/{bar:[A-z]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - )), - wantN: 1, - }, - { - name: "simple ending param with regexp", - path: "/foo/+{bar:[A-z]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - )), - wantN: 1, - wantStartCatchAll: 5, - }, - { - name: "simple infix param with regexp", - path: "/foo/{bar:[A-z]+}/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - staticToken("/baz", false), - )), - wantN: 1, - }, - { - name: "multi infix and ending param with regexp", - path: "/foo/{bar:[A-z]+}/{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - staticToken("/", false), - paramToken("baz", "[0-9]+"), - )), - wantN: 2, - }, - { - name: "multi infix and ending wildcard with regexp", - path: "/foo/+{bar:[A-z]+}/a+{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - staticToken("/a", false), - wildcardToken("baz", "[0-9]+"), - )), - wantN: 2, - wantStartCatchAll: 20, - }, - { - name: "consecutive infix regexp wildcard and regexp param allowed", - path: "/foo/+{bar:[A-z]+}/{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - staticToken("/", false), - paramToken("baz", "[0-9]+"), - )), - wantN: 2, - }, - { - name: "hostname starting with regexp", - path: "{a:[A-z]+}.b.c/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", "[A-z]+"), - staticToken(".b.c", true), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "hostname with middle param regexp", - path: "a.{b:[A-z]+}.c/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.", true), - paramToken("b", "[A-z]+"), - staticToken(".c", true), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "hostname ending with param regexp", - path: "a.b.{c:[A-z]+}/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.", true), - paramToken("c", "[A-z]+"), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "non capturing group allowed in regexp", - path: "/foo/{bar:(?:foo|bar)}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "(?:foo|bar)"), - )), - wantN: 1, - }, - { - name: "regexp wildcard at the beginning of the path", - path: "/+{foo:[A-z]+}/bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/", false), - wildcardToken("foo", "[A-z]+"), - staticToken("/bar", false), - )), - wantN: 1, - }, - { - name: "regexp wildcard at the beginning of the host", - path: "+{a:[A-z]+}.b.c/", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("a", "[A-z]+"), - staticToken(".b.c", true), - staticToken("/", false), - )), - wantN: 1, - }, - { - name: "consecutive wildcard from hostname to path", - path: "+{foo}/+{bar}", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("foo", ""), - staticToken("/", false), - wildcardToken("bar", ""), - )), - wantN: 2, - wantStartCatchAll: 7, - }, - { - name: "consecutive wildcard with empty catch all from hostname to path", - path: "+{foo}/*{bar}", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("foo", ""), - staticToken("/", false), - wildcardToken("bar", ""), - )), - wantN: 2, - wantStartCatchAll: 7, - }, - { - name: "param then wildcard regexp", - path: "{a}.+{b:b}/", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".", true), - wildcardToken("b", "b"), - staticToken("/", false), - )), - wantN: 2, - }, - { - name: "param regexp then wildcard regexp", - path: "{a:a}.+{b:b}/", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", "a"), - staticToken(".", true), - wildcardToken("b", "b"), - staticToken("/", false), - )), - wantN: 2, - }, - { - name: "catch all empty as suffix", - path: "/foo/*{any}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("any", ""), - )), - wantN: 1, - wantStartCatchAll: 5, - }, - { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive wildcard with catch all empty not allowed", - path: "/+{foo}/*{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", - path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard at start with and without regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with and without regexp not allowed", - path: "+{foo:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo}/+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "+{foo}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with first regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with first regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with second regexp not allowed", - path: "/foo/+{bar}/+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with second regexp not allowed", - path: "foo.+{bar}.+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "non slash char after regexp param not allowed", - path: "/foo/{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, - }, - { - name: "non slash char after regexp wildcard not allowed", - path: "/foo/+{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, - }, - { - name: "regexp wildcard not allowed in hostname", - path: "+{a.{b:[A-z]+}}.c/", - wantErr: ErrInvalidRoute, - }, - { - name: "regexp wildcard not allowed in hostname", - path: "+{a.b.{c:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "missing param name with regexp", - path: "/foo/{:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing wildcard name with regexp", - path: "/foo/+{:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing regular expression", - path: "/foo/{a:}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing regular expression with only ':'", - path: "/foo/{:}", - wantErr: ErrInvalidRoute, - }, - { - name: "unsupported regexp in optional wildcard", - path: "/foo/*{any:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in param regexp", - path: "/foo/{bar:[A-z]+", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in wildcard regexp", - path: "/foo/+{bar:[A-z]+", - wantErr: ErrInvalidRoute, - }, - { - name: "balanced braces in param regexp with invalid char after", - path: "/foo/{bar:{}}a", - wantErr: ErrInvalidRoute, - }, - { - name: "balanced braces in wildcard regexp with invalid brace after", - path: "/foo/{bar:{}}}", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in regexp complex", - path: "/foo/{bar:{{{{}}}}", - wantErr: ErrInvalidRoute, - }, - { - name: "invalid regular expression", - path: "/foo/{bar:a{5,2}}", - wantErr: ErrInvalidRoute, - }, - { - name: "invalid regular expression", - path: "/foo/{bar:\\k}", - wantErr: ErrInvalidRoute, - }, - { - name: "capture group in regexp are not allowed", - path: "/foo/{bar:(foo|bar)}", - wantErr: ErrInvalidRoute, - }, - { - name: "no opening brace after * wildcard", - path: "/foo/*:bar}", - wantErr: ErrInvalidRoute, - }, - { - name: "no infix catch all empty", - path: "/foo/*{any}/bar", - wantErr: ErrInvalidRoute, - }, - { - name: "no infix inflight catch all empty", - path: "/foo/uuid_*{any}/bar", - wantErr: ErrInvalidRoute, - }, - { - name: "no suffix catch all empty in hostname", - path: "a.b.*{any}/", - wantErr: ErrInvalidRoute, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - parsed, err := f.parseRoute(tc.path) - require.ErrorIs(t, err, tc.wantErr) - assert.Equal(t, tc.wantN, parsed.paramCnt) - assert.Equal(t, tc.wantTokens, parsed.token) - assert.Equal(t, tc.wantStartCatchAll, parsed.startCatchAll) - if err == nil { - assert.Equal(t, strings.IndexByte(tc.path, '/'), parsed.endHost) - } - }) - } -} - -func TestParseRouteParamsConstraint(t *testing.T) { - t.Run("param limit", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParams(3)) - _, err := f.parseRoute("/{1}/{2}/{3}") - assert.NoError(t, err) - _, err = f.parseRoute("/{1}/{2}/{3}/{4}") - assert.Error(t, err) - _, err = f.parseRoute("/ab{1}/{2}/cd/{3}/{4}/ef") - assert.Error(t, err) - }) - t.Run("param key limit", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParamKeyBytes(3)) - _, err := f.parseRoute("/{abc}/{abc}/{abc}") - assert.NoError(t, err) - _, err = f.parseRoute("/{abcd}/{abc}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abcd}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abc}/{abcd}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/+{abcd}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abc}/+{abcdef}") - assert.Error(t, err) - }) - t.Run("param key limit with regexp", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) - _, err := f.parseRoute("/{abc:a}/{abc:a}/{abc:a}") - assert.NoError(t, err) - _, err = f.parseRoute("/{abcd:a}/{abc:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abcd:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abc:a}/{abcd:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/+{abcd:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abc:a}/+{abcdef:a}") - assert.Error(t, err) - }) - t.Run("disabled regexp support for param", func(t *testing.T) { - f, _ := NewRouter() - _, err := f.parseRoute("/{a}/{b}/{c}") - assert.NoError(t, err) - // path params - _, err = f.parseRoute("/{a:a}/{b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b:b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b}/{c:c}") - assert.Error(t, err) - // hostname params - _, err = f.parseRoute("{a:a}.{b}.{c}/") - assert.Error(t, err) - _, err = f.parseRoute("{a}.{b:b}.{c}/") - assert.Error(t, err) - _, err = f.parseRoute("{a}.{b}.{c:c}/") - assert.Error(t, err) - }) - t.Run("disabled regexp support for wildcard", func(t *testing.T) { - f, _ := NewRouter() - _, err := f.parseRoute("/{a}/{b}/{c}") - assert.NoError(t, err) - // wildcard - _, err = f.parseRoute("/+{a:a}/{b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/+{b:b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b}/+{c:c}") - assert.Error(t, err) - }) -} - func TestRouteMatchersConstraint(t *testing.T) { t.Run("insert: enforce max route matchers", func(t *testing.T) { f, _ := NewRouter(WithMaxRouteMatchers(3)) @@ -4514,7 +3250,8 @@ func TestRouter_DeleteError(t *testing.T) { }) t.Run("delete invalid route", func(t *testing.T) { r, err := f.Delete(MethodGet, "/{") - assert.ErrorIs(t, err, ErrInvalidRoute) + var pe *PatternError + assert.ErrorAs(t, err, &pe) assert.Nil(t, r) }) t.Run("route does not exist", func(t *testing.T) { diff --git a/go.mod b/go.mod index 28ffda27..45485714 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/fox-toolkit/fox -go 1.24.0 +go 1.26.0 require ( github.com/google/gofuzz v1.2.0 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.49.0 - golang.org/x/sys v0.40.0 + golang.org/x/net v0.53.0 + golang.org/x/sys v0.43.0 ) require ( @@ -14,7 +14,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1206058d..1ddfd978 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,12 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/iter.go b/iter.go index 6ba01935..e1c0cf4d 100644 --- a/iter.go +++ b/iter.go @@ -57,7 +57,7 @@ func (it Iter) Routes(pattern string) iter.Seq[*Route] { } for _, route := range matched.routes { - if route.pattern == pattern { + if route.pattern.str == pattern { if !yield(route) { return } @@ -156,7 +156,7 @@ func (it Iter) PatternPrefix(prefix string) iter.Seq[*Route] { if elem.isLeaf() { for _, route := range elem.routes { - if len(route.params) > 0 && !strings.HasPrefix(route.pattern, prefix) { + if len(route.params) > 0 && !strings.HasPrefix(route.pattern.str, prefix) { continue } diff --git a/node.go b/node.go index 66cf1064..942cce14 100644 --- a/node.go +++ b/node.go @@ -363,7 +363,7 @@ Walk: // to search for match empty catch-all. for _, wildcardNode := range child.wildcards { for i, route := range wildcardNode.routes { - if route.handleSlash != StrictSlash && route.catchEmpty && route.match(method, c) { + if route.handleSlash != StrictSlash && route.pattern.optionalCatchAll && route.match(method, c) { if !lazy { // record empty match *c.params = append(*c.params, "") @@ -571,7 +571,7 @@ Walk: // Try to catch empty for wildcard supporting it. for _, wildcardNode := range matched.wildcards { for i, route := range wildcardNode.routes { - if route.catchEmpty && route.match(method, c) { + if route.pattern.optionalCatchAll && route.match(method, c) { if !lazy { *c.params = append(*c.params, "") } @@ -935,7 +935,7 @@ func parseBraceSegment(pattern string) (int, string) { key = "*" } - end := braceIndice(pattern, 0) + end := braceIndex(pattern, 0) if end <= 0 { return 0, "" } @@ -989,7 +989,7 @@ func (n *node) string(space int) string { sb.WriteByte('\n') sb.WriteString(strings.Repeat(" ", space+8)) sb.WriteString("=> ") - sb.WriteString(route.pattern) + sb.WriteString(route.pattern.str) if len(route.methods) > 0 { sb.WriteString(" [methods: ") diff --git a/node_test.go b/node_test.go index ef6a0190..f9dcd11a 100644 --- a/node_test.go +++ b/node_test.go @@ -196,7 +196,7 @@ func TestParseBraceSegment(t *testing.T) { { name: "double opening brace", pattern: "{{name}", - wantEnd: 0, // braceIndice needs balanced braces at level 0, {{name} ends at level 1 + wantEnd: 0, // braceIndex needs balanced braces at level 0, {{name} ends at level 1 wantKey: "", }, { @@ -440,7 +440,7 @@ func TestRouteWithParams(t *testing.T) { require.NotNilf(t, n, "route: %s", rte) require.NotNilf(t, n.routes[idx], "route: %s", rte) assert.False(t, tsr) - assert.Equal(t, rte, n.routes[idx].pattern) + assert.Equal(t, rte, n.routes[idx].pattern.str) } } @@ -1515,7 +1515,7 @@ func TestOverlappingRoute(t *testing.T) { require.NotNil(t, n) require.NotNil(t, n.routes[idx]) assert.False(t, tsr) - assert.Equal(t, tc.wantMatch, n.routes[idx].pattern) + assert.Equal(t, tc.wantMatch, n.routes[idx].pattern.str) c.route = n.routes[idx] *c.paramsKeys = c.route.params if len(tc.wantParams) == 0 { @@ -1533,7 +1533,7 @@ func TestOverlappingRoute(t *testing.T) { assert.False(t, tsr) c.route = n.routes[idx] assert.Empty(t, slices.Collect(c.Params())) - assert.Equal(t, tc.wantMatch, n.routes[idx].pattern) + assert.Equal(t, tc.wantMatch, n.routes[idx].pattern.str) }) } } @@ -2667,7 +2667,7 @@ func TestInfixWildcard(t *testing.T) { c := newTestContext(f) idx, n, tsr := lookupByPath(tree.patterns, http.MethodGet, tc.path, c, false, 0) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -3038,7 +3038,7 @@ func TestInfixWildcardTsr(t *testing.T) { c := newTestContext(f) idx, n, tsr := lookupByPath(tree.patterns, http.MethodGet, tc.path, c, false, 0) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -3133,7 +3133,7 @@ func TestTree_LookupTsr(t *testing.T) { if tc.want { require.NotNil(t, n) require.NotNil(t, n.routes[idx]) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) } }) } diff --git a/options.go b/options.go index 34d02a96..1de5b42d 100644 --- a/options.go +++ b/options.go @@ -164,7 +164,7 @@ func WithHandleFixedPath(opt FixedPathOption) GlobalOption { } // WithMaxRouteParams set the maximum number of parameters allowed in a route. The default max is math.MaxUint8. -// Routes exceeding this limit will fail with an error that is ErrInvalidRoute and ErrTooManyParams. +// Routes exceeding this limit will fail with a [*PatternError]. func WithMaxRouteParams(max int) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.maxParams = max @@ -173,8 +173,7 @@ func WithMaxRouteParams(max int) GlobalOption { } // WithMaxRouteParamKeyBytes set the maximum number of bytes allowed per parameter key in a route. The default max is -// math.MaxUint8. Routes with parameter keys exceeding this limit will fail with an error that Is ErrInvalidRoute and -// ErrParamKeyTooLarge. +// math.MaxUint8. Routes with parameter keys exceeding this limit will fail with a [*PatternError]. func WithMaxRouteParamKeyBytes(max int) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.maxParamKeyBytes = max @@ -192,8 +191,7 @@ func WithMaxRouteMatchers(max int) GlobalOption { } // AllowRegexpParam enables support for regular expressions in route parameters. When enabled, parameters can include -// regex patterns (e.g., {id:[0-9]+}). When disabled, routes containing regex patterns will fail with and error that -// Is ErrInvalidRoute and ErrRegexpNotAllowed. +// regex patterns (e.g., {id:[0-9]+}). When disabled, routes containing regex patterns will fail with a [*PatternError]. func AllowRegexpParam(enable bool) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.allowRegexp = enable diff --git a/parser.go b/parser.go new file mode 100644 index 00000000..b5372bd3 --- /dev/null +++ b/parser.go @@ -0,0 +1,422 @@ +package fox + +import ( + "fmt" + "regexp" + "strings" +) + +type pattern struct { + str string // canonical pattern + tokens []token + optionalCatchAll bool + endHost int +} + +func (fox *Router) parsePattern(raw string) (pattern, int, error) { + endHost := strings.IndexByte(raw, '/') + if endHost == -1 { + if len(raw) == 0 { + return pattern{}, 0, fmt.Errorf("%w: empty pattern", ErrInvalidRoute) + } + return pattern{}, 0, &PatternError{ + Pattern: raw, + Type: "hostname", + Reason: "syntax", + Hint: "missing trailing '/' after hostname", + Start: len(raw), + End: len(raw), + } + } + + path := raw[endHost:] + + var ( + paramCount int + hostTokens []token + ) + + if endHost > 0 { + var pe *PatternError + hostTokens, paramCount, pe = fox.parseHostname(raw[:endHost]) + if pe != nil { + pe.Pattern = raw + pe.Type = "hostname" + return pattern{}, 0, pe + } + } + + pathTokens, optCatchAll, paramCount, pe := fox.parsePath(path, paramCount) + if pe != nil { + pe.Pattern = raw + pe.Type = "path" + pe.Start += endHost + pe.End += endHost + return pattern{}, 0, pe + } + + tokens := make([]token, 0, len(hostTokens)+len(pathTokens)) + tokens = append(tokens, hostTokens...) + tokens = append(tokens, pathTokens...) + + return pattern{ + str: raw, + tokens: tokens, + endHost: endHost, + optionalCatchAll: optCatchAll, + }, paramCount, nil +} + +func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) { + var sb strings.Builder + sb.Grow(len(hostname)) + tokens := make([]token, 0, 5) + var ( + paramCount int + prevWild bool + staticSinceWild int + partLen int + totalLen int + last = dotDelim + nonNumeric bool + ) + + i := 0 + for i < len(hostname) { + c := hostname[i] + + switch c { + case '{', '+': + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String(), hsplit: true}) + sb.Reset() + } + isWild := c == '+' + paramStart := i + if isWild { + i++ + if i >= len(hostname) || hostname[i] != '{' { + return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") + } + if prevWild && staticSinceWild <= 1 { + paramEnd := len(hostname) + if idx := braceIndex(hostname[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, 0, newPatternError("syntax", i-1, paramEnd, "consecutive wildcard") + } + } + name, re, n, pe := fox.parseBrace(hostname[i:], dotDelim, false) + if pe != nil { + pe.Start += i + pe.End += i + return nil, 0, pe + } + paramCount++ + if paramCount > fox.maxParams { + return nil, 0, newPatternError("constraint", paramStart, i+n, "too many parameters") + } + + kind := nodeParam + if isWild { + kind = nodeWildcard + prevWild = true + staticSinceWild = 0 + } else { + prevWild = false + } + tokens = append(tokens, token{typ: kind, value: name, regexp: re}) + i += n + last = 0 + nonNumeric = true + if i < len(hostname) && hostname[i] != '.' { + return nil, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") + } + + case '*': + i++ + if i < len(hostname) && hostname[i] == '{' { + paramEnd := len(hostname) + if idx := braceIndex(hostname[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, 0, newPatternError("syntax", i-1, paramEnd, "optional wildcard allowed only as suffix") + } + return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") + + default: + switch { + case 'a' <= c && c <= 'z' || c == '_': + nonNumeric = true + partLen++ + case '0' <= c && c <= '9': + partLen++ + case c == '-': + if last == '.' { + if i == 0 { + return nil, 0, newPatternError("syntax", i, i+1, "label starts with '-'") + } + return nil, 0, newPatternError("syntax", i, i+1, "illegal character after '.'") + } + partLen++ + nonNumeric = true + case c == '.': + if last == '.' { + if i == 0 { + return nil, 0, newPatternError("syntax", i, i+1, "label starts with '.'") + } + return nil, 0, newPatternError("syntax", i-1, i+1, "illegal consecutive '.'") + } + if last == '-' { + return nil, 0, newPatternError("syntax", i-1, i, "label ends with '-'") + } + if partLen > 63 { + return nil, 0, newPatternError("constraint", i-partLen, i, "label exceeds 63 characters") + } + totalLen += partLen + 1 // +1 counts the current dot. + partLen = 0 + case 'A' <= c && c <= 'Z': + return nil, 0, newPatternError("syntax", i, i+1, "uppercase character in label") + default: + return nil, 0, newPatternError("syntax", i, i+1, "illegal character in label") + } + last = c + sb.WriteByte(c) + staticSinceWild++ + i++ + } + } + + totalLen += partLen + if last == '-' { + return nil, 0, newPatternError("syntax", len(hostname)-1, len(hostname), "illegal trailing '-'") + } + if last == '.' { + return nil, 0, newPatternError("syntax", len(hostname)-1, len(hostname), "illegal trailing '.'") + } + if !nonNumeric { + return nil, 0, newPatternError("syntax", 0, len(hostname), "all numeric") + } + if partLen > 63 { + return nil, 0, newPatternError("constraint", len(hostname)-partLen, len(hostname), "label exceeds 63 characters") + } + if totalLen > 253 { + return nil, 0, newPatternError("constraint", 0, len(hostname), "exceeds 253 characters") + } + + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String(), hsplit: true}) + } + return tokens, paramCount, nil +} + +func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, *PatternError) { + var sb strings.Builder + sb.Grow(len(path)) + tokens := make([]token, 0, 5) + var ( + prevWild bool + staticSinceWild int + optCatchAll bool + ) + + i := 0 + for i < len(path) { + c := path[i] + + switch c { + case '{', '+', '*': + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String()}) + sb.Reset() + } + isOpt := c == '*' + isWild := c == '+' || isOpt + paramStart := i + if isWild { + i++ + if i >= len(path) || path[i] != '{' { + return nil, false, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") + } + if prevWild && staticSinceWild <= 1 { + paramEnd := len(path) + if idx := braceIndex(path[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, paramEnd, "consecutive wildcard") + } + } + name, re, n, pe := fox.parseBrace(path[i:], slashDelim, isOpt) + if pe != nil { + pe.Start += i + pe.End += i + if isOpt && pe.Reason == "regexp" { + pe.Start = paramStart + } + return nil, false, 0, pe + } + paramCount++ + if paramCount > fox.maxParams { + return nil, false, 0, newPatternError("constraint", paramStart, i+n, "too many parameters") + } + + kind := nodeParam + if isWild { + kind = nodeWildcard + prevWild = true + staticSinceWild = 0 + } else { + prevWild = false + } + tokens = append(tokens, token{typ: kind, value: name, regexp: re}) + i += n + if isOpt { + if i < len(path) { + return nil, false, 0, newPatternError("syntax", paramStart, i, "optional wildcard allowed only as suffix") + } + optCatchAll = true + } + if i < len(path) && path[i] != '/' { + return nil, false, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") + } + + default: + if c < ' ' || c == 0x7f { + return nil, false, 0, newPatternError("syntax", i, i+1, "illegal control character") + } + if c == '/' && i > 0 && path[i-1] == '/' { + return nil, false, 0, newPatternError("syntax", i-1, i+1, "consecutive '/'") + } + if c == '.' && i > 0 && path[i-1] == '/' { + next := i + 1 + if next >= len(path) || path[next] == '/' { + // "/." at end or "/./" + end := next + if next < len(path) { + end = next + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, end, "dot segment") + } + if path[next] == '.' { + afterDots := next + 1 + if afterDots >= len(path) || path[afterDots] == '/' { + // "/.." at end or "/../" + end := afterDots + if afterDots < len(path) { + end = afterDots + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, end, "dot segment") + } + } + } + sb.WriteByte(c) + staticSinceWild++ + i++ + } + } + + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String()}) + } + return tokens, optCatchAll, paramCount, nil +} + +func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *regexp.Regexp, int, *PatternError) { + // Skip s[0] (the opening '{') and start at nesting level 1 to account for it. + idx := braceIndex(s[1:], 1) + if idx == -1 { + return "", nil, 0, newPatternError("syntax", 0, len(s), "unbalanced braces") + } + + content := s[1 : 1+idx] // Everything between { and }. + consumed := 1 + idx + 1 // { + content + } + + name := content + var rawRegex string + hasRegex := false + colonIdx := -1 + if ci := strings.IndexByte(content, ':'); ci >= 0 { + colonIdx = ci + name = content[:colonIdx] + rawRegex = content[colonIdx+1:] + hasRegex = true + } + + if len(name) > fox.maxParamKeyBytes { + return "", nil, 0, newPatternError("constraint", 1, 1+len(name), "key too large") + } + + if len(name) == 0 { + return "", nil, 0, newPatternError("parameter", 0, consumed, "missing name") + } + + for j := 0; j < len(name); j++ { + switch name[j] { + // TODO: just put . and /, add also } + case delim, '/', '*', '+', '{': + return "", nil, 0, newPatternError("parameter", 1+j, 1+j+1, "illegal character in name") + } + } + + if !hasRegex { + return name, nil, consumed, nil + } + + if isOptional { + return "", nil, 0, newPatternError("regexp", 0, consumed, "not allowed in optional wildcard") + } + + re, pe := fox.compileParamRegexp(rawRegex) + if pe != nil { + regexOffset := 1 + colonIdx + 1 + pe.Start += regexOffset + pe.End += regexOffset + return "", nil, 0, pe + } + return name, re, consumed, nil +} + +// compileParamRegexp validates and compiles a regular expression constraint for a parameter. +// Positions in the returned PatternError are relative to rawRegex. +func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *PatternError) { + if !fox.allowRegexp { + return nil, newPatternError("regexp", 0, len(rawRegex), "feature not enabled") + } + if rawRegex == "" { + return nil, newPatternError("regexp", 0, 0, "missing expression") + } + + re, err := regexp.Compile("^" + rawRegex + "$") + if err != nil { + return nil, &PatternError{ + Reason: "regexp", + Start: 0, + End: len(rawRegex), + Hint: err.Error(), + err: err, + } + } + if re.NumSubexp() > 0 { + return nil, newPatternError("regexp", 0, len(rawRegex), "capture group, use (?:...) instead") + } + + return re, nil +} + +// braceIndex returns the index of the closing brace that balances an opening +// brace. It starts at startLevel opened brace. +func braceIndex(s string, startLevel int) int { + level := startLevel + + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + level++ + case '}': + if level--; level == 0 { + return i + } + } + } + return -1 +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 00000000..8925f686 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,1695 @@ +package fox + +import ( + "errors" + "fmt" + "regexp" + "regexp/syntax" + "slices" + "strings" + "testing" + + "github.com/fox-toolkit/fox/internal/iterutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePattern(t *testing.T) { + f := MustRouter(AllowRegexpParam(true)) + + staticToken := func(v string, hsplit bool) token { + return token{ + value: v, + typ: nodeStatic, + hsplit: hsplit, + } + } + + paramToken := func(v, reg string) token { + tk := token{ + value: v, + typ: nodeParam, + } + if reg != "" { + tk.regexp = regexp.MustCompile("^" + reg + "$") + } + return tk + } + + wildcardToken := func(v, reg string) token { + tk := token{ + value: v, + typ: nodeWildcard, + } + if reg != "" { + tk.regexp = regexp.MustCompile("^" + reg + "$") + } + return tk + } + + cases := []struct { + name string + path string + wantN int + wantTokens []token + optionalCatchAll bool + }{ + { + name: "valid static route", + path: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "top level domain param", + path: "{tld}/foo/bar", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("tld", ""), + staticToken("/foo/bar", false), + )), + }, + { + name: "top level domain wildcard", + path: "+{tld}/foo/bar", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("tld", ""), + staticToken("/foo/bar", false), + )), + }, + { + name: "valid catch all route", + path: "/foo/bar/+{arg}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "valid param route", + path: "/foo/bar/{baz}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + paramToken("baz", ""), + )), + }, + { + name: "valid multi params route", + path: "/foo/{bar}/{baz}", + wantN: 2, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "valid same params route", + path: "/foo/{bar}/{bar}", + wantN: 2, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("bar", ""), + )), + }, + { + name: "valid multi params and catch all route", + path: "/foo/{bar}/{baz}/+{arg}", + wantN: 3, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + staticToken("/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "valid inflight param", + path: "/foo/xyz:{bar}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + paramToken("bar", ""), + )), + }, + { + name: "valid inflight catchall", + path: "/foo/xyz:+{bar}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + wildcardToken("bar", ""), + )), + }, + { + name: "valid multi inflight param and catch all", + path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", + wantN: 3, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + paramToken("bar", ""), + staticToken("/abc:", false), + paramToken("bar", ""), + staticToken("/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "catch all with arg in the middle of the route", + path: "/foo/bar/+{bar}/baz", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "multiple catch all suffix and inflight with arg in the middle of the route", + path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 4, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + { + name: "inflight catch all with arg in the middle of the route", + path: "/foo/bar/damn+{bar}/baz", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/damn", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "catch all with arg in the middle of the route and param after", + path: "/foo/bar/+{bar}/{baz}", + wantN: 2, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "simple domain and path", + path: "foo/bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/bar", false), + )), + }, + { + name: "simple domain with trailing slash", + path: "foo/", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/", false), + )), + }, + { + name: "period in param path allowed", + path: "foo/{.bar}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/", false), + paramToken(".bar", ""), + )), + }, + { + name: "missing trailing slash after hostname", + path: "foo.com", + }, + { + name: "empty parameter", + path: "/foo/bar{}", + wantN: 0, + }, + { + name: "missing arguments name after catch all", + path: "/foo/bar/*", + wantN: 0, + }, + { + name: "missing arguments name after param", + path: "/foo/bar/{", + wantN: 0, + }, + { + name: "catch all in the middle of the route", + path: "/foo/bar/*/baz", + wantN: 0, + }, + { + name: "empty infix catch all", + path: "/foo/bar/+{}/baz", + wantN: 0, + }, + { + name: "empty ending catch all", + path: "/foo/bar/baz/+{}", + wantN: 0, + }, + { + name: "unexpected character in param", + path: "/foo/{{bar}", + wantN: 0, + }, + { + name: "unexpected character in param", + path: "/foo/{*bar}", + wantN: 0, + }, + { + name: "unexpected character in catch-all", + path: "/foo/+{/bar}", + wantN: 0, + }, + { + name: "catch all not supported in hostname", + path: "a.b.c*/", + wantN: 0, + }, + { + name: "illegal character in params hostname", + path: "a.b.c{/", + wantN: 0, + }, + { + name: "illegal character in hostname label", + path: "a.b.c}/", + wantN: 0, + }, + { + name: "unexpected character in param hostname", + path: "a.{.bar}.c/", + wantN: 0, + }, + { + name: "unexpected character in wildcard hostname", + path: "a.+{.bar}.c/", + wantN: 0, + }, + { + name: "unexpected character in param hostname", + path: "a.{/bar}.c/", + wantN: 0, + }, + { + name: "unexpected character in wildcard hostname", + path: "a.+{/bar}.c/", + wantN: 0, + }, + { + name: "in flight catch-all after param in one route segment", + path: "/foo/{bar}+{baz}", + wantN: 0, + }, + { + name: "multiple param in one route segment", + path: "/foo/{bar}{baz}", + wantN: 0, + }, + { + name: "in flight param after catch all", + path: "/foo/+{args}{param}", + wantN: 0, + }, + { + name: "consecutive catch all with no slash", + path: "/foo/+{args}+{param}", + wantN: 0, + }, + { + name: "consecutive catch all", + path: "/foo/+{args}/+{param}", + wantN: 0, + }, + { + name: "consecutive catch all with inflight", + path: "/foo/ab+{args}/+{param}", + wantN: 0, + }, + { + name: "unexpected char after inflight catch all", + path: "/foo/ab+{args}a", + wantN: 0, + }, + { + name: "unexpected char after catch all", + path: "/foo/+{args}a", + wantN: 0, + }, + { + name: "prefix catch-all in hostname", + path: "+{any}.com/foo", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("any", ""), + staticToken(".com", true), + staticToken("/foo", false), + )), + }, + { + name: "infix catch-all in hostname", + path: "a.+{any}.com/foo", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.", true), + wildcardToken("any", ""), + staticToken(".com", true), + staticToken("/foo", false), + )), + }, + { + name: "illegal catch-all in hostname", + path: "a.b.+{any}/foo", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.", true), + wildcardToken("any", ""), + staticToken("/foo", false), + )), + }, + { + name: "static hostname with catch-all path", + path: "a.b.com/+{any}", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.com", true), + staticToken("/", false), + wildcardToken("any", ""), + )), + }, + { + name: "illegal control character in path", + path: "example.com/foo\x00", + wantN: 0, + }, + { + name: "illegal leading hyphen in hostname", + path: "-a.com/", + wantN: 0, + }, + { + name: "illegal leading dot in hostname", + path: ".a.com/", + wantN: 0, + }, + { + name: "illegal trailing hyphen in hostname", + path: "a.com-/", + wantN: 0, + }, + { + name: "illegal trailing dot in hostname", + path: "a.com./", + wantN: 0, + }, + { + name: "illegal trailing dot in hostname after param", + path: "{tld}./foo/bar", + wantN: 0, + }, + { + name: "illegal single dot in hostname", + path: "./", + wantN: 0, + }, + { + name: "illegal hyphen before dot", + path: "a-.com/", + wantN: 0, + }, + { + name: "illegal hyphen after dot", + path: "a.-com/", + wantN: 0, + }, + { + name: "illegal double dot", + path: "a..com/", + wantN: 0, + }, + { + name: "illegal double dot with param state", + path: "{b}..com/", + wantN: 0, + }, + { + name: "illegal double dot with inflight param state", + path: "a{b}..com/", + wantN: 0, + }, + { + name: "param not finishing with delimiter in hostname", + path: "{a}b{b}.com/", + wantN: 0, + }, + { + name: "consecutive parameter in hostname", + path: "{a}{b}.com/", + wantN: 0, + }, + { + name: "leading hostname label exceed 63 characters", + path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", + wantN: 0, + }, + { + name: "middle hostname label exceed 63 characters", + path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", + wantN: 0, + }, + { + name: "trailing hostname label exceed 63 characters", + path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantN: 0, + }, + { + name: "illegal character in domain", + path: "a.b!.com/", + wantN: 0, + }, + { + name: "invalid all-numeric label", + path: "123/", + wantN: 0, + }, + { + name: "all-numeric label with param", + path: "123.{a}.456/", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("123.", true), + paramToken("a", ""), + staticToken(".456", true), + staticToken("/", false), + )), + }, + { + name: "all-numeric label with wildcard", + path: "123.+{a}.456/", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("123.", true), + wildcardToken("a", ""), + staticToken(".456", true), + staticToken("/", false), + )), + }, + { + name: "all-numeric label with path wildcard", + path: "123.456/{abc}", + wantN: 0, + }, + { + name: "hostname exceed 255 character", + path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", + wantN: 0, + }, + { + name: "invalid all-numeric label", + path: "11.22.33/", + wantN: 0, + }, + { + name: "invalid uppercase label", + path: "ABC/", + wantN: 0, + }, + { + name: "2 regular params in domain", + path: "{a}.{b}.com/", + wantN: 2, + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".", true), + paramToken("b", ""), + staticToken(".com", true), + staticToken("/", false), + )), + }, + { + name: "253 character with .", + path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), + staticToken("/", false), + )), + }, + { + name: "param does not count at character", + path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), + staticToken("/", false), + )), + }, + { + name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", + path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 4, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + { + name: "hostname variant with inflight catch all with arg in the middle of the route", + path: "example.com/foo/bar/damn+{bar}/baz", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/damn", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "hostname variant catch all with arg in the middle of the route and param after", + path: "example.com/foo/bar/+{bar}/{baz}", + wantN: 2, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "complex domain and path", + path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 7, + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("ab", ""), + staticToken(".", true), + paramToken("c", ""), + staticToken(".de", true), + paramToken("f", ""), + staticToken(".com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + { + name: "path with double slash", + path: "/foo//bar", + }, + { + name: "path with > double slash", + path: "/foo///bar", + }, + { + name: "path with slash dot slash", + path: "/foo/./bar", + }, + { + name: "path with slash dot slash", + path: "/foo/././bar", + }, + { + name: "path with double dot parent reference", + path: "/foo/../bar", + }, + { + name: "path with double dot parent reference", + path: "/foo/../../bar", + }, + { + name: "path ending with slash dot", + path: "/foo/.", + }, + { + name: "path ending with slash double dot", + path: "/foo/..", + }, + { + name: "path ending with slash dot", + path: "/.", + }, + { + name: "path ending with slash double dot", + path: "/..", + }, + // Allowed dot and slash combinaison + { + name: "last path segment starting with slash dot and text", + path: "/foo/.bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.bar", false), + )), + }, + { + name: "last path segment starting with slash dot and text", + path: "/foo/..bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..bar", false), + )), + }, + { + name: "path segment starting with slash dot and text", + path: "/foo/.bar/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.bar/baz", false), + )), + }, + { + name: "path segment starting with slash dot and param", + path: "/foo/.{foo}/baz", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.", false), + paramToken("foo", ""), + staticToken("/baz", false), + )), + }, + { + name: "path segment starting with slash dot and text", + path: "/foo/..bar/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..bar/baz", false), + )), + }, + { + name: "path segment starting with slash dot and param", + path: "/foo/..{foo}/baz", + wantN: 1, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..", false), + paramToken("foo", ""), + staticToken("/baz", false), + )), + }, + { + name: "path segment ending with dot slash", + path: "/foo/bar./baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar./baz", false), + )), + }, + { + name: "path segment ending with double dot slash", + path: "/foo/bar../baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar../baz", false), + )), + }, + { + name: "path segment with > double dot", + path: "/foo/.../baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.../baz", false), + )), + }, + { + name: "path segment ending with slash and > double dot", + path: "/foo/...", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/...", false), + )), + }, + { + name: "last path segment ending with dot", + path: "/foo/bar.", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar.", false), + )), + }, + { + name: "last path segment ending with double dot", + path: "/foo/bar..", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar..", false), + )), + }, + { + name: "path segment with dot", + path: "/foo/a.b.c", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/a.b.c", false), + )), + }, + { + name: "path segment with double dot", + path: "/foo/a..b..c", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/a..b..c", false), + )), + }, + // Regexp + { + name: "simple ending param with regexp", + path: "/foo/{bar:[A-z]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + )), + wantN: 1, + }, + { + name: "simple ending param with regexp", + path: "/foo/+{bar:[A-z]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + )), + wantN: 1, + }, + { + name: "simple infix param with regexp", + path: "/foo/{bar:[A-z]+}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + staticToken("/baz", false), + )), + wantN: 1, + }, + { + name: "multi infix and ending param with regexp", + path: "/foo/{bar:[A-z]+}/{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + staticToken("/", false), + paramToken("baz", "[0-9]+"), + )), + wantN: 2, + }, + { + name: "multi infix and ending wildcard with regexp", + path: "/foo/+{bar:[A-z]+}/a+{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + staticToken("/a", false), + wildcardToken("baz", "[0-9]+"), + )), + wantN: 2, + }, + { + name: "consecutive infix regexp wildcard and regexp param allowed", + path: "/foo/+{bar:[A-z]+}/{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + staticToken("/", false), + paramToken("baz", "[0-9]+"), + )), + wantN: 2, + }, + { + name: "hostname starting with regexp", + path: "{a:[A-z]+}.b.c/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", "[A-z]+"), + staticToken(".b.c", true), + staticToken("/foo", false), + )), + wantN: 1, + }, + { + name: "hostname with middle param regexp", + path: "a.{b:[A-z]+}.c/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.", true), + paramToken("b", "[A-z]+"), + staticToken(".c", true), + staticToken("/foo", false), + )), + wantN: 1, + }, + { + name: "hostname ending with param regexp", + path: "a.b.{c:[A-z]+}/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.", true), + paramToken("c", "[A-z]+"), + staticToken("/foo", false), + )), + wantN: 1, + }, + { + name: "non capturing group allowed in regexp", + path: "/foo/{bar:(?:foo|bar)}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "(?:foo|bar)"), + )), + wantN: 1, + }, + { + name: "regexp wildcard at the beginning of the path", + path: "/+{foo:[A-z]+}/bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/", false), + wildcardToken("foo", "[A-z]+"), + staticToken("/bar", false), + )), + wantN: 1, + }, + { + name: "regexp wildcard at the beginning of the host", + path: "+{a:[A-z]+}.b.c/", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("a", "[A-z]+"), + staticToken(".b.c", true), + staticToken("/", false), + )), + wantN: 1, + }, + { + name: "consecutive wildcard from hostname to path", + path: "+{foo}/+{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("foo", ""), + staticToken("/", false), + wildcardToken("bar", ""), + )), + wantN: 2, + }, + { + name: "consecutive wildcard with empty catch all from hostname to path", + path: "+{foo}/*{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("foo", ""), + staticToken("/", false), + wildcardToken("bar", ""), + )), + wantN: 2, + optionalCatchAll: true, + }, + { + name: "param then wildcard regexp", + path: "{a}.+{b:b}/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".", true), + wildcardToken("b", "b"), + staticToken("/", false), + )), + wantN: 2, + }, + { + name: "param regexp then wildcard regexp", + path: "{a:a}.+{b:b}/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", "a"), + staticToken(".", true), + wildcardToken("b", "b"), + staticToken("/", false), + )), + wantN: 2, + }, + { + name: "catch all empty as suffix", + path: "/foo/*{any}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("any", ""), + )), + wantN: 1, + optionalCatchAll: true, + }, + { + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", + }, + { + name: "consecutive wildcard with catch all empty not allowed", + path: "/+{foo}/*{baz}", + }, + { + name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", + path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", + }, + { + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", + }, + { + name: "consecutive infix wildcard at start with and without regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz}", + }, + { + name: "hostname consecutive infix wildcard at start with and without regexp not allowed", + path: "+{foo:[A-z]+}.+{baz}/", + }, + { + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo}/+{baz:[0-9]+}/", + }, + { + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "+{foo}.+{baz:[0-9]+}/", + }, + { + name: "consecutive infix wildcard with regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", + }, + { + name: "hostname consecutive infix wildcard with regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", + }, + { + name: "consecutive infix wildcard with first regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz}", + }, + { + name: "hostname consecutive infix wildcard with first regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz}/", + }, + { + name: "consecutive infix wildcard with second regexp not allowed", + path: "/foo/+{bar}/+{baz:[A-z]+}/", + }, + { + name: "hostname consecutive infix wildcard with second regexp not allowed", + path: "foo.+{bar}.+{baz:[A-z]+}/", + }, + { + name: "non slash char after regexp param not allowed", + path: "/foo/{bar:[A-z]+}a/", + }, + { + name: "non slash char after regexp wildcard not allowed", + path: "/foo/+{bar:[A-z]+}a/", + }, + { + name: "regexp wildcard not allowed in hostname", + path: "+{a.{b:[A-z]+}}.c/", + }, + { + name: "regexp wildcard not allowed in hostname", + path: "+{a.b.{c:[A-z]+}/", + }, + { + name: "missing param name with regexp", + path: "/foo/{:[A-z]+}", + }, + { + name: "missing wildcard name with regexp", + path: "/foo/+{:[A-z]+}", + }, + { + name: "missing regular expression", + path: "/foo/{a:}", + }, + { + name: "missing regular expression with only ':'", + path: "/foo/{:}", + }, + { + name: "unsupported regexp in optional wildcard", + path: "/foo/*{any:[A-z]+}", + }, + { + name: "unbalanced braces in param regexp", + path: "/foo/{bar:[A-z]+", + }, + { + name: "unbalanced braces in wildcard regexp", + path: "/foo/+{bar:[A-z]+", + }, + { + name: "balanced braces in param regexp with invalid char after", + path: "/foo/{bar:{}}a", + }, + { + name: "balanced braces in wildcard regexp with invalid brace after", + path: "/foo/{bar:{}}}", + }, + { + name: "unbalanced braces in regexp complex", + path: "/foo/{bar:{{{{}}}}", + }, + { + name: "invalid regular expression", + path: "/foo/{bar:a{5,2}}", + }, + { + name: "invalid regular expression", + path: "/foo/{bar:\\k}", + }, + { + name: "capture group in regexp are not allowed", + path: "/foo/{bar:(foo|bar)}", + }, + { + name: "no opening brace after * wildcard", + path: "/foo/*:bar}", + }, + { + name: "no infix catch all empty", + path: "/foo/*{any}/bar", + }, + { + name: "no infix inflight catch all empty", + path: "/foo/uuid_*{any}/bar", + }, + { + name: "no suffix catch all empty in hostname", + path: "a.b.*{any}/", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pat, paramCnt, err := f.parsePattern(tc.path) + if err != nil { + var patErr *PatternError + require.ErrorAs(t, err, &patErr) + return + } + assert.Equal(t, tc.wantN, paramCnt) + assert.Equal(t, tc.wantTokens, pat.tokens) + assert.Equal(t, tc.optionalCatchAll, pat.optionalCatchAll) + if err == nil { + assert.Equal(t, strings.IndexByte(tc.path, '/'), pat.endHost) + } + }) + } +} + +func TestParsePatternParamsConstraint(t *testing.T) { + t.Run("param limit", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParams(3)) + _, _, err := f.parsePattern("/{1}/{2}/{3}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{1}/{2}/{3}/{4}") + assert.Error(t, err) + _, _, err = f.parsePattern("/ab{1}/{2}/cd/{3}/{4}/ef") + assert.Error(t, err) + }) + t.Run("param key limit", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParamKeyBytes(3)) + _, _, err := f.parsePattern("/{abc}/{abc}/{abc}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{abcd}/{abc}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abcd}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abc}/{abcd}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/+{abcd}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abc}/+{abcdef}") + assert.Error(t, err) + }) + t.Run("param key limit with regexp", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) + _, _, err := f.parsePattern("/{abc:a}/{abc:a}/{abc:a}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{abcd:a}/{abc:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abcd:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abc:a}/{abcd:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/+{abcd:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abc:a}/+{abcdef:a}") + assert.Error(t, err) + }) + t.Run("disabled regexp support for param", func(t *testing.T) { + f, _ := NewRouter() + _, _, err := f.parsePattern("/{a}/{b}/{c}") + assert.NoError(t, err) + // path params + _, _, err = f.parsePattern("/{a:a}/{b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b:b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b}/{c:c}") + assert.Error(t, err) + // hostname params + _, _, err = f.parsePattern("{a:a}.{b}.{c}/") + assert.Error(t, err) + _, _, err = f.parsePattern("{a}.{b:b}.{c}/") + assert.Error(t, err) + _, _, err = f.parsePattern("{a}.{b}.{c:c}/") + assert.Error(t, err) + }) + t.Run("disabled regexp support for wildcard", func(t *testing.T) { + f, _ := NewRouter() + _, _, err := f.parsePattern("/{a}/{b}/{c}") + assert.NoError(t, err) + // wildcard + _, _, err = f.parsePattern("/+{a:a}/{b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/+{b:b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b}/+{c:c}") + assert.Error(t, err) + }) +} + +func TestPatternErrorPositionDump(t *testing.T) { + cases := []struct { + name string + pattern string + options []GlobalOption + }{ + {"hostname missing trailing slash", "foo.com", nil}, + {"hostname label starts with dash", "-a.com/", nil}, + {"hostname label starts with dot", ".a.com/", nil}, + {"hostname dash after dot", "a.-b.com/", nil}, + {"hostname consecutive dots", "a..com/", nil}, + {"hostname label ends with dash", "a-.com/", nil}, + {"hostname label exceeds 63 chars at dot", "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", nil}, + {"hostname uppercase character", "A.com/", nil}, + {"hostname illegal character in label", "a!.com/", nil}, + {"hostname trailing dash", "a.com-/", nil}, + {"hostname trailing dot", "a.com./", nil}, + {"hostname all numeric", "123/", nil}, + {"hostname trailing label exceeds 63 chars", "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", nil}, + {"hostname exceeds 253 characters", "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", nil}, + {"hostname missing parameter after + delimiter", "+a.com/", nil}, + {"hostname consecutive wildcard", "+{a}.+{b}.com/", nil}, + {"hostname too many parameters", "{a}.{b}.com/", []GlobalOption{WithMaxRouteParams(1)}}, + {"hostname illegal character after parameter", "{a}b.com/", nil}, + {"hostname optional wildcard not allowed", "a.*{any}/", nil}, + {"hostname bare star missing parameter", "a.b*/", nil}, + {"path missing parameter after + delimiter", "/foo/+bar", nil}, + {"path consecutive wildcard", "/+{a}/+{b}", nil}, + {"path too many parameters", "/foo/{a}/{b}", []GlobalOption{WithMaxRouteParams(1)}}, + {"path optional wildcard not as suffix", "/foo/*{any}/bar", nil}, + {"path illegal character after parameter", "/foo/{a}b", nil}, + {"path illegal control character", "/foo\x01bar", nil}, + {"path consecutive slashes", "/foo//bar", nil}, + {"path consecutive slashes with hostname", "example.com/foo//bar", nil}, + {"path dot segment single dot mid", "/foo/./bar", nil}, + {"path dot segment single dot end", "/foo/.", nil}, + {"path dot segment double dot mid", "/foo/../bar", nil}, + {"path dot segment double dot end", "/foo/..", nil}, + {"path root single dot", "/.", nil}, + {"path root double dot", "/..", nil}, + {"unbalanced braces", "/foo/{bar", nil}, + {"parameter key too large", "/foo/{abcd}", []GlobalOption{WithMaxRouteParamKeyBytes(3)}}, + {"missing parameter name", "/foo/{}", nil}, + {"illegal character in parameter name", "/foo/{*bar}", nil}, + {"regexp not allowed in optional wildcard", "/foo/*{any:[A-z]+}", nil}, + {"regexp feature not enabled", "/foo/{a:[A-z]+}", nil}, + {"regexp missing expression", "/foo/{a:}", []GlobalOption{AllowRegexpParam(true)}}, + {"regexp compile error", "/foo/{a:a{5,2}}", []GlobalOption{AllowRegexpParam(true)}}, + {"regexp capture group not allowed", "/foo/{a:(foo|bar)}", []GlobalOption{AllowRegexpParam(true)}}, + } + + for _, tc := range cases { + f := MustRouter(tc.options...) + _, _, err := f.parsePattern(tc.pattern) + var pe *PatternError + if errors.As(err, &pe) { + fmt.Printf("%-50s start=%-3d end=%-3d\n%s\n\n", tc.name, pe.Start, pe.End, pe.Error()) + } else { + fmt.Printf("%-50s %v\n\n", tc.name, err) + } + } +} + +func TestPatternErrorPosition(t *testing.T) { + cases := []struct { + name string + pattern string + options []GlobalOption + wantType string + wantReason string + wantStart int + wantEnd int + wantMsg string + }{ + { + name: "hostname missing trailing slash", + pattern: "foo.com", + wantType: "hostname", + wantReason: "syntax", + wantStart: 7, + wantEnd: 7, + wantMsg: "missing trailing '/' after hostname", + }, + { + name: "hostname label starts with dash", + pattern: "-a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "label starts with '-'", + }, + { + name: "hostname label starts with dot", + pattern: ".a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "label starts with '.'", + }, + { + name: "hostname dash after dot", + pattern: "a.-b.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 3, + wantMsg: "illegal character after '.'", + }, + { + name: "hostname consecutive dots", + pattern: "a..com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 1, + wantEnd: 3, + wantMsg: "illegal consecutive '.'", + }, + { + name: "hostname label ends with dash", + pattern: "a-.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 1, + wantEnd: 2, + wantMsg: "label ends with '-'", + }, + { + name: "hostname label exceeds 63 chars at dot", + pattern: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 0, + wantEnd: 64, + wantMsg: "label exceeds 63 characters", + }, + { + name: "hostname uppercase character", + pattern: "A.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "uppercase character in label", + }, + { + name: "hostname illegal character in label", + pattern: "a!.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 1, + wantEnd: 2, + wantMsg: "illegal character in label", + }, + { + name: "hostname trailing dash", + pattern: "a.com-/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "illegal trailing '-'", + }, + { + name: "hostname trailing dot", + pattern: "a.com./", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "illegal trailing '.'", + }, + { + name: "hostname all numeric", + pattern: "123/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 3, + wantMsg: "all numeric", + }, + { + name: "hostname trailing label exceeds 63 chars", + pattern: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 4, + wantEnd: 68, + wantMsg: "label exceeds 63 characters", + }, + { + name: "hostname exceeds 253 characters", + pattern: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 0, + wantEnd: 256, + wantMsg: "exceeds 253 characters", + }, + { + name: "hostname missing parameter after + delimiter", + pattern: "+a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "missing parameter after delimiter", + }, + { + name: "hostname consecutive wildcard", + pattern: "+{a}.+{b}.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 9, + wantMsg: "consecutive wildcard", + }, + { + name: "hostname too many parameters", + pattern: "{a}.{b}.com/", + options: []GlobalOption{WithMaxRouteParams(1)}, + wantType: "hostname", + wantReason: "constraint", + wantStart: 4, + wantEnd: 7, + wantMsg: "too many parameters", + }, + { + name: "hostname illegal character after parameter", + pattern: "{a}b.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 3, + wantEnd: 4, + wantMsg: "illegal character after parameter", + }, + { + name: "hostname optional wildcard not allowed", + pattern: "a.*{any}/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 8, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "hostname bare star missing parameter", + pattern: "a.b*/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 3, + wantEnd: 4, + wantMsg: "missing parameter after delimiter", + }, + { + name: "path missing parameter after + delimiter", + pattern: "/foo/+bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "missing parameter after delimiter", + }, + { + name: "path consecutive wildcard", + pattern: "/+{a}/+{b}", + wantType: "path", + wantReason: "syntax", + wantStart: 6, + wantEnd: 10, + wantMsg: "consecutive wildcard", + }, + { + name: "path too many parameters", + pattern: "/foo/{a}/{b}", + options: []GlobalOption{WithMaxRouteParams(1)}, + wantType: "path", + wantReason: "constraint", + wantStart: 9, + wantEnd: 12, + wantMsg: "too many parameters", + }, + { + name: "path optional wildcard not as suffix", + pattern: "/foo/*{any}/bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 11, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "path illegal character after parameter", + pattern: "/foo/{a}b", + wantType: "path", + wantReason: "syntax", + wantStart: 8, + wantEnd: 9, + wantMsg: "illegal character after parameter", + }, + { + name: "path illegal control character", + pattern: "/foo\x01bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 5, + wantMsg: "illegal control character", + }, + { + name: "path consecutive slashes", + pattern: "/foo//bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 6, + wantMsg: "consecutive '/'", + }, + { + name: "path consecutive slashes with hostname", + pattern: "example.com/foo//bar", + wantType: "path", + wantReason: "syntax", + wantStart: 15, + wantEnd: 17, + wantMsg: "consecutive '/'", + }, + { + name: "path dot segment single dot mid", + pattern: "/foo/./bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 7, + wantMsg: "dot segment", + }, + { + name: "path dot segment single dot end", + pattern: "/foo/.", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 6, + wantMsg: "dot segment", + }, + { + name: "path dot segment double dot mid", + pattern: "/foo/../bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 8, + wantMsg: "dot segment", + }, + { + name: "path dot segment double dot end", + pattern: "/foo/..", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 7, + wantMsg: "dot segment", + }, + { + name: "path root single dot", + pattern: "/.", + wantType: "path", + wantReason: "syntax", + wantStart: 0, + wantEnd: 2, + wantMsg: "dot segment", + }, + { + name: "path root double dot", + pattern: "/..", + wantType: "path", + wantReason: "syntax", + wantStart: 0, + wantEnd: 3, + wantMsg: "dot segment", + }, + { + name: "unbalanced braces", + pattern: "/foo/{bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 9, + wantMsg: "unbalanced braces", + }, + { + name: "parameter key too large", + pattern: "/foo/{abcd}", + options: []GlobalOption{WithMaxRouteParamKeyBytes(3)}, + wantType: "path", + wantReason: "constraint", + wantStart: 6, + wantEnd: 10, + wantMsg: "key too large", + }, + { + name: "missing parameter name", + pattern: "/foo/{}", + wantType: "path", + wantReason: "parameter", + wantStart: 5, + wantEnd: 7, + wantMsg: "missing name", + }, + { + name: "illegal character in parameter name", + pattern: "/foo/{*bar}", + wantType: "path", + wantReason: "parameter", + wantStart: 6, + wantEnd: 7, + wantMsg: "illegal character in name", + }, + { + name: "regexp not allowed in optional wildcard", + pattern: "/foo/*{any:[A-z]+}", + wantType: "path", + wantReason: "regexp", + wantStart: 5, + wantEnd: 18, + wantMsg: "not allowed in optional wildcard", + }, + { + name: "regexp feature not enabled", + pattern: "/foo/{a:[A-z]+}", + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 14, + wantMsg: "feature not enabled", + }, + { + name: "regexp missing expression", + pattern: "/foo/{a:}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 8, + wantMsg: "missing expression", + }, + { + name: "regexp compile error", + pattern: "/foo/{a:a{5,2}}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 14, + wantMsg: "error parsing regexp", + }, + { + name: "regexp capture group not allowed", + pattern: "/foo/{a:(foo|bar)}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 17, + wantMsg: "capture group", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := MustRouter(tc.options...) + _, _, err := f.parsePattern(tc.pattern) + require.Error(t, err) + var pe *PatternError + require.ErrorAs(t, err, &pe) + assert.Equal(t, tc.wantType, pe.Type) + assert.Equal(t, tc.wantReason, pe.Reason) + assert.Equal(t, tc.wantStart, pe.Start) + assert.Equal(t, tc.wantEnd, pe.End) + assert.Contains(t, pe.Error(), tc.wantMsg) + fmt.Println(err) + }) + } +} + +func TestPatternErrorUnwrap(t *testing.T) { + t.Run("regexp compile error wraps underlying error", func(t *testing.T) { + f := MustRouter(AllowRegexpParam(true)) + _, _, err := f.parsePattern("/foo/{a:a{5,2}}") + require.Error(t, err) + var pe *PatternError + require.ErrorAs(t, err, &pe) + var syntaxErr *syntax.Error + assert.ErrorAs(t, pe, &syntaxErr) + }) + t.Run("non-regexp error returns nil on unwrap", func(t *testing.T) { + f := MustRouter() + _, _, err := f.parsePattern("/foo//bar") + require.Error(t, err) + var pe *PatternError + require.ErrorAs(t, err, &pe) + assert.Nil(t, pe.Unwrap()) + }) + t.Run("empty pattern returns ErrInvalidRoute", func(t *testing.T) { + f := MustRouter() + _, _, err := f.parsePattern("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRoute) + var pe *PatternError + assert.False(t, errors.As(err, &pe)) + }) +} diff --git a/route.go b/route.go index aceabc5e..6e252205 100644 --- a/route.go +++ b/route.go @@ -14,17 +14,14 @@ type Route struct { hself HandlerFunc hall HandlerFunc annots map[any]any - pattern string name string methods []string mws []middleware params []string - tokens []token matchers []Matcher - hostEnd int + pattern pattern priority uint handleSlash TrailingSlashOption - catchEmpty bool } // Handle calls the handler with the provided [Context]. See also [Route.HandleMiddleware]. @@ -55,17 +52,17 @@ func (r *Route) Methods() iter.Seq[string] { // Pattern returns the registered route pattern. func (r *Route) Pattern() string { - return r.pattern + return r.pattern.str } // Hostname returns the hostname part of the registered pattern if any. func (r *Route) Hostname() string { - return r.pattern[:r.hostEnd] + return r.pattern.str[:r.pattern.endHost] } // Path returns the path part of the registered pattern. func (r *Route) Path() string { - return r.pattern[r.hostEnd:] + return r.pattern.str[r.pattern.endHost:] } // Name returns the name of this [Route]. @@ -201,7 +198,7 @@ func routef(sb *strings.Builder, route *Route, pad int, showName bool) { } sb.WriteString(" pattern:") - sb.WriteString(route.pattern) + sb.WriteString(route.pattern.str) if route.name != "" && showName { sb.WriteString(" name:") diff --git a/tree.go b/tree.go index b845e178..9cb2068a 100644 --- a/tree.go +++ b/tree.go @@ -258,13 +258,13 @@ func (t *tXn) deleteNameIn(root, n *node, search string) *node { func (t *tXn) insert(route *Route, mode insertMode) error { t.mode = mode - newRoot, err := t.insertTokens(nil, t.patterns, route.tokens, route) + newRoot, err := t.insertTokens(nil, t.patterns, route.pattern.tokens, route) if err != nil { return err } if newRoot != nil { t.patterns = newRoot - t.maxDepth = max(t.maxDepth, t.computePathDepth(newRoot, route.tokens)) + t.maxDepth = max(t.maxDepth, t.computePathDepth(newRoot, route.pattern.tokens)) t.maxParams = max(t.maxParams, len(route.params)) t.size++ if len(route.methods) > 0 && t.mode == modeInsert { @@ -308,7 +308,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err // Since pattern matching precedes matcher evaluation, a conflict occurs when the exact path has no matchers (shadows all requests // to that pattern) or matchers equal to the catch-empty's (both match the same request). var conflicts []*Route - if route.catchEmpty && p != nil { + if route.pattern.optionalCatchAll && p != nil { for _, r := range p.routes { if (len(r.matchers) == 0 || r.matchersEqual(route.matchers)) && slicesutil.Overlap(r.methods, route.methods) { conflicts = append(conflicts, r) @@ -321,7 +321,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err for _, wildcard := range n.wildcards { for _, r := range wildcard.routes { - if r.catchEmpty && (len(route.matchers) == 0 || route.matchersEqual(r.matchers)) && slicesutil.Overlap(route.methods, r.methods) { + if r.pattern.optionalCatchAll && (len(route.matchers) == 0 || route.matchersEqual(r.matchers)) && slicesutil.Overlap(route.methods, r.methods) { conflicts = append(conflicts, r) } } @@ -341,7 +341,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err return nc, nil case modeUpdate: idx := slices.IndexFunc(n.routes, func(r *Route) bool { - return r.pattern == route.pattern && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) + return r.pattern.str == route.pattern.str && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) }) if idx == -1 { return nil, newRouteNotFoundError(route) @@ -606,7 +606,7 @@ func (t *tXn) insertWildcard(n *node, tk token, remaining []token, route *Route) // delete performs a recursive copy-on-write deletion. func (t *tXn) delete(route *Route) (*Route, bool) { - newRoot, oldRoute := t.deleteTokens(t.patterns, t.patterns, route.tokens, route) + newRoot, oldRoute := t.deleteTokens(t.patterns, t.patterns, route.pattern.tokens, route) if newRoot != nil { t.patterns = newRoot if !t.forked && len(route.methods) > 0 { @@ -636,7 +636,7 @@ func (t *tXn) deleteTokens(root, n *node, tokens []token, route *Route) (*node, } idx := slices.IndexFunc(n.routes, func(r *Route) bool { - return r.pattern == route.pattern && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) + return r.pattern.str == route.pattern.str && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) }) if idx == -1 { return nil, nil diff --git a/tree_test.go b/tree_test.go index 0571129b..6df07d6b 100644 --- a/tree_test.go +++ b/tree_test.go @@ -1228,7 +1228,7 @@ func TestDomainLookup(t *testing.T) { c := newTestContext(f) idx, n, tsr := tree.lookup(http.MethodGet, tc.host, tc.path, c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -1499,7 +1499,7 @@ func TestMatchersLookup(t *testing.T) { c.req = req idx, n, tsr := tree.lookup(http.MethodGet, tc.host, c.Path(), c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPattern, n.routes[idx].pattern) + assert.Equal(t, tc.wantPattern, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -1645,7 +1645,7 @@ func TestMatchersLookupWithPriority(t *testing.T) { c.req = req idx, n, _ := tree.lookup(http.MethodGet, "", c.Path(), c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPattern, n.routes[idx].pattern) + assert.Equal(t, tc.wantPattern, n.routes[idx].pattern.str) assert.Equal(t, tc.wantMatcher, n.routes[idx].matchers) c.route = n.routes[idx] *c.paramsKeys = c.route.params diff --git a/txn.go b/txn.go index 9a10e5a2..df58ea3f 100644 --- a/txn.go +++ b/txn.go @@ -19,9 +19,10 @@ type Txn struct { // Add registers a new route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteConflict]: If the route conflict with others. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. @@ -71,9 +72,10 @@ func (txn *Txn) AddRoute(route *Route) error { // Update override an existing route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. @@ -128,8 +130,9 @@ func (txn *Txn) UpdateRoute(route *Route) error { // Delete deletes an existing route for the given methods, pattern and matchers. On success, it returns the deleted [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid or the pattern is empty. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. // @@ -148,15 +151,13 @@ func (txn *Txn) Delete(methods []string, pattern string, opts ...MatcherOption) } } - parsed, err := txn.fox.parseRoute(pattern) + pat, _, err := txn.fox.parsePattern(pattern) if err != nil { return nil, err } rte := &Route{ - pattern: pattern, - hostEnd: parsed.endHost, - tokens: parsed.token, + pattern: pat, } for _, opt := range opts { @@ -250,7 +251,7 @@ func (txn *Txn) Route(methods []string, pattern string, matchers ...Matcher) *Ro return nil } idx := slices.IndexFunc(matched.routes, func(r *Route) bool { - return r.pattern == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) + return r.pattern.str == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) }) if idx < 0 { return nil @@ -317,7 +318,7 @@ func (txn *Txn) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc *Con idx, n, tsr := txn.rootTxn.patterns.lookup(r.Method, r.Host, path, c, false) if n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params return c.route, c, tsr } diff --git a/txn_test.go b/txn_test.go index ab0252d8..16d3042b 100644 --- a/txn_test.go +++ b/txn_test.go @@ -432,7 +432,7 @@ func TestUpdateWithName(t *testing.T) { WithHeaderMatcher("Authorization", "secret"), ) require.NoError(t, err) - assert.Equal(t, "/users/{name}", route.pattern) + assert.Equal(t, "/users/{name}", route.pattern.str) assert.Empty(t, route.name) assert.Nil(t, txn.Name("users_name")) txn.Commit() @@ -446,7 +446,7 @@ func TestUpdateWithName(t *testing.T) { WithName("foo"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "foo", route.name) assert.NotNil(t, txn.Name("foo")) txn.Commit() @@ -462,7 +462,7 @@ func TestUpdateWithName(t *testing.T) { WithName("users"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "users", route.name) assert.NotNil(t, txn.Name("users")) txn.Commit() @@ -478,7 +478,7 @@ func TestUpdateWithName(t *testing.T) { WithName("new_users"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "new_users", route.name) assert.Nil(t, txn.Name("users")) assert.NotNil(t, txn.Name("new_users"))