Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Constrained routes take 2 #170

Merged
merged 74 commits into from
Feb 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
f19a0db
move accept version to strategies folder and rename deriveVersion to …
AyoubElk Oct 3, 2020
1220b07
add accept-host strategy with exact match support
AyoubElk Oct 3, 2020
5544329
add constraints store
AyoubElk Oct 3, 2020
ab39b39
add accept-constraints
AyoubElk Oct 3, 2020
b311ec0
use constraintsStorage instead of version storage
AyoubElk Oct 3, 2020
141f42f
add kConstraints property and update reset()
AyoubElk Oct 3, 2020
25f7b04
update getVersionHandler() to support constraints
AyoubElk Oct 3, 2020
c6b11d7
add getMatchingHandler() to support both constrained and non-constrai…
AyoubElk Oct 3, 2020
eede77e
fix typo
AyoubElk Oct 3, 2020
9db137b
replace findChild() and findVersionChild() with generic findMatchingC…
AyoubElk Oct 3, 2020
ac61bde
update setVersionHandler() to support constraints
AyoubElk Oct 3, 2020
9c3d4d8
use acceptConstraints instead of acceptVersionStrategy
AyoubElk Oct 3, 2020
fe27b59
replace version usage with constraints
AyoubElk Oct 3, 2020
212c3ae
use constraintsExtractor
AyoubElk Oct 3, 2020
8650a7c
use getMatchingHandler() and findMatchingChild()
AyoubElk Oct 3, 2020
edd00c9
use getConstraintsHandler() and setConstraintsHandler()
AyoubElk Oct 3, 2020
edde841
refactor version & host strategies to use prototype for performance i…
AyoubElk Oct 12, 2020
537b684
replace getConstraintsExtractor with deriveConstraints
AyoubElk Oct 12, 2020
c05c763
fix assert import
AyoubElk Oct 12, 2020
b9954dc
update constraintsStore to centralize store storage in a shared map
AyoubElk Oct 12, 2020
303f2f3
update bench file to support new constraints format
AyoubElk Oct 12, 2020
4aa5990
match first handler that passes constraints check
AyoubElk Oct 12, 2020
c5ea6e2
replace let with var
AyoubElk Oct 14, 2020
2ac7cba
move variable declaration outside loop
AyoubElk Oct 14, 2020
40a3c92
add constraints existance check in getMatchingHandler
AyoubElk Oct 15, 2020
a1bf496
remove comment
AyoubElk Oct 15, 2020
1c49cac
revert to previous strategy format & remove deriveConstraint functions
AyoubElk Oct 15, 2020
6abe279
add regex matching for host store
AyoubElk Oct 15, 2020
35e9da8
add strategyObjectToPrototype function
AyoubElk Oct 15, 2020
65bfdcf
convert stratgies to prototype format
AyoubElk Oct 15, 2020
ce44dcb
inline constraint derivation for default strategies inside deriveCons…
AyoubElk Oct 15, 2020
0e04077
support conditional constraints derivation for custom strategies only…
AyoubElk Oct 15, 2020
8c66309
remove unused array
AyoubElk Oct 15, 2020
ce0260d
replace this.getHandler call for faster processing
AyoubElk Oct 15, 2020
7d7e0f2
replace let with var
AyoubElk Oct 15, 2020
52a3479
update bench.js file to use null instead of undefined
AyoubElk Oct 18, 2020
df674d0
update Node.getMatchingHandler to add hasConstraint boolean
AyoubElk Oct 18, 2020
f809308
update Node constructor options to support kConstraints
AyoubElk Oct 18, 2020
159d941
validate custom strategies format
AyoubElk Oct 18, 2020
6f83041
optimize performance by inlining default constraints derivation and d…
AyoubElk Oct 18, 2020
bf4a5e1
lint code and update tests
AyoubElk Oct 25, 2020
899db90
Always pass derived constraints to .find
airhorns Oct 25, 2020
e5b3446
Make node splitting Node's responsibility
airhorns Oct 25, 2020
c805f1e
Switch to one tree per method and optimize constraint support
airhorns Oct 26, 2020
57e7a6d
Add some couple realistic routes and benchmarks for a REST API
airhorns Oct 27, 2020
39b0759
Switch to using one tree per method instead of a map of per-method ha…
airhorns Oct 27, 2020
a0232ed
Merge branch 'one-tree-per-method' into constraints
airhorns Oct 27, 2020
6170204
Add constrained route pretty printing
airhorns Oct 27, 2020
acf1283
Switch to using one tree per method instead of a map of per-method ha…
airhorns Oct 27, 2020
a1934b3
Merge branch 'one-tree-per-method' into constraints
airhorns Oct 28, 2020
55f37a2
Rip out unproven performance optimizations
airhorns Oct 28, 2020
ef1e8ca
Add a test and fix host regex constraining
airhorns Oct 28, 2020
a27b977
Slightly simpler constraint deriver function body
airhorns Oct 30, 2020
8855e0b
Monomorphize the getMatchingHandler callsite
airhorns Oct 30, 2020
997a6df
Remove the mustMatchHandler complicatedness in favour of a hardcoded …
airhorns Oct 31, 2020
4465f90
Merge branch 'master' into constraints
airhorns Nov 2, 2020
0b441cf
Restore node 10 support by not using Array.flat
airhorns Nov 2, 2020
8a717c1
Merge branch 'master' into constraints
airhorns Nov 7, 2020
67389f7
Remove accidental merge conflict double addition and restore node 10 …
airhorns Nov 7, 2020
8a84f2e
Only derive constraints when doing so is necessary to support the def…
airhorns Nov 7, 2020
2434470
Implement support for the old style of adding a custom versioning str…
airhorns Nov 9, 2020
a8a2806
Document constraints and constraint strategies
airhorns Nov 11, 2020
5344dd5
Ensure the regex matching hosts for the accept host store are cleared…
airhorns Dec 14, 2020
2405ba8
Remove backwards compatibility supports from find-my-way in favour of…
airhorns Jan 5, 2021
8f56de3
Correct some small style issues and remove an accidentally left aroun…
airhorns Jan 5, 2021
b460130
Add some tests for the host constraint store
airhorns Jan 5, 2021
2225a71
Clarify comments on the constraint matching function compiler
airhorns Jan 7, 2021
bbd6493
Add a couple microoptimizations to the find function
airhorns Jan 7, 2021
ccc30b1
Lazy-allocate the params storage array to avoid another stack allocat…
airhorns Jan 7, 2021
2129ddd
Update documentation for .find
airhorns Jan 12, 2021
39fb2a5
Add a test case for passing undefined to find and document passing un…
airhorns Jan 12, 2021
d413ebc
Remove extra parameter to find calls in tests
airhorns Jan 26, 2021
f993daa
Ensure constrained routes are matched before unconstrained routes reg…
airhorns Feb 1, 2021
6a651be
Update README.md
airhorns Feb 2, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 93 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,80 @@ const router = require('find-my-way')({
})
```

## Constraints

`find-my-way` supports restricting handlers to only match certain requests for the same path. This can be used to support different versions of the same route that conform to a [semver](#semver) based versioning strategy, or restricting some routes to only be available on hosts. `find-my-way` has the semver based versioning strategy and a regex based hostname constraint strategy built in.

To constrain a route to only match sometimes, pass `constraints` to the route options when registering the route:

```js
findMyWay.on('GET', '/', { constraints: { version: '1.0.2' } }, (req, res) => {
// will only run when the request's Accept-Version header asks for a version semver compatible with 1.0.2, like 1.x, or 1.0.x.
})

