Skip to content

Commit

Permalink
versioning as configurable option (#107)
Browse files Browse the repository at this point in the history
  • Loading branch information
PavelPolyakov authored and delvedor committed Jan 29, 2019
1 parent 6b9b2d5 commit c69b04b
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 30 deletions.
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,37 @@ const router = require('find-my-way')({
})
```

<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:
```js
const customVersioning = {
// storage factory
storage: function () {
let versions = {}
return {
get: (version) => { return versions[version] || null },
set: (version, store) => { versions[version] = store },
del: (version) => { delete versions[version] },
empty: () => { versions = {} }
}
},
deriveVersion: (req, ctx) => {
return req.headers['accept']
}
}

const router = FindMyWay({ versioning: 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

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*

<a name="on"></a>
#### on(method, path, [opts], handler, [store])
Register a new route.
Expand All @@ -103,9 +134,13 @@ router.on('GET', '/example', (req, res, params, store) => {
}, { message: 'hello world' })
```

<a name="semver"></a>
##### Versioned routes
If needed you can provide a `version` option, which will allow you to declare multiple versions of the same route. The versioning should follow the [semver](https://semver.org/) specification.<br/>

If needed you can provide a `version` option, which will allow you to declare multiple versions of the same route.

###### 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/>
*Be aware that using this feature will cause a degradation of the overall performances of the router.*
Expand All @@ -122,6 +157,9 @@ router.on('GET', '/example', { version: '2.4.0' }, (req, res, params) => {
```
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
It's also possible to define a [custom versioning strategy](#custom-versioning) during the `find-my-way` initialization. In this case the logic of matching the request to the specific handler depends on the versioning strategy you use.

##### on(methods[], path, [opts], handler, [store])
Register a new route for each method specified in the `methods` array.
It comes handy when you need to declare multiple routes with the same handler but different methods.
Expand Down Expand Up @@ -273,7 +311,7 @@ router.lookup(req, res, { greeting: 'Hello, World!' })
#### find(method, path [, version])
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, which should be conform to the [semver](https://semver.org/) specification.
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.
```js
router.find('GET', '/example')
// => { handler: Function, params: Object, store: Object}
Expand Down
10 changes: 10 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ declare namespace Router {
req: V extends HTTPVersion.V1 ? IncomingMessage : Http2ServerRequest,
res: V extends HTTPVersion.V1 ? ServerResponse : Http2ServerResponse
): 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: V extends HTTPVersion.V1 ? IncomingMessage : Http2ServerRequest, ctx?: Context) : String,
}
}

interface RouteOptions {
Expand Down
33 changes: 21 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ if (!isRegexSafe(FULL_PATH_REGEXP)) {
throw new Error('the FULL_PATH_REGEXP is not safe, update this module')
}

const acceptVersionStrategy = require('./lib/accept-version')

function Router (opts) {
if (!(this instanceof Router)) {
return new Router(opts)
Expand All @@ -41,7 +43,8 @@ function Router (opts) {
this.ignoreTrailingSlash = opts.ignoreTrailingSlash || false
this.maxParamLength = opts.maxParamLength || 100
this.allowUnsafeRegex = opts.allowUnsafeRegex || false
this.tree = new Node()
this.versioning = opts.versioning || acceptVersionStrategy
this.tree = new Node({ versions: this.versioning.storage() })
this.routes = []
}

Expand Down Expand Up @@ -198,20 +201,20 @@ Router.prototype._insert = function _insert (method, path, kind, params, handler
// let's split the node and add a new child
if (len < prefixLen) {
node = new Node(
prefix.slice(len),
currentNode.children,
currentNode.kind,
new Node.Handlers(currentNode.handlers),
currentNode.regex,
currentNode.versions
{ prefix: prefix.slice(len),
children: currentNode.children,
kind: currentNode.kind,
handlers: new Node.Handlers(currentNode.handlers),
regex: currentNode.regex,
versions: currentNode.versions }
)
if (currentNode.wildcardChild !== null) {
node.wildcardChild = currentNode.wildcardChild
}

// reset the parent
currentNode
.reset(prefix.slice(0, len))
.reset(prefix.slice(0, len), this.versioning.storage())
.addChild(node)

// if the longest common prefix has the same length of the current path
Expand All @@ -226,7 +229,13 @@ Router.prototype._insert = function _insert (method, path, kind, params, handler
}
currentNode.kind = kind
} else {
node = new Node(path.slice(len), {}, kind, null, regex, null)
node = new Node({
prefix: path.slice(len),
kind: kind,
handlers: null,
regex: regex,
versions: this.versioning.storage()
})
if (version) {
node.setVersionHandler(version, method, handler, params, store)
} else {
Expand All @@ -248,7 +257,7 @@ Router.prototype._insert = function _insert (method, path, kind, params, handler
continue
}
// there are not children within the given label, let's create a new one!
node = new Node(path, {}, kind, null, regex, null)
node = new Node({ prefix: path, kind: kind, handlers: null, regex: regex, versions: this.versioning.storage() })
if (version) {
node.setVersionHandler(version, method, handler, params, store)
} else {
Expand All @@ -272,7 +281,7 @@ Router.prototype._insert = function _insert (method, path, kind, params, handler
}

Router.prototype.reset = function reset () {
this.tree = new Node()
this.tree = new Node({ versions: this.versioning.storage() })
this.routes = []
}

Expand Down Expand Up @@ -323,7 +332,7 @@ Router.prototype.off = function off (method, path) {
}

Router.prototype.lookup = function lookup (req, res, ctx) {
var handle = this.find(req.method, sanitizeUrl(req.url), req.headers['accept-version'])
var handle = this.find(req.method, sanitizeUrl(req.url), this.versioning.deriveVersion(req, ctx))
if (handle === null) return this._defaultRoute(req, res, ctx)
return ctx === undefined
? handle.handler(req, res, handle.params, handle.store)
Expand Down
10 changes: 10 additions & 0 deletions lib/accept-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict'

const SemVerStore = require('semver-store')

module.exports = {
storage: SemVerStore,
deriveVersion: function (req, ctx) {
return req.headers['accept-version']
}
}
21 changes: 11 additions & 10 deletions node.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

const assert = require('assert')
const http = require('http')
const SemVerStore = require('semver-store')
const Handlers = buildHandlers()

const types = {
Expand All @@ -14,17 +13,19 @@ const types = {
MULTI_PARAM: 4
}

function Node (prefix, children, kind, handlers, regex, versions) {
this.prefix = prefix || '/'
function Node (options) {
// former arguments order: prefix, children, kind, handlers, regex, versions
options = options || {}
this.prefix = options.prefix || '/'
this.label = this.prefix[0]
this.children = children || {}
this.children = options.children || {}
this.numberOfChildren = Object.keys(this.children).length
this.kind = kind || this.types.STATIC
this.handlers = new Handlers(handlers)
this.regex = regex || null
this.kind = options.kind || this.types.STATIC
this.handlers = new Handlers(options.handlers)
this.regex = options.regex || null
this.wildcardChild = null
this.parametricBrother = null
this.versions = versions || SemVerStore()
this.versions = options.versions
}

Object.defineProperty(Node.prototype, 'types', {
Expand Down Expand Up @@ -97,15 +98,15 @@ Node.prototype.addChild = function (node) {
return this
}

Node.prototype.reset = function (prefix) {
Node.prototype.reset = function (prefix, versions) {
this.prefix = prefix
this.children = {}
this.kind = this.types.STATIC
this.handlers = new Handlers()
this.numberOfChildren = 0
this.regex = null
this.wildcardChild = null
this.versions = SemVerStore()
this.versions = versions
return this
}

Expand Down
10 changes: 5 additions & 5 deletions test/issue-104.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,17 @@ test('Mixed parametric routes, with last defined route being static', t => {

test('parametricBrother of Parent Node, with a parametric child', t => {
t.plan(1)
const parent = new Node('/a')
const parametricChild = new Node(':id', null, parent.types.PARAM)
const parent = new Node({ prefix: '/a' })
const parametricChild = new Node({ prefix: ':id', kind: parent.types.PARAM })
parent.addChild(parametricChild)
t.equal(parent.parametricBrother, null)
})

test('parametricBrother of Parent Node, with a parametric child and a static child', t => {
t.plan(1)
const parent = new Node('/a')
const parametricChild = new Node(':id', null, parent.types.PARAM)
const staticChild = new Node('/b', null, parent.types.STATIC)
const parent = new Node({ prefix: '/a' })
const parametricChild = new Node({ prefix: ':id', kind: parent.types.PARAM })
const staticChild = new Node({ prefix: '/b', kind: parent.types.STATIC })
parent.addChild(parametricChild)
parent.addChild(staticChild)
t.equal(parent.parametricBrother, null)
Expand Down
37 changes: 37 additions & 0 deletions test/version.custom-versioning.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict'

const t = require('tap')
const test = t.test
const FindMyWay = require('../')
const noop = () => {}

const customVersioning = {
// storage factory
storage: function () {
let versions = {}
return {
get: (version) => { return versions[version] || null },
set: (version, store) => { versions[version] = store },
del: (version) => { delete versions[version] },
empty: () => { versions = {} }
}
},
deriveVersion: (req, ctx) => {
return req.headers['accept']
}
}

test('A route could support multiple versions (find) / 1', t => {
t.plan(5)

const findMyWay = FindMyWay({ versioning: customVersioning })

findMyWay.on('GET', '/', { version: 'application/vnd.example.api+json;version=2' }, noop)
findMyWay.on('GET', '/', { version: 'application/vnd.example.api+json;version=3' }, noop)

t.ok(findMyWay.find('GET', '/', 'application/vnd.example.api+json;version=2'))
t.ok(findMyWay.find('GET', '/', 'application/vnd.example.api+json;version=3'))
t.notOk(findMyWay.find('GET', '/', 'application/vnd.example.api+json;version=4'))
t.notOk(findMyWay.find('GET', '/', 'application/vnd.example.api+json;version=5'))
t.notOk(findMyWay.find('GET', '/', 'application/vnd.example.api+json;version=6'))
})
File renamed without changes.

0 comments on commit c69b04b

Please sign in to comment.