UNPKG

@conglomerate/router

Version:
619 lines (478 loc) 15.9 kB
// // @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;