findMyWay.on('GET', '/', { constraints: { host: 'example.com' } }, (req, res) => {
// will only run when the request's Host header is `example.com`
})
```

Constraints can be combined, and route handlers will only match if __all__ of the constraints for the handler match the request. `find-my-way` does a boolean AND with each route constraint, not an OR.

`find-my-way` will try to match the most constrained handlers first before handler with fewer or no constraints.

<a name="custom-constraint-strategies"></a>
### Custom Constraint Strategies

Custom constraining strategies can be added and are matched against incoming requests while trying to maintain `find-my-way`'s high performance. To register a new type of constraint, you must add a new constraint strategy that knows how to match values to handlers, and that knows how to get the constraint value from a request. Register strategies when constructing a router:

```js
const customResponseTypeStrategy = {
// strategy name for referencing in the route handler `constraints` options
name: 'accept',
// storage factory for storing routes in the find-my-way route tree
storage: function () {
let handlers = {}
return {
get: (type) => { return handlers[type] || null },
set: (type, store) => { handlers[type] = store },
del: (type) => { delete handlers[type] },
empty: () => { handlers = {} }
}
},
// function to get the value of the constraint from each incoming request
deriveConstraint: (req, ctx) => {
return req.headers['accept']
},
// optional flag marking if handlers without constraints can match requests that have a value for this constraint
mustMatchWhenDerived: true
}

