Skip to content

Commit

Permalink
Support suffix matching
Browse files Browse the repository at this point in the history
  • Loading branch information
zensh committed Mar 24, 2017
1 parent f6e5ec2 commit b1ab162
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 84 deletions.
3 changes: 3 additions & 0 deletions .travis.yml
Expand Up @@ -4,5 +4,8 @@ node_js:
- "4"
- "6"
- "7"
cache:
directories:
- node_modules
script: "npm run test-cov"
after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls"
66 changes: 44 additions & 22 deletions README.md
@@ -1,5 +1,5 @@
route-trie
====
# route-trie

A minimal and powerful trie based url path router for Node.js.

[![NPM version][npm-image]][npm-url]
Expand Down Expand Up @@ -27,13 +27,15 @@ Implementations:
- [hirouter](https://github.com/teambition/hirouter) HTML5 history and router, simple, powerful and no framework(browser).
- [RotorJS](https://github.com/kuraga/rotorjs) Component-based JavaScript library for single-page applications and an example application.

## Features:
## Features

1. Support named parameter
1. Support regexp
2. Fixed path automatic redirection
3. Trailing slash automatic redirection
4. Support `405 Method Not Allowed`
5. Best Performance
1. Support suffix matching
1. Fixed path automatic redirection
1. Trailing slash automatic redirection
1. Support `405 Method Not Allowed`
1. Best Performance

## Installation

Expand Down Expand Up @@ -70,13 +72,15 @@ Returns a Node instance for the `pattern`, The same pattern will always return t

## Pattern Rule

The defined pattern can contain three types of parameters:
The defined pattern can contain six types of parameters:

| Syntax | Description |
|--------|------|
| `:name` | named parameter |
| `:name*` | named with catch-all parameter |
| `:name(regexp)` | named with regexp parameter |
| `:name+suffix` | named parameter with suffix matching |
| `:name(regexp)+suffix` | named with regexp parameter and suffix matching |
| `:name*` | named with catch-all parameter |
| `::name` | not named parameter, it is literal `:name` |

Named parameters are dynamic path segments. They match anything until the next '/' or the path end:
Expand All @@ -88,29 +92,47 @@ Defined: `/api/:type/:ID`
/api/user/123/comments no match
```

Named with catch-all parameters match anything until the path end, including the directory index (the '/' before the catch-all). Since they match anything until the end, catch-all parameters must always be the final path element.

Defined: `/files/:filepath*`
```
/files no match
/files/LICENSE matched: filepath="LICENSE"
/files/templates/article.html matched: filepath="templates/article.html"
```

Named with regexp parameters match anything using regexp until the next '/' or the path end:

Defined: `/api/:type/:ID(^\\d+$)`
Defined: `/api/:type/:ID(^\d+$)`
```
/api/user/123 matched: type="user", ID="123"
/api/user no match
/api/user/abc no match
/api/user/123/comments no match
```

The value of parameters is saved on the `matched.params`. Retrieve the value of a parameter by name:
Named parameters with suffix, such as [Google API Design](https://cloud.google.com/apis/design/custom_methods):

Defined: `/api/:resource/:ID+:undelete`
```
/api/file/123 no match
/api/file/123:undelete matched: resource="file", ID="123"
/api/file/123:undelete/comments no match
```

Named with regexp parameters and suffix:

Defined: `/api/:resource/:ID(^\d+$)+:cancel`
```
/api/task/123 no match
/api/task/123:cancel matched: resource="task", ID="123"
/api/task/abc:cancel no match
```

Named with catch-all parameters match anything until the path end, including the directory index (the '/' before the catch-all). Since they match anything until the end, catch-all parameters must always be the final path element.

Defined: `/files/:filepath*`
```
/files no match
/files/LICENSE matched: filepath="LICENSE"
/files/templates/article.html matched: filepath="templates/article.html"
```

The value of parameters is saved on the `Matched.Params`. Retrieve the value of a parameter by name:
```
let type = matched.params.type
let id = matched.Params.ID
type := matched.Params("type")
id := matched.Params("ID")
```

**Notice for regex pattern:**
Expand Down
151 changes: 101 additions & 50 deletions index.js
Expand Up @@ -4,9 +4,9 @@
'use strict'

const wordReg = /^\w+$/
const doubleColonReg = /::\w*$/
const suffixReg = /\+[A-Za-z0-9!$%&'*+,-.:;=@_~]*$/
const doubleColonReg = /::[A-Za-z0-9!$%&'*+,-.:;=@_~]*$/
const trimSlashReg = /^\//
const multiSlashReg = /\/{2,}/
const fixMultiSlashReg = /\/{2,}/g

class Trie {
Expand Down Expand Up @@ -35,7 +35,7 @@ class Trie {

define (pattern) {
if (typeof pattern !== 'string') throw new TypeError('Pattern must be string.')
if (multiSlashReg.test(pattern)) throw new Error('Multi-slash existhis.')
if (pattern.includes('//')) throw new Error('Multi-slash existhis.')
let _pattern = pattern.replace(trimSlashReg, '')
let node = defineNode(this.root, _pattern.split('/'), this.ignoreCase)

Expand All @@ -57,7 +57,7 @@ class Trie {

let start = 1
let end = path.length
let res = new Matched()
let matched = new Matched()
let parent = this.root
for (let i = 1; i <= end; i++) {
if (i < end && path[i] !== '/') continue
Expand All @@ -70,42 +70,45 @@ class Trie {
if (node == null) {
// TrailingSlashRedirect: /acb/efg/ -> /acb/efg
if (this.tsr && frag === '' && i === end && parent.endpoint) {
res.tsr = path.slice(0, end - 1)
matched.tsr = path.slice(0, end - 1)
if (this.fpr && fixedLen > 0) {
res.fpr = res.tsr
res.tsr = ''
matched.fpr = matched.tsr
matched.tsr = ''
}
}
return res
return matched
}

parent = node
if (parent.name) {
if (parent.wildcard) {
res.params[parent.name] = path.slice(start, end)
matched.params[parent.name] = path.slice(start, end)
break
} else {
res.params[parent.name] = frag
if (parent.suffix !== '') {
frag = frag.slice(0, frag.length - parent.suffix.length)
}
matched.params[parent.name] = frag
}
}
start = i + 1
}

if (parent.endpoint) {
res.node = parent
matched.node = parent
if (this.fpr && fixedLen > 0) {
res.fpr = path
res.node = null
matched.fpr = path
matched.node = null
}
} else if (this.tsr && parent.children[''] != null) {
// TrailingSlashRedirect: /acb/efg -> /acb/efg/
res.tsr = path + '/'
matched.tsr = path + '/'
if (this.fpr && fixedLen > 0) {
res.fpr = res.tsr
res.tsr = ''
matched.fpr = matched.tsr
matched.tsr = ''
}
}
return res
return matched
}
}

Expand All @@ -128,10 +131,12 @@ class Node {
this.name = ''
this.allow = ''
this.pattern = ''
this.frag = ''
this.suffix = ''
this.regex = null
this.endpoint = false
this.wildcard = false
this.varyChild = null
this.varyChildren = []
this.parent = parent
this.children = Object.create(null)
this.handlers = Object.create(null)
Expand Down Expand Up @@ -159,6 +164,14 @@ class Node {
getAllow () {
return this.allow
}

getFrags () {
let frags = this.frag
if (this.parent != null) {
frags = this.parent.getFrags() + '/' + frags
}
return frags
}
}

function defineNode (parent, frags, ignoreCase) {
Expand All @@ -176,14 +189,23 @@ function defineNode (parent, frags, ignoreCase) {
}

function matchNode (parent, frag) {
let child = parent.children[frag]
if (child == null) {
child = parent.varyChild
if (child != null && child.regex != null && !child.regex.test(frag)) {
child = null
if (parent.children[frag] != null) {
return parent.children[frag]
}
for (let child of parent.varyChildren) {
let _frag = frag
if (child.suffix !== '') {
if (frag === child.suffix || !frag.endsWith(child.suffix)) {
continue
}
_frag = frag.slice(0, frag.length - child.suffix.length)
}
if (child.regex != null && !child.regex.test(_frag)) {
continue
}
return child
}
return child
return null
}

function parseNode (parent, frag, ignoreCase) {
Expand All @@ -207,51 +229,80 @@ function parseNode (parent, frag, ignoreCase) {
// pattern "/a/::/bc" should match "/a/:/bc"
parent.children[_frag] = node
} else if (frag[0] === ':') {
let regex
let name = frag.slice(1)
let trailing = name[name.length - 1]
if (trailing === ')') {
let index = name.indexOf('(')
if (index > 0) {
regex = name.slice(index + 1, name.length - 1)
if (regex.length > 0) {
name = name.slice(0, index)
node.regex = new RegExp(regex)
} else {
throw new Error(`Invalid pattern: "${frag}"`)

switch (name[name.length - 1]) {
case '*':
name = name.slice(0, name.length - 1)
node.wildcard = true
break
default:
let i = name.search(suffixReg)
if (i >= 0) {
node.suffix = name.slice(i + 1)
name = name.slice(0, i)
if (node.suffix === '') {
throw new Error(`invalid pattern: "${node.getFrags()}"`)
}
}

if (name[name.length - 1] === ')') {
let i = name.indexOf('(')
if (i > 0) {
let regex = name.slice(i + 1, name.length - 1)
if (regex.length > 0) {
name = name.slice(0, i)
node.regex = new RegExp(regex)
} else {
throw new Error(`Invalid pattern: "${node.getFrags()}"`)
}
}
}
}
} else if (trailing === '*') {
name = name.slice(0, name.length - 1)
node.wildcard = true
}

// name must be word characters `[0-9A-Za-z_]`
if (!wordReg.test(name)) {
throw new Error(`Invalid pattern: "${frag}"`)
throw new Error(`Invalid pattern: "${node.getFrags()}"`)
}
node.name = name
let child = parent.varyChild
if (child != null) {
if (child.name !== name || child.wildcard !== node.wildcard) {
throw new Error(`Invalid pattern: "${frag}"`)

for (let child of parent.varyChildren) {
if (child.name !== node.name) {
throw new Error(`invalid pattern: "${node.getFrags()}"`)
}
if (child.regex != null && child.regex.toString() !== node.regex.toString()) {
throw new Error(`Invalid pattern: "${frag}"`)
if (child.wildcard) {
if (!node.wildcard) {
throw new Error(`can't define "${node.getFrags()}" after "${child.getFrags()}"`)
}
return child
}
if (child.suffix === '' && child.regex == null && (node.suffix !== '' || node.regex != null)) {
throw new Error(`can't define "${node.getFrags()}" after "${child.getFrags()}"`)
}
if (child.suffix === node.suffix) {
if (child.regex == null && node.regex == null) {
return child
}
if (child.regex != null && node.regex != null && child.regex.toString() === node.regex.toString()) {
return child
}
if (child.regex == null && node.regex != null) {
throw new Error(`invalid pattern: "${node.getFrags()}"`)
}
}
return child
}

parent.varyChild = node
parent.varyChildren.push(node)
} else if (frag[0] === '*' || frag[0] === '(' || frag[0] === ')') {
throw new Error(`Invalid pattern: "${frag}"`)
throw new Error(`Invalid pattern: "${node.getFrags()}"`)
} else {
parent.children[_frag] = node
}
return node
}

Trie.NAME = 'Trie'
Trie.VERSION = 'v2.0.2'
Trie.VERSION = 'v2.1.0'
Trie.Node = Node
Trie.Matched = Matched
module.exports = Trie.Trie = Trie

0 comments on commit b1ab162

Please sign in to comment.