Skip to content

Commit

Permalink
Add optional regex validation for wildcards and allow wildcards with …
Browse files Browse the repository at this point in the history
…suffix
  • Loading branch information
Sergio Andres Virviescas Santana committed Mar 27, 2020
1 parent 137e277 commit 510879a
Show file tree
Hide file tree
Showing 19 changed files with 756 additions and 303 deletions.
61 changes: 38 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,41 +98,56 @@ func Hello(ctx *fasthttp.RequestCtx) {
func main() {
r := router.New()
r.GET("/", Index)
r.GET("/hello/:name", Hello)
r.GET("/hello/{name}", Hello)

log.Fatal(fasthttp.ListenAndServe(":8080", r.Handler))
}
```

### Named parameters

As you can see, `:name` is a _named parameter_. The values are accessible via `RequestCtx.UserValues`. You can get the value of a parameter by using the `ctx.UserValue("name")`.
As you can see, `{name}` is a _named parameter_. The values are accessible via `RequestCtx.UserValues`. You can get the value of a parameter by using the `ctx.UserValue("name")`.

Named parameters only match a single path segment:

```
Pattern: /user/:user
Pattern: /user/{user}
/user/gordon match
/user/you match
/user/gordon/profile no match
/user/ no match
/user/gordon match
/user/you match
/user/gordon/profile no match
/user/ no match
Pattern with suffix: /user/{user}_admin
/user/gordon_admin match
/user/you_admin match
/user/you no match
/user/gordon/profile no match
/user/gordon_admin/profile no match
/user/ no match
```

**Note:** Since this router has only explicit matches, you can not register static routes and parameters for the same path segment. For example you can not register the patterns `/user/new` and `/user/:user` for the same request method at the same time. The routing of different request methods is independent from each other.
**Note:** Since this router has only explicit matches, you can not register static routes and parameters for the same path segment. For example you can not register the patterns `/user/new` and `/user/{user}` for the same request method at the same time. The routing of different request methods is independent from each other.

#### Optional parameters

If you need define an optional parameters, add `?` at the end of param name. `:name?`
If you need define an optional parameters, add `?` at the end of param name. `{name?}`

#### Regex validation

If you need define a validation, you could use a custom regex for the paramater value, add `:<regex>` after the name. For example: `{name:[a-zA-Z]{5}}`.

**_Optional paramters and regex validation are compatibles, only add `?` between the name and the regex. For example: `{name?:[a-zA-Z]{5}}`._**

### Catch-All parameters

The second type are _catch-all_ parameters and have the form `*name`.
The second type are _catch-all_ parameters and have the form `{name:*}`.
Like the name suggests, they match everything.
Therefore they must always be at the **end** of the pattern:

```
Pattern: /src/*filepath
Pattern: /src/{filepath:*}
/src/ match
/src/somefile.go match
Expand All @@ -145,19 +160,19 @@ The router relies on a tree structure which makes heavy use of _common prefixes_

```
Priority Path Handle
9 \ *<1>
3 ├s nil
2 |├earch\ *<2>
1 |└upport\ *<3>
2 ├blog\ *<4>
1 | └:post nil
1 | └\ *<5>
2 ├about-us\ *<6>
1 | └team\ *<7>
1 └contact\ *<8>
9 \ *<1>
3 ├s nil
2 |├earch\ *<2>
1 |└upport\ *<3>
2 ├blog\ *<4>
1 | └{post} nil
1 | └\ *<5>
2 ├about-us\ *<6>
1 | └team\ *<7>
1 └contact\ *<8>
```

Every `*<num>` represents the memory address of a handler function (a pointer). If you follow a path trough the tree from the root to the leaf, you get the complete route path, e.g `\blog\:post\`, where `:post` is just a placeholder ([_parameter_](#named-parameters)) for an actual post name. Unlike hash-maps, a tree structure also allows us to use dynamic parts like the `:post` parameter, since we actually match against the routing patterns instead of just comparing hashes. [As benchmarks show][benchmark], this works very well and efficient.
Every `*<num>` represents the memory address of a handler function (a pointer). If you follow a path trough the tree from the root to the leaf, you get the complete route path, e.g `\blog\{post}\`, where `{post}` is just a placeholder ([_parameter_](#named-parameters)) for an actual post name. Unlike hash-maps, a tree structure also allows us to use dynamic parts like the `{post}` parameter, since we actually match against the routing patterns instead of just comparing hashes. [As benchmarks show][benchmark], this works very well and efficient.

Since URL paths have a hierarchical structure and make use only of a limited set of characters (byte values), it is very likely that there are a lot of common prefixes. This allows us to easily reduce the routing into ever smaller problems. Moreover the router manages a separate tree for every request method. For one thing it is more space efficient than holding a method->handle map in every single node, for another thing is also allows us to greatly reduce the routing problem before even starting the look-up in the prefix-tree.

Expand Down Expand Up @@ -208,7 +223,7 @@ The `NotFound` handler can for example be used to serve static files from the ro
r.NotFound = fasthttp.FSHandler("./public", 0)
```

But this approach sidesteps the strict core rules of this router to avoid routing problems. A cleaner approach is to use a distinct sub-path for serving files, like `/static/*filepath` or `/files/*filepath`.
But this approach sidesteps the strict core rules of this router to avoid routing problems. A cleaner approach is to use a distinct sub-path for serving files, like `/static/{filepath:*}` or `/files/{filepath:*}`.

## Web Frameworks based on Router

Expand Down
4 changes: 2 additions & 2 deletions examples/basic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func QueryArgs(ctx *fasthttp.RequestCtx) {
func main() {
r := router.New()
r.GET("/", Index)
r.GET("/hello/:name", Hello)
r.GET("/multi/:name/:word", MultiParams)
r.GET("/hello/{name}", Hello)
r.GET("/multi/{name}/{word}", MultiParams)
r.GET("/ping", QueryArgs)

log.Fatal(fasthttp.ListenAndServe(":8080", r.Handler))
Expand Down
12 changes: 9 additions & 3 deletions examples/basic/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ func MultiParams(ctx *fasthttp.RequestCtx) {
fmt.Fprintf(ctx, "hi, %s, %s!\n", ctx.UserValue("name"), ctx.UserValue("word"))
}

// RegexParams is the params handler with regex validation
func RegexParams(ctx *fasthttp.RequestCtx) {
fmt.Fprintf(ctx, "hi, %s\n", ctx.UserValue("name"))
}

// QueryArgs is used for uri query args test #11:
// if the req uri is /ping?name=foo, output: Pong! foo
// if the req uri is /piNg?name=foo, redirect to /ping, output: Pong!
Expand All @@ -34,9 +39,10 @@ func QueryArgs(ctx *fasthttp.RequestCtx) {
func main() {
r := router.New()
r.GET("/", Index)
r.GET("/hello/:name", Hello)
r.GET("/multi/:name/:word", MultiParams)
r.GET("/optional/:name?/:word?", MultiParams)
r.GET("/hello/{name}", Hello)
r.GET("/multi/{name}/{word}", MultiParams)
r.GET("/regex/{name:[a-zA-Z]+}/test", RegexParams)
r.GET("/optional/{name?:[a-zA-Z]+}/{word?}", MultiParams)
r.GET("/ping", QueryArgs)

log.Fatal(fasthttp.ListenAndServe(":8080", r.Handler))
Expand Down
2 changes: 1 addition & 1 deletion examples/hosts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func main() {
// Initialize a router as usual
r := router.New()
r.GET("/", Index)
r.GET("/hello/:name", Hello)
r.GET("/hello/{name}", Hello)

// Make a new HostSwitch and insert the router (our http handler)
// for example.com and port 12345
Expand Down
2 changes: 1 addition & 1 deletion examples/hosts/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func main() {
// Initialize a router as usual
r := router.New()
r.GET("/", Index)
r.GET("/hello/:name", Hello)
r.GET("/hello/{name}", Hello)

// Make a new HostSwitch and insert the router (our http handler)
// for example.com and port 12345
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/fasthttp/router
go 1.13

require (
github.com/savsgio/gotils v0.0.0-20200117113501-90175b0fbe3f
github.com/savsgio/gotils v0.0.0-20200319105752-a9cc718f6a3f
github.com/valyala/bytebufferpool v1.0.0
github.com/valyala/fasthttp v1.9.0
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/klauspost/compress v1.8.2 h1:Bx0qjetmNjdFXASH02NSAREKpiaDwkO1DRZ3dV2K
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/savsgio/gotils v0.0.0-20200117113501-90175b0fbe3f h1:PgA+Olipyj258EIEYnpFFONrrCcAIWNUNoFhUfMqAGY=
github.com/savsgio/gotils v0.0.0-20200117113501-90175b0fbe3f/go.mod h1:lHhJedqxCoHN+zMtwGNTXWmF0u9Jt363FYRhV6g0CdY=
github.com/savsgio/gotils v0.0.0-20200319105752-a9cc718f6a3f h1:XfUnevLK4O22at3R77FlyQHKwlQs75LELdsH2wRX2KQ=
github.com/savsgio/gotils v0.0.0-20200319105752-a9cc718f6a3f/go.mod h1:lHhJedqxCoHN+zMtwGNTXWmF0u9Jt363FYRhV6g0CdY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.9.0 h1:hNpmUdy/+ZXYpGy0OBfm7K0UQTzb73W0T0U4iJIVrMw=
Expand Down
72 changes: 46 additions & 26 deletions path.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@

package router

import (
"strings"

"github.com/savsgio/gotils"
)
import "github.com/savsgio/gotils"

const stackBufSize = 128

Expand Down Expand Up @@ -159,29 +155,53 @@ func bufApp(buf *[]byte, s string, w int, c byte) {
func getOptionalPaths(path string) []string {
paths := make([]string, 0)

index := 0
newParam := false
for i := 0; i < len(path); i++ {
c := path[i]

if c == ':' {
index = i
newParam = true
} else if i > 0 && newParam && c == '?' {
p := strings.Replace(path[:index], "?", "", -1)
p = p[:len(p)-1]
if !gotils.StringSliceInclude(paths, p) {
paths = append(paths, p)
}
start := 0
walk:
for {
if start >= len(path) {
return paths
}

p = strings.Replace(path[:i], "?", "", -1)
if !gotils.StringSliceInclude(paths, p) {
paths = append(paths, p)
}
c := path[start]
start++

newParam = false
if c != '{' {
continue
}
}

return paths
newPath := ""
questionMarkIndex := -1

for end, c := range []byte(path[start:]) {
switch c {
case '}':
if questionMarkIndex == -1 {
continue walk
}

end++
if len(path) > start+end && path[start+end] == '/' {
// Include trailing slash for a better lookup
end++
}

newPath += path[questionMarkIndex+1 : start+end]

path = path[:questionMarkIndex] + path[questionMarkIndex+1:] // remove '?'
paths = append(paths, newPath)
start += end - 1

continue walk

case '?':
questionMarkIndex = start + end
newPath += path[:questionMarkIndex]

// include the path without the wildcard
if !gotils.StringSliceInclude(paths, path[:start-1]) {
paths = append(paths, path[:start-1])
}
}
}
}
}
40 changes: 28 additions & 12 deletions path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package router

import (
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -142,24 +143,39 @@ func TestGetOptionalPath(t *testing.T) {
ctx.SetStatusCode(fasthttp.StatusOK)
}

expectedPaths := []string{
"/show/:name",
"/show/:name/:surname",
"/show/:name/:surname/at",
"/show/:name/:surname/at/:address",
"/show/:name/:surname/at/:address/:id",
"/show/:name/:surname/at/:address/:id/:phone",
expected := []struct {
path string
tsr bool
handler fasthttp.RequestHandler
}{
{"/show/{name}", true, nil},
{"/show/{name}/", false, handler},
{"/show/{name}/{surname}", true, nil},
{"/show/{name}/{surname}/", false, handler},
{"/show/{name}/{surname}/at", true, nil},
{"/show/{name}/{surname}/at/", false, handler},
{"/show/{name}/{surname}/at/{address}", true, nil},
{"/show/{name}/{surname}/at/{address}/", false, handler},
{"/show/{name}/{surname}/at/{address}/{id}", true, nil},
{"/show/{name}/{surname}/at/{address}/{id}/", false, handler},
{"/show/{name}/{surname}/at/{address}/{id}/{phone:.*}", false, handler},
{"/show/{name}/{surname}/at/{address}/{id}/{phone:.*}/", true, nil},
}

r := New()
r.GET("/show/:name/:surname?/at/:address?/:id/:phone?", handler)
r.GET("/show/{name}/{surname?}/at/{address?}/{id}/{phone?:.*}", handler)

for _, path := range expectedPaths {
for _, e := range expected {
ctx := new(fasthttp.RequestCtx)

h, _ := r.Lookup("GET", path, ctx)
h, tsr := r.Lookup("GET", e.path, ctx)

if tsr != e.tsr {
t.Errorf("TSR (path: %s) == %v, want %v", e.path, tsr, e.tsr)
}

if h == nil {
t.Errorf("Expected optional path '%s' is not registered", path)
if reflect.ValueOf(h).Pointer() != reflect.ValueOf(e.handler).Pointer() {
t.Errorf("Handler (path: %s) == %p, want %p", e.path, h, e.handler)
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions radix/conts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package radix

const stackBufSize = 128

const (
root nodeType = iota
static
param
wildcard
)
Loading

0 comments on commit 510879a

Please sign in to comment.