@conglomerate/router
Version:
619 lines (478 loc) • 15.9 kB
JavaScript
//
// @conglomerate/router 1.2.0
//
// @conglomerate/router is released under the terms of the BSD-3-Clause license.
// (c) 2015 - 2016 Mark Milstein <mark@epiloque.com> https://github.com/epiloque
//
// For all details and documentation: https://github.com/eitherlands/conglomerate-router
//
'use strict';
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var _Object$keys = _interopDefault(require('babel-runtime/core-js/object/keys'));
var _conglomerate_assert = require('@conglomerate/assert');
var _conglomerate_error = require('@conglomerate/error');
var _conglomerate_escape = require('@conglomerate/escape');
var isUndefined = _interopDefault(require('@f/is-undefined'));
function generate() {
/*
/path/{param}/path/{param?}
/path/{param*2}/path
/path/{param*2}
/path/x{param}x
/{param*}
*/
var empty = '(?:^\\/$)';
var legalChars = '[\\w\\!\\$&\'\\(\\)\\*\\+\\,\\=\\:@\\-\\.~]';
var encoded = '%[A-F0-9]{2}';
var literalChar = '(?:' + legalChars + '|' + encoded + ')';
var literal = literalChar + '+';
var literalOptional = literalChar + '*';
var midParam = '(?:\\{\\w+(?:\\*[1-9]\\d*)?\\})'; // {p}, {p*2}
var endParam = '(?:\\/(?:\\{\\w+(?:(?:\\*(?:[1-9]\\d*)?)|(?:\\?))?\\})?)?'; // {p}, {p*2}, {p*}, {p?}
var partialParam = '(?:\\{\\w+\\??\\})'; // {p}, {p?}
var mixedParam = '(?:(?:' + literal + partialParam + ')+' + literalOptional + ')|(?:' + partialParam + '(?:' + literal + partialParam + ')+' + literalOptional + ')|(?:' + partialParam + literal + ')';
var segmentContent = '(?:' + literal + '|' + midParam + '|' + mixedParam + ')';
var segment = '\\/' + segmentContent;
var segments = '(?:' + segment + ')*';
var path = '(?:^' + segments + endParam + '$)';
// 1:literal 2:name 3:* 4:count 5:?
var parseParam = '(' + literal + ')|(?:\\{(\\w+)(?:(\\*)(\\d+)?)?(\\?)?\\})';
var expressions = {
parseParam: new RegExp(parseParam, 'g'),
validatePath: new RegExp(empty + '|' + path),
validatePathEncoded: /%(?:2[146-9A-E]|3[\dABD]|4[\dA-F]|5[\dAF]|6[1-9A-F]|7[\dAE])/g
};
return expressions;
}
var internals$1 = {};
var Segment = internals$1.Segment = function () {
this._edge = null; // { segment, record }
this._fulls = null; // { path: { segment, record }
this._literals = null; // { literal: { segment, <node> } }
this._param = null; // <node>
this._mixed = null; // [{ segment, <node> }]
this._wildcard = null; // { segment, record }
};
internals$1.Segment.prototype.add = function (segments, record) {
/*
{ literal: 'x' } -> x
{ empty: false } -> {p}
{ wildcard: true } -> {p*}
{ mixed: /regex/ } -> a{p}b
*/
var current = segments[0];
var remaining = segments.slice(1);
var isEdge = !remaining.length;
var literals = [];
var isLiteral = true;
for (var i = 0; i < segments.length && isLiteral; ++i) {
isLiteral = segments[i].literal !== undefined;
literals.push(segments[i].literal);
}
if (isLiteral) {
this._fulls = this._fulls || {};
var literal = '/' + literals.join('/');
if (!record.settings.isCaseSensitive) {
literal = literal.toLowerCase();
}
_conglomerate_assert.assert(!this._fulls[literal], 'New route', record.path, 'conflicts with existing', this._fulls[literal] && this._fulls[literal].record.path);
this._fulls[literal] = { segment: current, record: record };
} else if (current.literal !== undefined) {
// Can be empty string
// Literal
this._literals = this._literals || {};
var currentLiteral = record.settings.isCaseSensitive ? current.literal : current.literal.toLowerCase();
this._literals[currentLiteral] = this._literals[currentLiteral] || new internals$1.Segment();
this._literals[currentLiteral].add(remaining, record);
} else if (current.wildcard) {
// Wildcard
_conglomerate_assert.assert(!this._wildcard, 'New route', record.path, 'conflicts with existing', this._wildcard && this._wildcard.record.path);
_conglomerate_assert.assert(!this._param || !this._param._wildcard, 'New route', record.path, 'conflicts with existing', this._param && this._param._wildcard && this._param._wildcard.record.path);
this._wildcard = { segment: current, record: record };
} else if (current.mixed) {
// Mixed
this._mixed = this._mixed || [];
var mixed = this._mixedLookup(current);
if (!mixed) {
mixed = { segment: current, node: new internals$1.Segment() };
this._mixed.push(mixed);
this._mixed.sort(internals$1.mixed);
}
if (isEdge) {
_conglomerate_assert.assert(!mixed.node._edge, 'New route', record.path, 'conflicts with existing', mixed.node._edge && mixed.node._edge.record.path);
mixed.node._edge = { segment: current, record: record };
} else {
mixed.node.add(remaining, record);
}
} else {
// Parameter
this._param = this._param || new internals$1.Segment();
if (isEdge) {
_conglomerate_assert.assert(!this._param._edge, 'New route', record.path, 'conflicts with existing', this._param._edge && this._param._edge.record.path);
this._param._edge = { segment: current, record: record };
} else {
_conglomerate_assert.assert(!this._wildcard || !remaining[0].wildcard, 'New route', record.path, 'conflicts with existing', this._wildcard && this._wildcard.record.path);
this._param.add(remaining, record);
}
}
};
internals$1.Segment.prototype._mixedLookup = function (segment) {
for (var i = 0; i < this._mixed.length; ++i) {
if (internals$1.mixed({ segment: segment }, this._mixed[i]) === 0) {
return this._mixed[i];
}
}
return null;
};
internals$1.mixed = function (a, b) {
var aFirst = -1;
var bFirst = 1;
var as = a.segment;
var bs = b.segment;
if (as.length !== bs.length) {
return as.length > bs.length ? aFirst : bFirst;
}
if (as.first !== bs.first) {
return as.first ? bFirst : aFirst;
}
for (var i = 0; i < as.segments.length; ++i) {
var am = as.segments[i];
var bm = bs.segments[i];
if (am === bm) {
continue;
}
if (am.length === bm.length) {
return am > bm ? bFirst : aFirst;
}
return am.length < bm.length ? bFirst : aFirst;
}
return 0;
};
internals$1.Segment.prototype.lookup = function (path, segments, options) {
var match = null;
// Literal edge
if (this._fulls) {
match = this._fulls[options.isCaseSensitive ? path : path.toLowerCase()];
if (match) {
return { record: match.record, array: [] };
}
}
// Literal node
var current = segments[0];
var nextPath = path.slice(current.length + 1);
var remainder = segments.length > 1 ? segments.slice(1) : null;
if (this._literals) {
match = this._literals[options.isCaseSensitive ? current : current.toLowerCase()];
if (match) {
var record = internals$1.deeper(match, nextPath, remainder, [], options);
if (record) {
return record;
}
}
}
// Mixed
if (this._mixed) {
for (var i = 0; i < this._mixed.length; ++i) {
match = this._mixed[i];
var params = current.match(match.segment.mixed);
if (params) {
var array = [];
for (var j = 1; j < params.length; ++j) {
array.push(params[j]);
}
var _record = internals$1.deeper(match.node, nextPath, remainder, array, options);
if (_record) {
return _record;
}
}
}
}
// Param
if (this._param) {
if (current || !this._param._edge || this._param._edge.segment.empty) {
var _record2 = internals$1.deeper(this._param, nextPath, remainder, [current], options);
if (_record2) {
return _record2;
}
}
}
// Wildcard
if (this._wildcard) {
return { record: this._wildcard.record, array: [path.slice(1)] };
}
return null;
};
internals$1.deeper = function (match, path, segments, array, options) {
if (!segments) {
if (match._edge) {
return { record: match._edge.record, array: array };
}
if (match._wildcard) {
return { record: match._wildcard.record, array: array };
}
} else {
var result = match.lookup(path, segments, options);
if (result) {
return { record: result.record, array: array.concat(result.array) };
}
}
return null;
};
// Declare internals
var internals = {
pathRegex: generate(),
defaults: {
isCaseSensitive: true
}
};
function badRequest(message, data) {
return _conglomerate_error.create(400, message, data);
}
function notFound(message, data) {
return _conglomerate_error.create(404, message, data);
}
var Router = internals.Router = function (options) {
options = options || {};
if (isUndefined(options.isCaseSensitive)) {
options.isCaseSensitive = true;
}
this.settings = options;
this.routes = {}; // Key: HTTP method or * for catch-all, value: sorted array of routes
this.ids = {}; // Key: route id, value: record
this.specials = {
badRequest: null,
notFound: null,
options: null
};
};
internals.Router.prototype.add = function (config, route, handler) {
var method = config.method.toLowerCase();
var table = this.routes;
table[method] = table[method] || { routes: [], router: new Segment() };
var analysis = config.analysis || this.analyze(config.path);
var record = {
path: config.path,
route: route || config.path,
handler: handler || config.handler,
meta: config.meta,
segments: analysis.segments,
params: analysis.params,
fingerprint: analysis.fingerprint,
settings: this.settings
};
// Add route
table[method].router.add(analysis.segments, record);
table[method].routes.push(record);
table[method].routes.sort(internals.sort);
var last = record.segments[record.segments.length - 1];
if (last.empty) {
table[method].router.add(analysis.segments.slice(0, -1), record);
}
if (config.id) {
_conglomerate_assert.assert(!this.ids[config.id], 'Route id', config.id, 'for path', config.path, 'conflicts with existing path', this.ids[config.id] && this.ids[config.id].path);
this.ids[config.id] = record;
}
return record;
};
internals.Router.prototype.special = function (type, route) {
_conglomerate_assert.assert(_Object$keys(this.specials).indexOf(type) !== -1, 'Unknown special route type:', type);
this.specials[type] = { route: route };
};
internals.Router.prototype.route = function (method, path) {
method = method.toLowerCase();
var segments = path.split('/').slice(1);
var route = this._lookup(path, segments, this.routes, method) || method === 'options' && this.specials.options || this._lookup(path, segments, this.routes, '*') || this.specials.notFound || notFound();
return route;
};
internals.Router.prototype._lookup = function (path, segments, table, method) {
var set = table[method];
if (!set) {
return null;
}
var match = set.router.lookup(path, segments, this.settings);
if (!match) {
return null;
}
var assignments = {};
var array = [];
for (var i = 0; i < match.array.length; ++i) {
var name = match.record.params[i];
var value = match.array[i];
if (value) {
value = internals.decode(value);
if (value.isError) {
return this.specials.badRequest || value;
}
if (assignments[name] !== undefined) {
assignments[name] = assignments[name] + '/' + value;
} else {
assignments[name] = value;
}
if (i + 1 === match.array.length || name !== match.record.params[i + 1]) {
array.push(assignments[name]);
}
}
}
return {
params: assignments,
paramsArray: array,
route: match.record.route,
handler: match.record.handler,
meta: match.record.meta
};
};
internals.decode = function (value) {
try {
return decodeURIComponent(value);
} catch (err) {
return badRequest('Invalid request path');
}
};
internals.Router.prototype.normalize = function (path) {
if (path && path.indexOf('%') !== -1) {
// Uppercase %encoded values
var uppercase = path.replace(/%[0-9a-fA-F][0-9a-fA-F]/g, function (encoded) {
return encoded.toUpperCase();
});
// Decode non-reserved path characters: a-z A-Z 0-9 _!$&'()*+,;=:@-.~
// ! (%21) $ (%24) & (%26) ' (%27) ( (%28) ) (%29) * (%2A) + (%2B) , (%2C) - (%2D) . (%2E)
// 0-9 (%30-39) : (%3A) ; (%3B) = (%3D)
// @ (%40) A-Z (%41-5A) _ (%5F) a-z (%61-7A) ~ (%7E)
var decoded = uppercase.replace(/%(?:2[146-9A-E]|3[\dABD]|4[\dA-F]|5[\dAF]|6[1-9A-F]|7[\dAE])/g, function (encoded) {
return String.fromCharCode(parseInt(encoded.substring(1), 16));
});
path = decoded;
}
return path;
};
internals.Router.prototype.analyze = function (path) {
_conglomerate_assert.assert(internals.pathRegex.validatePath.test(path), 'Invalid path:', path);
_conglomerate_assert.assert(!internals.pathRegex.validatePathEncoded.test(path), 'Path cannot contain encoded non-reserved path characters:', path);
var pathParts = path.split('/');
var segments = [];
var params = [];
var fingers = [];
for (var i = 1; i < pathParts.length; ++i) {
// Skip first empty segment
var segment = pathParts[i];
// Literal
if (segment.indexOf('{') === -1) {
segment = this.settings.isCaseSensitive ? segment : segment.toLowerCase();
fingers.push(segment);
segments.push({ literal: segment });
continue;
}
// Parameter
var parts = internals.parseParams(segment);
if (parts.length === 1) {
// Simple parameter
var item = parts[0];
_conglomerate_assert.assert(params.indexOf(item.name) === -1, 'Cannot repeat the same parameter name:', item.name, 'in:', path);
params.push(item.name);
if (item.wilcard) {
if (item.count) {
for (var j = 0; j < item.count; ++j) {
fingers.push('?');
segments.push({});
if (j) {
params.push(item.name);
}
}
} else {
fingers.push('#');
segments.push({ wildcard: true });
}
} else {
fingers.push('?');
segments.push({ empty: item.empty });
}
} else {
// Mixed parameter
var seg = {
length: parts.length,
first: typeof parts[0] !== 'string',
segments: []
};
var finger = '';
var regex = '^';
for (var _j = 0; _j < parts.length; ++_j) {
var part = parts[_j];
if (typeof part === 'string') {
finger += part;
regex += _conglomerate_escape.escapeRegex(part);
seg.segments.push(part);
} else {
_conglomerate_assert.assert(params.indexOf(part.name) === -1, 'Cannot repeat the same parameter name:', part.name, 'in:', path);
params.push(part.name);
finger += '?';
regex = regex + '(.' + (part.empty ? '*' : '+') + ')';
}
}
seg.mixed = new RegExp(regex + '$', !this.settings.isCaseSensitive ? 'i' : '');
fingers.push(finger);
segments.push(seg);
}
}
return {
fingerprint: '/' + fingers.join('/'),
segments: segments,
params: params
};
};
internals.parseParams = function (segment) {
var parts = [];
segment.replace(internals.pathRegex.parseParam, function (match, literal, name, wilcard, count, empty) {
if (literal) {
parts.push(literal);
} else {
parts.push({
name: name,
wilcard: !!wilcard,
count: count && parseInt(count, 10),
empty: !!empty
});
}
return '';
});
return parts;
};
internals.Router.prototype.table = function () {
var result = [];
function collect(table) {
if (!table) {
return;
}
_Object$keys(table).forEach(function (method) {
table[method].routes.forEach(function (record) {
result.push(record.route);
});
});
}
collect(this.routes);
return result;
};
internals.sort = function (a, b) {
var aFirst = -1;
var bFirst = 1;
var as = a.segments;
var bs = b.segments;
if (as.length !== bs.length) {
return as.length > bs.length ? bFirst : aFirst;
}
for (var i = 0;; ++i) {
if (as[i].literal) {
if (bs[i].literal) {
if (as[i].literal === bs[i].literal) {
continue;
}
return as[i].literal > bs[i].literal ? bFirst : aFirst;
}
return aFirst;
} else if (bs[i].literal) {
return bFirst;
}
return as[i].wildcard ? bFirst : aFirst;
}
};
exports.Router = Router;
exports['default'] = Router;