const router = FindMyWay({ constraints: { accept: customResponseTypeStrategy } });
```

Once a custom constraint strategy is registered, routes can be added that are constrained using it:


```js
findMyWay.on('GET', '/', { constraints: { accept: 'application/fancy+json' } }, (req, res) => {
// will only run when the request's Accept header asks for 'application/fancy+json'
})

findMyWay.on('GET', '/', { constraints: { accept: 'application/fancy+xml' } }, (req, res) => {
// will only run when the request's Accept header asks for 'application/fancy+xml'
})
```

Constraint strategies should be careful to make the `deriveConstraint` function performant as it is run for every request matched by the router. See the `lib/strategies` directory for examples of the built in constraint strategies.


<a name="custom-versioning"></a>
By default `find-my-way` uses [accept-version](./lib/accept-version.js) strategy to match requests with different versions of the handlers. The matching logic of that strategy is explained [below](#semver). It is possible to define the alternative strategy:
By default, `find-my-way` uses a built in strategies for the version constraint that uses semantic version based matching logic, which is detailed [below](#semver). It is possible to define an alternative strategy:

```js
const customVersioning = {
// storage factory
// replace the built in version strategy
name: 'version',
// provide a storage factory to store handlers in a simple way
storage: function () {
let versions = {}
return {
Expand All @@ -110,22 +179,22 @@ const customVersioning = {
empty: () => { versions = {} }
}
},
deriveVersion: (req, ctx) => {
deriveConstraint: (req, ctx) => {
return req.headers['accept']
}
},
mustMatchWhenDerived: true // if the request is asking for a version, don't match un-version-constrained handlers
}

const router = FindMyWay({ versioning: customVersioning });
const router = FindMyWay({ constraints: { version: customVersioning } });
```

The custom strategy object should contain next properties:
* `storage` - the factory function for the Storage of the handlers based on their version.
* `deriveVersion` - the function to determine the version based on the request
* `storage` - a factory function to store lists of handlers for each possible constraint value. The storage object can use domain-specific storage mechanisms to store handlers in a way that makes sense for the constraint at hand. See `lib/strategies` for examples, like the `version` constraint strategy that matches using semantic versions, or the `host` strategy that allows both exact and regex host constraints.
* `deriveConstraint` - the function to determine the value of this constraint given a request

The signature of the functions and objects must match the one from the example above.


*Please, be aware, if you use custom versioning strategy - you use it on your own risk. This can lead both to the performance degradation and bugs which are not related to `find-my-way` itself*
*Please, be aware, if you use your own constraining strategy - you use it on your own risk. This can lead both to the performance degradation and bugs which are not related to `find-my-way` itself!*

<a name="on"></a>
#### on(method, path, [opts], handler, [store])
Expand All @@ -144,27 +213,28 @@ router.on('GET', '/example', (req, res, params, store) => {

##### Versioned routes

If needed you can provide a `version` option, which will allow you to declare multiple versions of the same route. If you never configure a versioned route, the `'Accept-Version'` header will be ignored.
If needed, you can provide a `version` route constraint, which will allow you to declare multiple versions of the same route that are used selectively when requests ask for different version using the `Accept-Version` header. This is useful if you want to support several different behaviours for a given route and different clients select among them.

Remember to set a [Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) header in your responses with the value you are using for deifning the versioning (e.g.: 'Accept-Version'), to prevent cache poisoning attacks. You can also configure this as part your Proxy/CDN.
If you never configure a versioned route, the `'Accept-Version'` header will be ignored. Remember to set a [Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) header in your responses with the value you are using for deifning the versioning (e.g.: 'Accept-Version'), to prevent cache poisoning attacks. You can also configure this as part your Proxy/CDN.

###### default
<a name="semver"></a>
Default versioning strategy is called `accept-version` and it follows the [semver](https://semver.org/) specification.<br/>
When using `lookup`, `find-my-way` will automatically detect the `Accept-Version` header and route the request accordingly.<br/>
Internally `find-my-way` uses the [`semver-store`](https://github.com/delvedor/semver-store) to get the correct version of the route; *advanced ranges* and *pre-releases* currently are not supported.<br/>
The default versioning strategy follows the [semver](https://semver.org/) specification. When using `lookup`, `find-my-way` will automatically detect the `Accept-Version` header and route the request accordingly. Internally `find-my-way` uses the [`semver-store`](https://github.com/delvedor/semver-store) to get the correct version of the route; *advanced ranges* and *pre-releases* currently are not supported.

*Be aware that using this feature will cause a degradation of the overall performances of the router.*

```js
router.on('GET', '/example', { version: '1.2.0' }, (req, res, params) => {
router.on('GET', '/example', { constraints: { version: '1.2.0' }}, (req, res, params) => {
res.end('Hello from 1.2.0!')
})

router.on('GET', '/example', { version: '2.4.0' }, (req, res, params) => {
router.on('GET', '/example', { constraints: { version: '2.4.0' }}, (req, res, params) => {
res.end('Hello from 2.4.0!')
})

// The 'Accept-Version' header could be '1.2.0' as well as '*', '2.x' or '2.4.x'
```

If you declare multiple versions with the same *major* or *minor* `find-my-way` will always choose the highest compatible with the `Accept-Version` header value.

###### custom
Expand Down Expand Up @@ -237,6 +307,8 @@ and the URL of the incoming request is /33/foo/bar,
the second route will be matched because the first chunk (33) matches the static chunk.
If the URL would have been /32/foo/bar, the first route would have been matched.

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. `find-my-way` will check the most constrained handlers first, which means the handlers with the most keys in the `constraints` object.

<a name="supported-methods"></a>
##### Supported methods
The router is able to route all HTTP methods defined by [`http` core module](https://nodejs.org/api/http.html#http_http_methods).
Expand Down Expand Up @@ -318,16 +390,17 @@ router.lookup(req, res, { greeting: 'Hello, World!' })
```

<a name="find"></a>
#### find(method, path [, version])
#### find(method, path, [constraints])
Return (if present) the route registered in *method:path*.<br>
The path must be sanitized, all the parameters and wildcards are decoded automatically.<br/>
You can also pass an optional version string. In case of the default versioning strategy it should be conform to the [semver](https://semver.org/) specification.
An object with routing constraints should usually be passed as `constraints`, containing keys like the `host` for the request, the `version` for the route to be matched, or other custom constraint values. If the router is using the default versioning strategy, the version value should be conform to the [semver](https://semver.org/) specification. If you want to use the existing constraint strategies to derive the constraint values from an incoming request, use `lookup` instead of `find`. If no value is passed for `constraints`, the router won't match any constrained routes. If using constrained routes, passing `undefined` for the constraints leads to undefined behavior and should be avoided.

```js
router.find('GET', '/example')
router.find('GET', '/example', { host: 'fastify.io' })
// => { handler: Function, params: Object, store: Object}
// => null

router.find('GET', '/example', '1.x')
router.find('GET', '/example', { host: 'fastify.io', version: '1.x' })
// => { handler: Function, params: Object, store: Object}
// => null
```
Expand Down
37 changes: 24 additions & 13 deletions bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ findMyWay.on('GET', '/customer/:name-:surname', () => true)
findMyWay.on('POST', '/customer', () => true)
findMyWay.on('GET', '/at/:hour(^\\d+)h:minute(^\\d+)m', () => true)
findMyWay.on('GET', '/abc/def/ghi/lmn/opq/rst/uvz', () => true)
findMyWay.on('GET', '/', { version: '1.2.0' }, () => true)

findMyWay.on('GET', '/products', () => true)
findMyWay.on('GET', '/products/:id', () => true)
Expand All @@ -39,31 +38,40 @@ findMyWay.on('GET', '/posts/:id/comments/:id', () => true)
findMyWay.on('GET', '/posts/:id/comments/:id/author', () => true)
findMyWay.on('GET', '/posts/:id/counter', () => true)

findMyWay.on('GET', '/pages', () => true)
findMyWay.on('POST', '/pages', () => true)
findMyWay.on('GET', '/pages/:id', () => true)
const constrained = new FindMyWay()
constrained.on('GET', '/', () => true)
constrained.on('GET', '/versioned', () => true)
constrained.on('GET', '/versioned', { constraints: { version: '1.2.0' } }, () => true)
constrained.on('GET', '/versioned', { constraints: { version: '2.0.0', host: 'example.com' } }, () => true)
constrained.on('GET', '/versioned', { constraints: { version: '2.0.0', host: 'fastify.io' } }, () => true)

suite
.add('lookup static route', function () {
findMyWay.lookup({ method: 'GET', url: '/', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/', headers: { host: 'fastify.io' } }, null)
})
.add('lookup dynamic route', function () {
findMyWay.lookup({ method: 'GET', url: '/user/tomas', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/user/tomas', headers: { host: 'fastify.io' } }, null)
})
.add('lookup dynamic multi-parametric route', function () {
findMyWay.lookup({ method: 'GET', url: '/customer/john-doe', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/customer/john-doe', headers: { host: 'fastify.io' } }, null)
})
.add('lookup dynamic multi-parametric route with regex', function () {
findMyWay.lookup({ method: 'GET', url: '/at/12h00m', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/at/12h00m', headers: { host: 'fastify.io' } }, null)
})
.add('lookup long static route', function () {
findMyWay.lookup({ method: 'GET', url: '/abc/def/ghi/lmn/opq/rst/uvz', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/abc/def/ghi/lmn/opq/rst/uvz', headers: { host: 'fastify.io' } }, null)
})
.add('lookup long dynamic route', function () {
findMyWay.lookup({ method: 'GET', url: '/user/qwertyuiopasdfghjklzxcvbnm/static', headers: {} }, null)
findMyWay.lookup({ method: 'GET', url: '/user/qwertyuiopasdfghjklzxcvbnm/static', headers: { host: 'fastify.io' } }, null)
})
.add('lookup static route on constrained router', function () {
constrained.lookup({ method: 'GET', url: '/', headers: { host: 'fastify.io' } }, null)
})
.add('lookup static versioned route', function () {
findMyWay.lookup({ method: 'GET', url: '/', headers: { 'accept-version': '1.x' } }, null)
constrained.lookup({ method: 'GET', url: '/versioned', headers: { 'accept-version': '1.x', host: 'fastify.io' } }, null)
})
.add('lookup static constrained (version & host) route', function () {
constrained.lookup({ method: 'GET', url: '/versioned', headers: { 'accept-version': '2.x', host: 'fastify.io' } }, null)
})
.add('find static route', function () {
findMyWay.find('GET', '/', undefined)
Expand All @@ -83,8 +91,11 @@ suite
.add('find long dynamic route', function () {
findMyWay.find('GET', '/user/qwertyuiopasdfghjklzxcvbnm/static', undefined)
})
.add('find static versioned route', function () {
findMyWay.find('GET', '/', '1.x')
.add('find long nested dynamic route', function () {
findMyWay.find('GET', '/posts/10/comments/42/author', undefined)
})
.add('find long nested dynamic route with other method', function () {
findMyWay.find('POST', '/posts/10/comments', undefined)
})
.add('find long nested dynamic route', function () {
findMyWay.find('GET', '/posts/10/comments/42/author', undefined)
Expand Down
27 changes: 17 additions & 10 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ declare namespace Router {
store: any
) => void;

interface ConstraintStrategy<V extends HTTPVersion> {
name: string,
mustMatchWhenDerived?: boolean,
storage() : {
get(version: String) : Handler<V> | null,
set(version: String, store: Handler<V>) : void,
del(version: String) : void,
empty() : void
},
validate(value: unknown): void,
deriveConstraint<Context>(req: Req<V>, ctx?: Context) : String,
}

interface Config<V extends HTTPVersion> {
ignoreTrailingSlash?: boolean;

Expand All @@ -77,19 +90,13 @@ declare namespace Router {
res: Res<V>
): void;

versioning? : {
storage() : {
get(version: String) : Handler<V> | null,
set(version: String, store: Handler<V>) : void,
del(version: String) : void,
empty() : void
},
deriveVersion<Context>(req: Req<V>, ctx?: Context) : String,
constraints? : {
[key: string]: ConstraintStrategy<V>
}
}

interface RouteOptions {
version: string;
constraints?: { [key: string]: any }
}

interface ShortHandRoute<V extends HTTPVersion> {
Expand Down Expand Up @@ -142,7 +149,7 @@ declare namespace Router {
find(
method: HTTPMethod,
path: string,
version?: string
constraints?: { [key: string]: any }
): FindResult<V> | null;

reset(): void;
Expand Down
Loading