Skip to content

Commit

Permalink
[minor] URI patters now support multiple wildcards, you can form more…
Browse files Browse the repository at this point in the history
… patterns. e.g. all routes ending with /hello. (#36)

[minor] URI patters now support multiple wildcards, you can form more patterns. e.g. all routes ending with /hello
[-] refer to the test TestWildcardMadness in router_test.go to see sample usage
[patch] there was a regression introduced while fixing trailing slash config support for wildcard routes
[-] regression was introduced after changing the regex used for route matching. Now there's no more
regex and routes are parsed with custom fns (is ~2x faster)
  • Loading branch information
bnkamalesh authored Feb 8, 2022
1 parent 2f16471 commit b774906
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 191 deletions.
196 changes: 94 additions & 102 deletions route.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package webgo
import (
"fmt"
"net/http"
"regexp"
"strings"
)

Expand All @@ -26,12 +25,8 @@ type Route struct {
// subsequent writes from the following handlers will be ignored
Handlers []http.HandlerFunc

// uriKeys is the list of URI parameter variables available for this route
uriKeys []string
// uriPatternString is the pattern string which is compiled to regex object
uriPatternString string
// uriPattern is the compiled regex to match the URI pattern
uriPattern *regexp.Regexp
hasWildcard bool
parts []routePart

// skipMiddleware if true, middleware added using `router` will not be applied to this Route.
// This is used only when a Route is set using the RouteGroup, which can have its own set of middleware
Expand All @@ -41,108 +36,56 @@ type Route struct {

serve http.HandlerFunc
}

// computePatternStr computes the pattern string required for generating the route's regex.
// It also adds the URI parameter key to the route's `keys` field
func (r *Route) computePatternStr(patternString string, hasWildcard bool, key string) (string, error) {
regexPattern := ""
patternKey := ""
if hasWildcard {
patternKey = fmt.Sprintf(":%s*", key)
regexPattern = urlwildcard
if r.TrailingSlash {
regexPattern = urlwildcardWithTrailslash
}
} else {
patternKey = fmt.Sprintf(":%s", key)
regexPattern = urlchars
}

patternString = strings.Replace(patternString, patternKey, regexPattern, 1)

for idx, k := range r.uriKeys {
if key == k {
return "", fmt.Errorf(
"%s\nURI:%s\nKey:%s, Position: %d",
errDuplicateKey,
r.Pattern,
k,
idx+1,
)
}
}

r.uriKeys = append(r.uriKeys, key)
return patternString, nil
type routePart struct {
isVariable bool
hasWildcard bool
// part will be the key name, if it's a variable/named URI parameter
part string
}

func (r *Route) parseURIWithParams(patternString string) (string, error) {
func (r *Route) parseURIWithParams() {
// if there are no URI params, then there's no need to set route parts
if !strings.Contains(r.Pattern, ":") {
return patternString, nil
return
}

var err error
// uriValues is a map of URI Key and its respective value,
// this is calculated per request
key := ""
hasKey := false
hasWildcard := false
parts := strings.Split(r.Pattern, "/")
if len(parts) == 1 {
return
}

for i := 0; i < len(r.Pattern); i++ {
char := string(r.Pattern[i])
rparts := make([]routePart, 0, len(parts))
for _, part := range parts[1:] {
hasParam := false
hasWildcard := false

if char == ":" {
hasKey = true
} else if char == "*" {
if strings.Contains(part, ":") {
hasParam = true
}
if strings.Contains(part, "*") {
r.hasWildcard = true
hasWildcard = true
} else if hasKey && char != "/" {
key += char
} else if hasKey && len(key) > 0 {
patternString, err = r.computePatternStr(patternString, hasWildcard, key)
if err != nil {
return "", err
}
hasWildcard, hasKey = false, false
key = ""
}
}

if hasKey && len(key) > 0 {
patternString, err = r.computePatternStr(patternString, hasWildcard, key)
if err != nil {
return "", err
}
}
return patternString, nil
key := strings.ReplaceAll(part, ":", "")
key = strings.ReplaceAll(key, "*", "")
rparts = append(
rparts,
routePart{
isVariable: hasParam,
hasWildcard: hasWildcard,
part: key,
})
}
r.parts = rparts
}

// init prepares the URIKeys, compile regex for the provided pattern
func (r *Route) init() error {
if r.initialized {
return nil
}

patternString := r.Pattern

patternString, err := r.parseURIWithParams(patternString)
if err != nil {
return err
}

if r.TrailingSlash {
patternString = fmt.Sprintf("^%s%s$", patternString, trailingSlash)
} else {
patternString = fmt.Sprintf("^%s$", patternString)
}

// compile the regex for the pattern string calculated
reg, err := regexp.Compile(patternString)
if err != nil {
return err
}

r.uriPattern = reg
r.uriPatternString = patternString
r.parseURIWithParams()
r.serve = defaultRouteServe(r)

r.initialized = true
Expand All @@ -151,23 +94,72 @@ func (r *Route) init() error {

// matchPath matches the requestURI with the URI pattern of the route.
// If the path is an exact match (i.e. no URI parameters), then the second parameter ('isExactMatch') is true
func (r *Route) matchPath(requestURI string) (bool, isExactMatch bool) {
if r.Pattern == requestURI {
return true, true
func (r *Route) matchPath(requestURI string) (bool, map[string]string) {
p := r.Pattern
if r.TrailingSlash {
p += "/"
} else {
if requestURI[len(requestURI)-1] == '/' {
return false, nil
}
}
if r.Pattern == requestURI || p == requestURI {
return true, nil
}

return r.uriPattern.Match([]byte(requestURI)), false
return r.matchWithWildcard(requestURI)
}

func (r *Route) params(requestURI string) map[string]string {
params := r.uriPattern.FindStringSubmatch(requestURI)[1:]
uriValues := make(map[string]string, len(params))
func (r *Route) matchWithWildcard(requestURI string) (bool, map[string]string) {
params := map[string]string{}
uriParts := strings.Split(requestURI, "/")[1:]

partsLastIdx := len(r.parts) - 1
partIdx := 0
paramParts := make([]string, 0, len(uriParts))
for idx, part := range uriParts {
// if part is empty, it means it's end of URI with trailing slash
if part == "" {
break
}

for i := 0; i < len(params); i++ {
uriValues[r.uriKeys[i]] = params[i]
if partIdx > partsLastIdx {
return false, nil
}

currentPart := r.parts[partIdx]
if !currentPart.isVariable && currentPart.part != part {
return false, nil
}

paramParts = append(paramParts, part)
if currentPart.isVariable {
params[currentPart.part] = strings.Join(paramParts, "/")
}

if !currentPart.hasWildcard {
paramParts = make([]string, 0, len(uriParts)-idx)
partIdx++
continue
}

nextIdx := partIdx + 1
if nextIdx > partsLastIdx {
continue
}
nextPart := r.parts[nextIdx]

// if the URI has more parts/params after wildcard,
// the immediately following part after wildcard cannot be a variable or another wildcard.
if !nextPart.isVariable && nextPart.part == part {
// remove the last added 'part' from parameters, as it's part of the static URI
params[currentPart.part] = strings.Join(paramParts[:len(paramParts)-1], "/")
paramParts = make([]string, 0, len(uriParts)-idx)
partIdx += 2
}
}

return uriValues
return true, params
}

func (r *Route) use(mm ...Middleware) {
Expand Down
69 changes: 0 additions & 69 deletions route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,78 +3,9 @@ package webgo
import (
"fmt"
"net/http"
"regexp"
"strings"
"testing"
)

func TestRoute_computePatternStr(t *testing.T) {
t.Parallel()
type fields struct {
Name string
Method string
Pattern string
TrailingSlash bool
FallThroughPostResponse bool
Handlers []http.HandlerFunc
uriKeys []string
uriPatternString string
uriPattern *regexp.Regexp
serve http.HandlerFunc
}
type args struct {
patternString string
hasWildcard bool
key string
}
tests := []struct {
name string
fields fields
args args
want string
wantErr bool
}{
{
name: "duplicate URIs",
fields: fields{
Pattern: "/a/b/:c/:c",
// uriKeys is initialized with a key, so as to detect duplicate key
uriKeys: []string{"c"},
},
args: args{
patternString: strings.Replace("/a/b/:c/:c", ":c", urlchars, 2),
hasWildcard: false,
key: "c",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Route{
Name: tt.fields.Name,
Method: tt.fields.Method,
Pattern: tt.fields.Pattern,
TrailingSlash: tt.fields.TrailingSlash,
FallThroughPostResponse: tt.fields.FallThroughPostResponse,
Handlers: tt.fields.Handlers,
uriKeys: tt.fields.uriKeys,
uriPatternString: tt.fields.uriPatternString,
uriPattern: tt.fields.uriPattern,
serve: tt.fields.serve,
}
got, err := r.computePatternStr(tt.args.patternString, tt.args.hasWildcard, tt.args.key)
if (err != nil) != tt.wantErr {
t.Errorf("Route.computePatternStr() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Route.computePatternStr() = %v, want %v", got, tt.want)
}
})
}
}

func TestRouteGroupsPathPrefix(t *testing.T) {
t.Parallel()
routes := []Route{
Expand Down
12 changes: 6 additions & 6 deletions router.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ func (crw *customResponseWriter) reset() {
type Middleware func(http.ResponseWriter, *http.Request, http.HandlerFunc)

// discoverRoute returns the correct 'route', for the given request
func discoverRoute(path string, routes []*Route) *Route {
func discoverRoute(path string, routes []*Route) (*Route, map[string]string) {
for _, route := range routes {
if ok, _ := route.matchPath(path); ok {
return route
if ok, params := route.matchPath(path); ok {
return route, params
}
}
return nil
return nil, nil
}

// Router is the HTTP router
Expand Down Expand Up @@ -182,7 +182,7 @@ func (rtr *Router) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
}

path := r.URL.EscapedPath()
route := discoverRoute(path, routes)
route, params := discoverRoute(path, routes)
if route == nil {
// serve 404 when there are no matching routes
crw.statusCode = http.StatusNotFound
Expand All @@ -192,8 +192,8 @@ func (rtr *Router) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
}

ctxPayload := newContext()
ctxPayload.path = path
ctxPayload.Route = route
ctxPayload.URIParams = params

// webgo context is injected to the HTTP request context
*r = *r.WithContext(
Expand Down
Loading

0 comments on commit b774906

Please sign in to comment.