Merge pull request #9 from jo/docuri-routes

Completely new routing engine.
pull/12/head
Johannes Jörg Schmidt 10 years ago
commit 5c2d85b97d

@ -1,96 +1,127 @@
# docuri [![Build Status](https://travis-ci.org/jo/docuri.svg?branch=master)](https://travis-ci.org/jo/docuri)
# DocURI [![Build Status](https://travis-ci.org/jo/docuri.svg?branch=master)](https://travis-ci.org/jo/docuri)
Rich document ids for CouchDB:
```js
'movie/blade-runner/gallery-image/12/medium'
```
type/id/subtype/index/version
```
For example: `movie/blade-runner/gallery-image/12/medium`
Docuris have many advantages:
* You can access the doc type (eg. in changes feed)
* They sort well in Futon
* They tell a lot about the document
### Advantages
* You can access the doc type everywhere (eg. in changes feed, response, view results...)
* They sort well in Futon and` _all_docs`
* DocURIs can tell a lot about the document
* You can rely on a schema and construct ids of dependend documents (eg. a specific version of an image)
* You can easily delete related documents (eg. by requesting a range from `_all_docs`)
* Easily delete related documents (eg. by requesting a range from `_all_docs`)
...and I'm sure I forgot to mention the best.
Give Docuris a try!
_Give DocURIs a try!_
## Usage
Define methods for certain DocURI fragments and provide a routes hash that pairs routes to methods.
DocURI is inspired by [Backbone.Router](http://backbonejs.org/#Router).
Routes can contain parameter parts, `:param`, which match a single DocURI component
between slashes; and splat parts `*splat`, which can match any number of DocURI
components. Part of a route can be made optional by surrounding it in
parentheses `(/:optional)`.
### `parse(string)`
Parse id string:
For example, a route of `'movie/:movie_id/gallery-image'` will generate a function which parses
```js
var docuri = require('docuri');
docuri.parse('mytype/myid/mysubtype/myindex/myversion');
// {
// type: 'mytype',
// id: 'myid',
// subtype: 'mysubtype',
// index: 'myindex',
// version: 'myversion'
// }
'movie/blade-runner/gallery-image/12'
// =>
{
movie_id: 'blade-runner',
id: '12'
}
```
and vice versa.
### `stringify(object)`
Build id string from object:
A route of `'movie/:movie_id/:type/*path'` will generate a function which parses
```js
docuri.stringify({
type: 'mytype',
id: 'myid',
subtype: 'mysubtype',
index: 'myindex',
version: 'myversion'
});
// 'mytype/myid/mysubtype/myindex/myversion'
'movie/blade-runner/gallery-image/12'
// =>
{
movie_id: 'blade-runner',
type: 'gallery-image',
path: ['12']
}
// and
'movie/blade-runner/gallery-image/12/medium'
// =>
{
movie_id: 'blade-runner',
type: 'gallery-image',
path: ['12', 'medium']
}
```
### `merge(objectOrString)`
Change id string components:
The route `'movie/:movie_id/gallery-image/:id(/:version)'` will generate a
function which parses
```js
docuri.merge('mytype/myid/mysubtype/myindex/myversion', {
type: 'my_new_type',
});
// 'my_new_type/myid/mysubtype/myindex/myversion'
'movie/blade-runner/gallery-image/12'
// =>
{
movie_id: 'blade-runner',
id: '12'
}
// as well as
'movie/blade-runner/gallery-image/12/medium'
// =>
{
movie_id: 'blade-runner',
id: '12',
version: 'medium'
}
```
### `parts(objectOrString)`
Array of components. Trailing `undefined` components are stripped off:
### `docuri.route(route, name)`
Create a single route. The `route` argument must be a routing string. The
`name` argument will be the identifier for the resulting function:
`docuri[name]`. Routes added later may override previously declared routes.
```js
docuri.parts('mytype/myid/');
// ['mytype', 'myid']
docuri.parts({ type: 'mytype', subtype: 'mysubtype' });
// ['mytype', undefined, 'mysubtype']
// parses 'page/home' as { id: 'home' }:
docuri.route('page/:id', 'page');
```
### `docuri.definition([array])`
Access or use custom definition:
### `docuri.routes(map)`
Install a routes hash which maps DocURIs with parameters to functions:
```js
docuri.definition();
// ['type', 'id', 'subtype', 'index', 'version']
docuri
.definition(['id', 'meta'])
.parse('42/answer');
// {
// id: '42',
// meta: 'answer'
// }
docuri.routes({
'movie/:id': 'movie',
'movie/:movie_id/:type/*path': 'movieAsset'
'movie/:movie_id/gallery-image/:id(/:version)': 'galleryImage',
});
```
Note: the (optional) argument to `docuri.definition` MUST be an array of strings
containing at least one item.
### `docuri[name](strOrObj, [obj])`
The functions generated by DocURI can have a different behaviour, depending on
the type and number of the supplied arguments:
## Browser support
To use docid in your client-side application, browserify it like this:
* `name(str)`: parse DocURI string to object
* `name(obj)`: generate DocURI string from object
* `name(str, obj)`: change DocURI string parts with values provided by object returning a string
The function returns `false` if a string can not be parsed, enabling type
checks.
#### Example
```js
docuri.movie('movie/blade-runner');
// { id: 'blade-runner' }
docuri.movieAsset('movie/blade-runner');
// false
docuri.galleryImage({ movie_id: 'blade-runner', id: 12 });
// 'movie/blade-runner/gallery-image/12'
docuri.galleryImage('movie/blade-runner/gallery-image/12', { version: 'large' });
// 'movie/blade-runner/gallery-image/12/large'
```
## Browser support
To use DocURI in your client-side application, browserify it like this:
```shell
browserify -s DocURI path/to/docuri/index.js > path/to/your/assets
```
Once added to your DOM, this will leave you with a global DocURI object for use
in your e.g. Backbone Models/Collections.
Or grab it from [browserify-as-a-service: docuri@latest](http://www.modulefarm.com/standalone/docuri@latest).
## Development
To run the unit tests:

@ -1,74 +1,138 @@
/*
* docuri: Rich document ids for CouchDB.
* DocURI: Rich document ids for CouchDB.
*
* Copyright (c) 2014 null2 GmbH Berlin
* Licensed under the MIT license.
*/
// type/id/subtype/index/version
var DEFINITION = ['type', 'id', 'subtype', 'index', 'version'];
var docuri;
module.exports = exports = docuri = {};
var docuri = module.exports = exports = {};
docuri.definition = function(definition) {
if (definition) {
DEFINITION = definition;
var reservedNames = ['routes', 'route'];
return docuri;
}
// Cached regular expressions for matching named param parts and splatted parts
// of route strings.
// http://backbonejs.org/docs/backbone.html#section-158
var optionalParam = /\((.*?)\)/g;
var namedParam = /(\(\?)?:\w+/g;
var splatParam = /\*\w+/g;
var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g;
return DEFINITION;
}
// Convert a route string into a regular expression,
// with named regular expressions for named arguments.
// http://backbonejs.org/docs/backbone.html#section-165
function routeToRegExp(src) {
var keys = [];
var route = src.replace(escapeRegExp, '\\$&')
.replace(optionalParam, '(?:$1)?')
.replace(namedParam, function(match, optional) {
keys.push(match);
return optional ? match : '([^/?]+)';
})
.replace(splatParam, function(match) {
keys.push(match);
return '([^?]*?)';
});
keys = keys.reduce(function(memo, key) {
var value = '\\' + key;
memo[key] = new RegExp(value + '(\\/|\\)|$)');
return memo;
}, {});
docuri.parts = function(obj) {
if (typeof obj === 'string') {
obj = docuri.parse(obj);
return {
src: src,
exp: new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$'),
keys: keys
}
}
var parts = DEFINITION.map(function(part) {
return obj[part];
});
// Given a route and a DocURI return an object of extracted parameters.
// Unmatched DocURIs will be treated as false.
// http://backbonejs.org/docs/backbone.html#section-166
function extractParameters(route, fragment) {
var params = route.exp.exec(fragment);
while (parts.length && (typeof parts[parts.length - 1] === 'undefined' || parts[parts.length - 1] === null)) {
parts.pop();
if (!params) {
return false;
}
return parts;
};
params = params.slice(1);
return Object.keys(route.keys).reduce(function(memo, key, i) {
var param = params[i];
docuri.parse = function(str) {
str = str || '';
if (param) {
if (key[0] === '*') {
param = param.split('/');
}
return str.split('/').reduce(function(obj, value, i) {
if (value) {
obj[DEFINITION[i]] = value;
memo[key.substr(1)] = param;
}
return obj;
return memo;
}, {});
};
}
docuri.stringify = function(obj) {
obj = obj || {};
// Insert named parameters from object.
function insertParameters(route, obj) {
var str = route.src;
return docuri.parts(obj).join('/');
};
Object.keys(route.keys).forEach(function(key) {
var k = key.substr(1);
var value = obj[k] || '';
docuri.merge = function(obj, objToMerge) {
objToMerge = objToMerge || {};
if (Array.isArray(value)) {
value = value.join('/');
}
if (typeof obj === 'string') {
obj = docuri.parse(obj);
str = str.replace(route.keys[key], value + '$1');
});
return str
.replace(/\(\/\)$/g, '')
.replace(/[)(]/g, '');
}
// Map routes
docuri.routes = function(map) {
Object.keys(map).forEach(function(route) {
docuri.route(route, map[route]);
});
};
// Manually bind a single named route
docuri.route = function(route, name) {
if (reservedNames.indexOf(name) > -1) {
throw('Reserved name "' + name + '" cannot be used.');
}
DEFINITION.forEach(function(part) {
if (part in objToMerge) {
obj[part] = objToMerge[part];
route = routeToRegExp(route);
docuri[name] = function(source, target) {
source = source || {};
if (target) {
source = extractParameters(route, source);
Object.keys(target).forEach(function(key) {
source[key] = target[key];
});
}
});
return docuri.stringify(obj);
if (typeof source === 'object') {
return insertParameters(route, source);
}
if (typeof source === 'string') {
return extractParameters(route, source);
}
};
};

@ -1,28 +0,0 @@
var test = require('tap').test;
var definition = require('..').definition;
test('default configuration', function(t) {
t.deepEqual(definition(), ['type', 'id', 'subtype', 'index', 'version'], 'should return default parts');
t.end();
});
test('set configuration', function(t) {
var parts = ['my', 'parts'];
t.type(definition(parts).merge, 'function', 'should return docuri api: merge');
t.type(definition(parts).parse, 'function', 'should return docuri api: parse');
t.type(definition(parts).stringify, 'function', 'should return docuri api: stringify');
t.end();
});
test('change configuration', function(t) {
var parts = ['my', 'parts'];
definition(parts);
t.deepEqual(definition(), parts, 'should return custom parts');
t.end();
});
test('use changed configuration', function(t) {
var parts = ['my', 'parts'];
t.deepEqual(definition(parts).parse('one/two'), { my: 'one', parts: 'two'}, 'should use custom parts');
t.end();
});

@ -0,0 +1,51 @@
var test = require('tap').test;
var docuri = require('..');
test('simple route', function(t) {
docuri.route('page', 'page');
t.deepEqual(docuri.page('page'), {}, 'parsed page returns empty object');
t.equal(docuri.page('image'), false, 'parsed image returns false');
t.equal(docuri.page(), 'page', 'generate page url');
t.end();
});
test('named parameter', function(t) {
docuri.route('page/:id', 'page');
t.deepEqual(docuri.page('page/mypage'), { id: 'mypage' }, 'parsed page has "id" set to "mypage"');
t.equal(docuri.page({ id: 'mypage' }), 'page/mypage', 'stringified page results in "page/mypage"');
t.end();
});
test('optional parameter', function(t) {
docuri.route('page(/:id)', 'page');
t.deepEqual(docuri.page('page'), {}, 'parsed page returns empty object');
t.deepEqual(docuri.page('page/mypage'), { id: 'mypage' }, 'parsed page has "id" set to "mypage"');
t.equal(docuri.page(), 'page', 'stringified empty page results in "page"');
t.equal(docuri.page({ id: 'mypage' }), 'page/mypage', 'stringified page results in "page/mypage"');
t.end();
});
test('two named parameters', function(t) {
docuri.route('page/:page_id/content/:id', 'content');
t.deepEqual(docuri.content('page/mypage/content/mycontent'), { page_id: 'mypage', id: 'mycontent' }, 'parsed content has "page_id" set to "mypage" and "id" set to "mycontent"');
t.equal(docuri.content({ page_id: 'mypage', id: 'mycontent' }), 'page/mypage/content/mycontent', 'stringified content results in "page/mypage/content/mycontent"');
t.end();
});
test('splat parameter', function(t) {
docuri.route('page/*path', 'page');
t.deepEqual(docuri.page('page/mypage/otherpage'), { path: ['mypage', 'otherpage'] }, 'parsed page has "parts" set to ["mypage","otherpage"]');
t.equal(docuri.page({ path: ['mypage', 'otherpage'] }), 'page/mypage/otherpage', 'stringified page results in "page/mypage/otherpage"');
t.end();
});

@ -0,0 +1,27 @@
var test = require('tap').test;
var docuri = require('..');
test('named parameter', function(t) {
docuri.route('page/:id', 'page');
t.equal(docuri.page('page/mypage', { id: 'otherpage' }), 'page/otherpage', 'merged page has "id" set to "otherpage"');
t.end();
});
test('optional parameter', function(t) {
docuri.route('page(/:id)', 'page');
t.deepEqual(docuri.page('page/mypage'), { id: 'mypage' }, 'parsed page has "id" set to "mypage"');
t.equal(docuri.page('page/mypage', { id: 'otherpage' }), 'page/otherpage', 'merged page has "id" set to "otherpage"');
t.end();
});
test('two named parameters', function(t) {
docuri.route('page/:page_id/content/:id', 'content');
t.equal(docuri.content('page/mypage/content/mycontent', { id: 'othercontent' }), 'page/mypage/content/othercontent', 'merged content has "id" set to "othercontent"');
t.end();
});

@ -1,62 +0,0 @@
var test = require('tap').test;
var merge = require('..').merge;
test('changing type component', function(t) {
t.equal(merge('type/id/subtype/index/version', {type:'new_type'}), 'new_type/id/subtype/index/version', 'should return docuri string with type changed');
t.end();
});
test('changing id component', function(t) {
t.equal(merge('type/id/subtype/index/version', {id:'new_id'}), 'type/new_id/subtype/index/version', 'should return docuri string with id changed');
t.end();
});
test('changing subtype component', function(t) {
t.equal(merge('type/id/subtype/index/version', {subtype:'new_subtype'}), 'type/id/new_subtype/index/version', 'should return docuri string with subtype changed');
t.end();
});
test('changing index component', function(t) {
t.equal(merge('type/id/subtype/index/version', {index:'new_index'}), 'type/id/subtype/new_index/version', 'should return docuri string with index changed');
t.end();
});
test('changing version component', function(t) {
t.equal(merge('type/id/subtype/index/version', {version:'new_version'}), 'type/id/subtype/index/new_version', 'should return docuri string with version changed');
t.end();
});
test('changing unknown component', function(t) {
t.equal(merge('type/id/subtype/index/version', {unknown:'i dont know'}), 'type/id/subtype/index/version', 'should return unchanged docuri');
t.end();
});
test('missing second argument', function(t) {
t.equal(merge('type/id/subtype/index/version'), 'type/id/subtype/index/version', 'should return unchanged docuri string');
t.end();
});
test('changing type component using object', function(t) {
t.equal(merge({
type: 'type',
id: 'id',
subtype: 'subtype',
index: 'index',
version: 'version'
}, {type:'new_type'}), 'new_type/id/subtype/index/version', 'should return docuri string with type changed');
t.end();
});
test('removing component with undefined', function(t) {
t.equal(merge('type/id/subtype/index/version', {version:undefined}), 'type/id/subtype/index', 'should return docuri string with version removed');
t.end();
});
test('removing component with null', function(t) {
t.equal(merge('type/id/subtype/index/version', {version:null}), 'type/id/subtype/index', 'should return docuri string with version removed');
t.end();
});

@ -1,55 +0,0 @@
var test = require('tap').test;
var parse = require('..').parse;
test('missing argument', function(t) {
t.deepEqual(parse(), {}, 'should return empty object');
t.end();
});
test('empty string', function(t) {
t.deepEqual(parse(''), {}, 'should return empty object');
t.end();
});
test('type', function(t) {
t.deepEqual(parse('mytype'), { type: 'mytype' }, 'should return object with type');
t.end();
});
test('type/id', function(t) {
t.deepEqual(parse('mytype/myid'), {
type: 'mytype',
id: 'myid'
}, 'should return object with type and id');
t.end();
});
test('type/id/subtype', function(t) {
t.deepEqual(parse('mytype/myid/mysubtype'), {
type: 'mytype',
id: 'myid',
subtype: 'mysubtype'
}, 'should return object with type, id and subtype');
t.end();
});
test('type/id/subtype/index', function(t) {
t.deepEqual(parse('mytype/myid/mysubtype/myindex'), {
type: 'mytype',
id: 'myid',
subtype: 'mysubtype',
index: 'myindex'
}, 'should return object with type, id, subtype and index');
t.end();
});
test('type/id/subtype/index/version', function(t) {
t.deepEqual(parse('mytype/myid/mysubtype/myindex/myversion'), {
type: 'mytype',
id: 'myid',
subtype: 'mysubtype',
index: 'myindex',
version: 'myversion'
}, 'should return object with type, id, subtype, index and version');
t.end();
});

@ -1,36 +0,0 @@
var test = require('tap').test;
var parts = require('..').parts;
test('parts of type string', function(t) {
t.deepEqual(parts('type'), ['type'], 'should return array including type');
t.end();
});
test('parts of type/id string', function(t) {
t.deepEqual(parts('type/id'), ['type', 'id'], 'should return array including type and id');
t.end();
});
test('parts of type/id/subtype string', function(t) {
t.deepEqual(parts('type/id/subtype'), ['type', 'id', 'subtype'], 'should return array including type, id and subtype');
t.end();
});
test('parts of type//subtype string', function(t) {
t.deepEqual(parts('type//subtype'), ['type', undefined, 'subtype'], 'should return array including type, undefined and subtype');
t.end();
});
test('parts of type object', function(t) {
t.deepEqual(parts({ type: 'type' }), ['type'], 'should return array including type');
t.end();
});
test('parts of type/id object', function(t) {
t.deepEqual(parts({ type: 'type', id: 'id'}), ['type', 'id'], 'should return array including type and id');
t.end();
});
test('parts of type/id/subtype object', function(t) {
t.deepEqual(parts({ type: 'type', id: 'id', subtype: 'subtype' }), ['type', 'id', 'subtype'], 'should return array including type, id and subtype');
t.end();
});
test('parts of type//subtype object', function(t) {
t.deepEqual(parts({ type: 'type', subtype: 'subtype' }), ['type', undefined, 'subtype'], 'should return array including type, undefined and subtype');
t.end();
});

@ -0,0 +1,26 @@
var test = require('tap').test;
var docuri = require('..');
test('reserved name route', function(t) {
t.plan(1);
try {
docuri.route('page', 'route');
} catch(e) {
t.equal(e, 'Reserved name "route" cannot be used.', 'reserved name "route" should throw error');
}
t.end();
});
test('reserved name routes', function(t) {
t.plan(1);
try {
docuri.route('page', 'routes');
} catch(e) {
t.equal(e, 'Reserved name "routes" cannot be used.', 'reserved name "routes" should throw error');
}
t.end();
});

@ -1,140 +0,0 @@
var test = require('tap').test;
var stringify = require('..').stringify;
test('missing argument', function(t) {
t.deepEqual(stringify(), '', 'should return empty string');
t.end();
});
test('empty object', function(t) {
t.deepEqual(stringify({}), '', 'should return empty string');
t.end();
});
test('type', function(t) {
t.deepEqual(stringify({ type: 'mytype' }), 'mytype', 'should return string with type');
t.end();
});
test('/id', function(t) {
t.deepEqual(stringify({
id: 'myid'
}), '/myid', 'should return string with type and id');
t.end();
});
test('type/id', function(t) {
t.deepEqual(stringify({
type: 'mytype',
id: 'myid'
}), 'mytype/myid', 'should return string with type and id');
t.end();
});
test('//subtype', function(t) {
t.deepEqual(stringify({
subtype: 'mysubtype'
}), '//mysubtype', 'should return string with type, id and subtype');
t.end();
});
test('/id/subtype', function(t) {
t.deepEqual(stringify({
id: 'myid',
subtype: 'mysubtype'
}), '/myid/mysubtype', 'should return string with type, id and subtype');
t.end();
});
test('type/id/subtype', function(t) {
t.deepEqual(stringify({
type: 'mytype',
id: 'myid',
subtype: 'mysubtype'
}), 'mytype/myid/mysubtype', 'should return string with type, id and subtype');
t.end();
});
test('/id/subtype/index', function(t) {
t.deepEqual(stringify({
id: 'myid',
subtype: 'mysubtype',
index: 'myindex'
}), '/myid/mysubtype/myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('type//subtype/index', function(t) {
t.deepEqual(stringify({
type: 'mytype',
subtype: 'mysubtype',
index: 'myindex'
}), 'mytype//mysubtype/myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('type///index', function(t) {
t.deepEqual(stringify({
type: 'mytype',
index: 'myindex'
}), 'mytype///myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('//subtype/index', function(t) {
t.deepEqual(stringify({
subtype: 'mysubtype',
index: 'myindex'
}), '//mysubtype/myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('///index', function(t) {
t.deepEqual(stringify({
index: 'myindex'
}), '///myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('type/id/subtype/index', function(t) {
t.deepEqual(stringify({
type: 'mytype',
id: 'myid',
subtype: 'mysubtype',
index: 'myindex'
}), 'mytype/myid/mysubtype/myindex', 'should return string with type, id, subtype and index');
t.end();
});
test('////version', function(t) {
t.deepEqual(stringify({
version: 'myversion'
}), '////myversion', 'should return string with type, id, subtype, index and version');
t.end();
});
test('type/id/subtype/index/version', function(t) {
t.deepEqual(stringify({
type: 'mytype',
id: 'myid',
subtype: 'mysubtype',
index: 'myindex',
version: 'myversion'
}), 'mytype/myid/mysubtype/myindex/myversion', 'should return string with type, id, subtype, index and version');
t.end();
});
test('0/1/2/3/4', function(t) {
t.deepEqual(stringify({
type: 0,
id: 1,
subtype: 2,
index: 3,
version: 4
}), '0/1/2/3/4', 'should correctly handle number path fields');
t.end();
});
Loading…
Cancel
Save