Skip to content

Commit 501e909

Browse files
feat: add multiple parametric nodes support (#320)
* feat: add multiple parametric nodes support * docs: update nodes maching order documentation
1 parent 6f059d1 commit 501e909

File tree

5 files changed

+202
-75
lines changed

5 files changed

+202
-75
lines changed

README.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -283,25 +283,34 @@ Having a route with multiple parameters may affect negatively the performance, s
283283
<a name="match-order"></a>
284284
##### Match order
285285

286-
The routing algorithm matches one chunk at a time (where the chunk is a string between two slashes),
286+
The routing algorithm matches one node at a time (where the node is a string between two slashes),
287287
this means that it cannot know if a route is static or dynamic until it finishes to match the URL.
288288

289-
The chunks are matched in the following order:
289+
The nodes are matched in the following order:
290290

291291
1. static
292-
1. parametric
293-
1. wildcards
294-
1. parametric(regex)
295-
1. multi parametric(regex)
292+
2. parametric node with static ending
293+
3. parametric(regex)/multi-parametric
294+
4. parametric
295+
5. wildcard
296296

297297
So if you declare the following routes
298298

299-
- `/:userId/foo/bar`
300-
- `/33/:a(^.*$)/:b`
299+
- `/foo/filename.png` - static route
300+
- `/foo/:filename.png` - route with param `filename` and static ending `.png`
301+
- `/foo/:filename.:ext` - route with two params `filename` and `ext`
302+
- `/foo/:filename` - route with one param `filename`
303+
- `/*` - wildcard route
304+
305+
You will have next matching rules:
306+
- the static node would have the highest priority. It will be matched only if incoming URL equals `/foo/filename.png`
307+
- the parametric node with a static ending would have the higher priority than other parametric nodes without it. This node would match any filenames with `.png` extension. If one node static ending ends with another node static ending, the node with a longer static ending would have higher priority.
308+
- `/foo/:filename.png.png` - higher priority, more specific route
309+
- `/foo/:filename.png` - lower priority
310+
- the multi-parametric node (or any regexp node) without static ending would have lower priority than parametric node with static ending and higher priority than generic parametric node. You can declare only one node like that for the same route (see [caveats](#caveats)). It would match any filenames with any extensions.
311+
- the parametric node has lower priority than any other parametric node. It would match any filenames, even if they don't have an extension.
312+
- the wildcard node has the lowest priority of all nodes.
301313

302-
and the URL of the incoming request is /33/foo/bar,
303-
the second route will be matched because the first chunk (33) matches the static chunk.
304-
If the URL would have been /32/foo/bar, the first route would have been matched.
305314
Once a url has been matched, `find-my-way` will figure out which handler registered for that path matches the request if there are any constraints.
306315
`find-my-way` will check the most constrained handlers first, which means the handlers with the most keys in the `constraints` object.
307316

custom_node.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class StaticNode extends ParentNode {
6161
this._compilePrefixMatch()
6262
}
6363

64-
createParametricChild (regex) {
64+
createParametricChild (regex, staticSuffix) {
6565
const regexpSource = regex && regex.source
6666

6767
let parametricChild = this.parametricChildren.find(child => {
@@ -73,12 +73,21 @@ class StaticNode extends ParentNode {
7373
return parametricChild
7474
}
7575

76-
parametricChild = new ParametricNode(regex)
77-
if (regex) {
78-
this.parametricChildren.unshift(parametricChild)
79-
} else {
80-
this.parametricChildren.push(parametricChild)
81-
}
76+
parametricChild = new ParametricNode(regex, staticSuffix)
77+
this.parametricChildren.push(parametricChild)
78+
this.parametricChildren.sort((child1, child2) => {
79+
if (!child1.isRegex) return 1
80+
if (!child2.isRegex) return -1
81+
82+
if (child1.staticSuffix === null) return 1
83+
if (child2.staticSuffix === null) return -1
84+
85+
if (child2.staticSuffix.endsWith(child1.staticSuffix)) return 1
86+
if (child1.staticSuffix.endsWith(child2.staticSuffix)) return -1
87+
88+
return 0
89+
})
90+
8291
return parametricChild
8392
}
8493

@@ -153,10 +162,11 @@ class StaticNode extends ParentNode {
153162
}
154163

155164
class ParametricNode extends ParentNode {
156-
constructor (regex) {
165+
constructor (regex, staticSuffix) {
157166
super()
158-
this.regex = regex || null
159167
this.isRegex = !!regex
168+
this.regex = regex || null
169+
this.staticSuffix = staticSuffix || null
160170
this.kind = NODE_TYPES.PARAMETRIC
161171
}
162172

index.js

Lines changed: 35 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ function Router (opts) {
8989
this.trees = {}
9090
this.constrainer = new Constrainer(opts.constraints)
9191

92-
this._routesPatterns = []
92+
this._routesPatterns = {}
9393
}
9494

9595
Router.prototype.on = function on (method, path, opts, handler, store) {
@@ -156,6 +156,7 @@ Router.prototype._on = function _on (method, path, opts, handler, store) {
156156
// Boot the tree for this method if it doesn't exist yet
157157
if (this.trees[method] === undefined) {
158158
this.trees[method] = new StaticNode('/')
159+
this._routesPatterns[method] = []
159160
}
160161

161162
if (path === '*' && this.trees[method].prefix.length !== 0) {
@@ -193,18 +194,21 @@ Router.prototype._on = function _on (method, path, opts, handler, store) {
193194
let isRegexNode = false
194195
const regexps = []
195196

196-
let staticEndingLength = 0
197197
let lastParamStartIndex = i + 1
198198
for (let j = lastParamStartIndex; ; j++) {
199199
const charCode = path.charCodeAt(j)
200200

201-
if (charCode === 40 || charCode === 45 || charCode === 46) {
202-
isRegexNode = true
201+
const isRegexParam = charCode === 40
202+
const isStaticPart = charCode === 45 || charCode === 46
203+
const isEndOfNode = charCode === 47 || j === path.length
203204

205+
if (isRegexParam || isStaticPart || isEndOfNode) {
204206
const paramName = path.slice(lastParamStartIndex, j)
205207
params.push(paramName)
206208

207-
if (charCode === 40) {
209+
isRegexNode = isRegexNode || isRegexParam || isStaticPart
210+
211+
if (isRegexParam) {
208212
const endOfRegexIndex = getClosingParenthensePosition(path, j)
209213
const regexString = path.slice(j, endOfRegexIndex + 1)
210214

@@ -219,53 +223,39 @@ Router.prototype._on = function _on (method, path, opts, handler, store) {
219223
regexps.push('(.*?)')
220224
}
221225

222-
let lastParamEndIndex = j
223-
for (; lastParamEndIndex < path.length; lastParamEndIndex++) {
224-
const charCode = path.charCodeAt(lastParamEndIndex)
225-
const nextCharCode = path.charCodeAt(lastParamEndIndex + 1)
226-
if (charCode === 58 && nextCharCode === 58) {
227-
lastParamEndIndex++
228-
continue
226+
const staticPartStartIndex = j
227+
for (; j < path.length; j++) {
228+
const charCode = path.charCodeAt(j)
229+
if (charCode === 47) break
230+
if (charCode === 58) {
231+
const nextCharCode = path.charCodeAt(j + 1)
232+
if (nextCharCode === 58) j++
233+
else break
229234
}
230-
if (charCode === 58 || charCode === 47) break
231235
}
232236

233-
let staticPart = path.slice(j, lastParamEndIndex)
237+
let staticPart = path.slice(staticPartStartIndex, j)
234238
if (staticPart) {
235239
staticPart = staticPart.split('::').join(':')
236240
staticPart = staticPart.split('%').join('%25')
237241
regexps.push(escapeRegExp(staticPart))
238242
}
239243

240-
lastParamStartIndex = lastParamEndIndex + 1
241-
j = lastParamEndIndex
244+
lastParamStartIndex = j + 1
242245

243-
if (path.charCodeAt(j) === 47 || j === path.length) {
244-
staticEndingLength = staticPart.length
245-
}
246-
} else if (charCode === 47 || j === path.length) {
247-
const paramName = path.slice(lastParamStartIndex, j)
248-
params.push(paramName)
246+
if (isEndOfNode || path.charCodeAt(j) === 47 || j === path.length) {
247+
const nodePattern = isRegexNode ? '()' + staticPart : staticPart
249248

250-
if (regexps.length !== 0) {
251-
regexps.push('(.*?)')
252-
}
253-
}
249+
path = path.slice(0, i + 1) + nodePattern + path.slice(j)
250+
i += nodePattern.length
254251

255-
if (path.charCodeAt(j) === 47 || j === path.length) {
256-
path = path.slice(0, i + 1) + path.slice(j - staticEndingLength)
257-
i += staticEndingLength
258-
break
252+
const regex = isRegexNode ? new RegExp('^' + regexps.join('') + '$') : null
253+
currentNode = currentNode.createParametricChild(regex, staticPart || null)
254+
parentNodePathIndex = i + 1
255+
break
256+
}
259257
}
260258
}
261-
262-
let regex = null
263-
if (isRegexNode) {
264-
regex = new RegExp('^' + regexps.join('') + '$')
265-
}
266-
267-
currentNode = currentNode.createParametricChild(regex)
268-
parentNodePathIndex = i + 1
269259
} else if (isWildcardNode) {
270260
// add the wildcard parameter
271261
params.push('*')
@@ -282,25 +272,16 @@ Router.prototype._on = function _on (method, path, opts, handler, store) {
282272
path = path.toLowerCase()
283273
}
284274

285-
const isRootWildcard = path === '*' || path === '/*'
286-
for (const existRoute of this._routesPatterns) {
287-
let samePath = false
288-
289-
if (existRoute.path === path) {
290-
samePath = true
291-
} else if (isRootWildcard && (existRoute.path === '/*' || existRoute.path === '*')) {
292-
samePath = true
293-
}
275+
if (path === '*') {
276+
path = '/*'
277+
}
294278

295-
if (
296-
samePath &&
297-
existRoute.method === method &&
298-
deepEqual(existRoute.constraints, constraints)
299-
) {
279+
for (const existRoute of this._routesPatterns[method]) {
280+
if (existRoute.path === path && deepEqual(existRoute.constraints, constraints)) {
300281
throw new Error(`Method '${method}' already declared for route '${path}' with constraints '${JSON.stringify(constraints)}'`)
301282
}
302283
}
303-
this._routesPatterns.push({ method, path, constraints })
284+
this._routesPatterns[method].push({ path, params, constraints })
304285

305286
currentNode.handlerStorage.addHandler(handler, params, store, this.constrainer, constraints)
306287
}
@@ -317,7 +298,7 @@ Router.prototype.addConstraintStrategy = function (constraints) {
317298
Router.prototype.reset = function reset () {
318299
this.trees = {}
319300
this.routes = []
320-
this._routesPatterns = []
301+
this._routesPatterns = {}
321302
}
322303

323304
Router.prototype.off = function off (method, path, constraints) {

test/errors.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ test('Method already declared if * is used', t => {
254254
findMyWay.on('GET', '*', () => {})
255255
t.fail('should throw error')
256256
} catch (e) {
257-
t.equal(e.message, 'Method \'GET\' already declared for route \'*\' with constraints \'{}\'')
257+
t.equal(e.message, 'Method \'GET\' already declared for route \'/*\' with constraints \'{}\'')
258258
}
259259
})
260260

test/params-collisions.test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
'use strict'
2+
3+
const t = require('tap')
4+
const test = t.test
5+
const FindMyWay = require('..')
6+
7+
test('should setup parametric and regexp node', t => {
8+
t.plan(2)
9+
10+
const findMyWay = FindMyWay()
11+
12+
const paramHandler = () => {}
13+
const regexpHandler = () => {}
14+
15+
findMyWay.on('GET', '/foo/:bar', paramHandler)
16+
findMyWay.on('GET', '/foo/:bar(123)', regexpHandler)
17+
18+
t.equal(findMyWay.find('GET', '/foo/value').handler, paramHandler)
19+
t.equal(findMyWay.find('GET', '/foo/123').handler, regexpHandler)
20+
})
21+
22+
test('should setup parametric and multi-parametric node', t => {
23+
t.plan(2)
24+
25+
const findMyWay = FindMyWay()
26+
27+
const paramHandler = () => {}
28+
const regexpHandler = () => {}
29+
30+
findMyWay.on('GET', '/foo/:bar', paramHandler)
31+
findMyWay.on('GET', '/foo/:bar.png', regexpHandler)
32+
33+
t.equal(findMyWay.find('GET', '/foo/value').handler, paramHandler)
34+
t.equal(findMyWay.find('GET', '/foo/value.png').handler, regexpHandler)
35+
})
36+
37+
test('should throw when set upping two parametric nodes', t => {
38+
t.plan(1)
39+
40+
const findMyWay = FindMyWay()
41+
findMyWay.on('GET', '/foo/:bar', () => {})
42+
43+
t.throws(() => findMyWay.on('GET', '/foo/:baz', () => {}))
44+
})
45+
46+
test('should throw when set upping two regexp nodes', t => {
47+
t.plan(1)
48+
49+
const findMyWay = FindMyWay()
50+
findMyWay.on('GET', '/foo/:bar(123)', () => {})
51+
52+
t.throws(() => findMyWay.on('GET', '/foo/:bar(456)', () => {}))
53+
})
54+
55+
test('should set up two parametric nodes with static ending', t => {
56+
t.plan(2)
57+
58+
const findMyWay = FindMyWay()
59+
60+
const paramHandler1 = () => {}
61+
const paramHandler2 = () => {}
62+
63+
findMyWay.on('GET', '/foo/:bar.png', paramHandler1)
64+
findMyWay.on('GET', '/foo/:bar.jpeg', paramHandler2)
65+
66+
t.equal(findMyWay.find('GET', '/foo/value.png').handler, paramHandler1)
67+
t.equal(findMyWay.find('GET', '/foo/value.jpeg').handler, paramHandler2)
68+
})
69+
70+
test('should set up two regexp nodes with static ending', t => {
71+
t.plan(2)
72+
73+
const findMyWay = FindMyWay()
74+
75+
const paramHandler1 = () => {}
76+
const paramHandler2 = () => {}
77+
78+
findMyWay.on('GET', '/foo/:bar(123).png', paramHandler1)
79+
findMyWay.on('GET', '/foo/:bar(456).jpeg', paramHandler2)
80+
81+
t.equal(findMyWay.find('GET', '/foo/123.png').handler, paramHandler1)
82+
t.equal(findMyWay.find('GET', '/foo/456.jpeg').handler, paramHandler2)
83+
})
84+
85+
test('node with longer static suffix should have higher priority', t => {
86+
t.plan(2)
87+
88+
const findMyWay = FindMyWay()
89+
90+
const paramHandler1 = () => {}
91+
const paramHandler2 = () => {}
92+
93+
findMyWay.on('GET', '/foo/:bar.png', paramHandler1)
94+
findMyWay.on('GET', '/foo/:bar.png.png', paramHandler2)
95+
96+
t.equal(findMyWay.find('GET', '/foo/value.png').handler, paramHandler1)
97+
t.equal(findMyWay.find('GET', '/foo/value.png.png').handler, paramHandler2)
98+
})
99+
100+
test('node with longer static suffix should have higher priority', t => {
101+
t.plan(2)
102+
103+
const findMyWay = FindMyWay()
104+
105+
const paramHandler1 = () => {}
106+
const paramHandler2 = () => {}
107+
108+
findMyWay.on('GET', '/foo/:bar.png.png', paramHandler2)
109+
findMyWay.on('GET', '/foo/:bar.png', paramHandler1)
110+
111+
t.equal(findMyWay.find('GET', '/foo/value.png').handler, paramHandler1)
112+
t.equal(findMyWay.find('GET', '/foo/value.png.png').handler, paramHandler2)
113+
})
114+
115+
test('should set up regexp node and node with static ending', t => {
116+
t.plan(2)
117+
118+
const regexHandler = () => {}
119+
const multiParamHandler = () => {}
120+
121+
const findMyWay = FindMyWay()
122+
findMyWay.on('GET', '/foo/:bar(123)', regexHandler)
123+
findMyWay.on('GET', '/foo/:bar(123).jpeg', multiParamHandler)
124+
125+
t.equal(findMyWay.find('GET', '/foo/123.jpeg').handler, multiParamHandler)
126+
t.equal(findMyWay.find('GET', '/foo/123').handler, regexHandler)
127+
})

0 commit comments

Comments
 (0)