uri-template-router
Version:
Match a URI to a pattern in a collection of URI Templates
1,125 lines (1,024 loc) • 40.2 kB
JavaScript
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.uriTemplateRouter = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
module.exports.Router = Router;
const { Node, reduce, parallel, union, concat, optional, star, fromString, compare } = require('./lib/fsm.js');
// Export a function that docs/demo.js uses
// FIXME this might be relocated later
module.exports.compare = require('./lib/fsm.js').compare;
const RANGE = {};
RANGE.UNRES = ['-', '.', '0-9', 'A-Z', '_', 'a-z', '~'].join('');
RANGE.GEN_D = [':', '/', '?', '#', '[', ']', '@'].join('');
RANGE.SUB_D = ['!', '$', '&', "'", '(', ')', '*', '+', ',', ';', '='].join('');
RANGE.RESER = [RANGE.GEN_D, RANGE.SUB_D].join('');
RANGE.URI = [RANGE.UNRES, RANGE.RESER].join('');
const regex_sc = /[.*+?^${}()|[\]\\]/g;
function regex_escape(str){
return str.replace(regex_sc, '\\$&');
}
const regex_rangesc = /[\\\]]/g;
function range_regex(str){
return '(?:['+str.replace(regex_rangesc, '\\$&')+']|%[0-9A-Fa-f]{2})';
}
function range_fsm(str, uriTemplate, offset){
return optional([
new Node({[str]: 0, '%': 1}, {[uriTemplate]: new PartialMatch(offset, Value)}, true),
new Node({'0-9A-Fa-f': 2}, {[uriTemplate]: new PartialMatch(offset, Value)}, false),
new Node({'0-9A-Fa-f': 0}, {[uriTemplate]: new PartialMatch(offset, Value)}, false),
]);
}
function encodeURIComponent_v(v){
return encodeURIComponent(v).replace(/!/g, '%21');
}
function Operator(prefix, separator, delimiter, range, named, form){
this.prefix = prefix;
this.separator = separator;
this.delimiter = delimiter;
this.range = range;
this.named = named;
this.form = form;
this.encode = (range===RANGE.URI) ? encodeURI_literal : encodeURIComponent_v;
}
const operators = {
'': new Operator( '', ',', null, RANGE.UNRES, false),
'+': new Operator('', ',', null, RANGE.URI, false),
'#': new Operator('#', ',', null, RANGE.URI, false),
'.': new Operator('.', '.', '.', RANGE.UNRES, false),
'/': new Operator('/', '/', '/', RANGE.UNRES, false),
';': new Operator(';', ';', ';', RANGE.UNRES, true, false),
'?': new Operator('?', '&', '&', RANGE.UNRES, true, true),
'&': new Operator('&', '&', '&', RANGE.UNRES, true, true),
};
// This technique works only because the 2-3rd characters in pct-encoding are also legal characters by themselves
encodeURI_literal.pattern = new RegExp('[^'+RANGE.URI.replace(regex_rangesc, '\\$&')+'%'+']|%(?![0-9A-Fa-f]{2})', 'ug');
function encodeURI_literal(v){
return v.replace(encodeURI_literal.pattern, function(a){
return encodeURIComponent(a);
});
}
module.exports.toViz = toViz;
function toViz(transitions, history){
var highlight = new Set();
if(history){
var start = 0;
history.forEach(function(node){
highlight.add(`${start} ${node.symbol} ${node.nextStateId}`);
start = node.nextStateId;
});
}
var str = '';
str += 'digraph G {\n';
str += '\t_initial [shape=point];\n';
str += '\t_initial -> 0;\n';
transitions.forEach(function(state, id){
const final = state.final ? ' [shape=doublecircle]' : '';
str += '\t'+id+final+';\n';
for(const symbol in state.transitions){
const target = state.transitions[symbol];
const penwidth = highlight.has(`${id} ${symbol} ${target}`) ? ',penwidth=3' : '' ;
str += '\t'+id+' -> '+target+' [label='+JSON.stringify(symbol)+penwidth+'];\n';
}
if(state.final && Array.isArray(state.final)){
str += '\t'+JSON.stringify('final_'+state.final.toString())+' [shape='+JSON.stringify('doublebox')+',label='+JSON.stringify(state.final.toString())+'];\n';
str += '\t'+id+' -> '+JSON.stringify('final_'+state.final.toString())+' [dir=both,arrowtail=odot,arrowhead=o];\n';
}
// if(state.final && Array.isArray(state.final)) state.final.forEach(function(final){
// str += '\t'+JSON.stringify('final_'+final)+' [shape='+JSON.stringify('doublebox')+',label='+JSON.stringify(final.toString())+'];\n';
// str += '\t'+id+' -> '+JSON.stringify('final_'+final)+' [dir=both,arrowtail=odot,arrowhead=o];\n';
// });
// if(state.partials) for(const k in state.partials){
// const partial = state.partials[k];
// str += '\t'+JSON.stringify('final_'+k)+' [shape='+JSON.stringify('doublebox')+',label='+JSON.stringify(k+JSON.stringify(partial))+'];\n';
// str += '\t'+id+' -> '+JSON.stringify('final_'+k)+' [dir=both,arrowtail=odot,arrowhead=o,style=dashed];\n';
// };
});
str += '}\n';
return str;
}
function Operator(prefix, separator, delimiter, range, named, form){
this.prefix = prefix;
this.separator = separator;
this.delimiter = delimiter;
this.range = range;
this.named = named;
this.form = form;
this.encode = (range===RANGE.URI) ? encodeURI_literal : encodeURIComponent_v;
}
function Router(){
this.clear();
}
Router.prototype.clear = function clear(){
this.nid = 0;
this.states = [];
this.routeSet = new Set;
this.templateRouteMap = new Map;
this.valueRouteMap = new Map;
this.hierarchy = {children: []};
};
Router.prototype.hasRoute = function hasRoute(route){
return this.routeSet.has(route);
};
Object.defineProperty(Router.prototype, "size", {
get: function sizeGet(){ return this.routeSet.size; },
});
Object.defineProperty(Router.prototype, "routes", {
get: function routesGet(){ return Array.from(this.routeSet); },
});
Router.prototype.getTemplate = function getTemplate(uriTemplate){
if(typeof uriTemplate !== 'string') throw new Error('Expected string `uriTemplate`');
return this.templateRouteMap.get(uriTemplate);
};
Router.prototype.hasTemplate = function hasTemplate(uriTemplate){
if(typeof uriTemplate !== 'string') throw new Error('Expected string `uriTemplate`');
return this.templateRouteMap.has(uriTemplate);
};
Router.prototype.getValue = function getValue(matchValue){
return this.valueRouteMap.get(matchValue);
};
Router.prototype.hasValue = function hasValue(matchValue){
return this.valueRouteMap.has(matchValue);
};
Router.prototype.toViz = function Router_toViz(){
return toViz(this.states);
};
const Literal = ('Literal');
// const Prefix = ('Prefix');
const Value = ('Value');
module.exports.PartialMatch = PartialMatch;
function PartialMatch(position, type, open, close){
if(!type) throw new Error('Expected a Type');
this.position = position;
this.type = type;
this.open = open || []; // list of groups that open
this.close = close || []; // list of groups that open
}
module.exports.FinalMatch = FinalMatch;
function FinalMatch(route, close){
this.route = route;
this.close = close;
}
FinalMatch.prototype.toString = function toString(){
return '<'+this.route.uriTemplate+'>';
};
var rule_literals = /([\x21\x23-\x24\x26\x28-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E\xA0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFEF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|%[0-9A-Fa-f][0-9A-Fa-f])/;
var rule_varspec = /^([0-9A-Za-z_]|%[0-9A-Fa-f]{2})(\.?([0-9A-Za-z_]|%[0-9A-Fa-f]{2}))*(:[0-9]{0,3}|\*)?$/;
module.exports.Route = Route;
function Route(uriTemplate, options, matchValue){
if(typeof uriTemplate!=='string') throw new Error('Expected `uriTemplate` to be a string');
this.uriTemplate = uriTemplate;
this.options = options;
this.matchValue = matchValue;
// Parse the URI template
var varnames = this.varnames = {};
var variables = this.variables = [];
var tokens = this.tokens = [];
var expressionList = [];
for(var uri_i=0; uri_i<uriTemplate.length; uri_i++){
var chr = uriTemplate[uri_i];
if(chr==='%'){
if(uriTemplate.substring(uri_i, uri_i+3).match(/^%[0-9A-F]{2}$/)){
chr += uriTemplate[uri_i+1] + uriTemplate[uri_i+2];
uri_i += 2;
}else{
throw new Error('Invalid pct-encoded sequence '+JSON.stringify(uriTemplate.substring(uri_i, uri_i+3)));
}
}
if(chr=='{'){
var endpos = uriTemplate.indexOf('}', uri_i+2);
if(endpos<0) throw new Error('Unclosed expression: Expected "}" but found end of template');
var patternBody = uriTemplate.substring(uri_i+1, endpos);
uri_i = endpos;
// If the first character is part of a valid variable name, assume the default operator
// Else, assume the first character is a operator
var operatorChar = patternBody[0].match(/[a-zA-Z0-9_%]/) ? '' : patternBody[0] ;
var operator = operators[operatorChar];
if(!operator){
throw new Error('Unknown expression operator: '+JSON.stringify(operatorChar));
}
const expression = Expression.from(patternBody, expressionList.length);
expression.variableList.forEach(function(varspec){
varspec.index = Object.keys(varnames).length;
varnames[varspec.varname] = varspec;
variables[varspec.index] = varspec;
});
expressionList.push(expression);
tokens.push(expression);
}else if(chr.match(rule_literals)){
if(typeof tokens[tokens.length-1]=='string') tokens[tokens.length-1] += chr;
else tokens.push(chr);
}else{
throw new Error('Unexpected character '+JSON.stringify(chr));
}
}
this.fsm = reduce(this.toFSM());
function partial_intersect(states){
if(states[0]) return states[0].partials;
}
function final_intersect(states){
if(states.every(final => final && (Array.isArray(final) ? final.length : final))){
const items = states.flatMap(final => (final && Array.isArray(final)) ? final : []);
return items.length ? items : true;
}else{
return false;
}
}
if(options && options.parent){
var parent = options.parent;
if(typeof options.parent=='object'){
parent = options.parent;
}else if(typeof options.parent==='string'){
parent = new Route(options.parent);
}else{
throw new Error('Unknown type for parent');
}
this.fsm = parallel([this.fsm, parent.fsm], partial_intersect, final_intersect);
}
}
Route.prototype.gen = function Route_gen(params){
if(typeof params!='object') throw new Error('Expected arguments[0] `params` to be an object');
return this.tokens.map( (v)=>v.toString(params) ).join('');
};
Route.prototype.toString = function toString(params){
return this.tokens.map( (v)=>v.toString(params) ).join('');
};
Route.prototype.compare = function routecompare(other){
return compare([this.fsm || this.toFSM(), other.fsm || other.toFSM()]);
}
Route.prototype.toJSON = function toJSON(){
return this.uriTemplate;
};
Route.prototype.toFSM = function toFSM(){
const route = this;
var template_i = 0;
// Get the FSM of each of the tokens, and concatenate them together
const fsms = route.tokens.map(function addExpression(expression){
// If a string, treat as literal characters
if(typeof expression=='string'){
const offset = template_i;
template_i += expression.length;
return fromString(expression, v=>({[route.uriTemplate]:new PartialMatch((offset+v), Literal)}));
}
return expression.toFSM(route.uriTemplate, template_i);
});
return concat(fsms);
// return reduce(concat(fsms));
};
Route.prototype.toRegex = function toRegex(){
const regex_str = this.tokens.map(function(segment){
if(typeof segment==='string'){
return regex_escape(segment);
}else{
return segment.toRegex().source;
}
}).join('');
return new RegExp('^'+regex_str+'$', 'u');
};
Route.prototype.decode = function decode(uri){
const regex = this.toRegex();
const match = uri.match(regex);
if(!match) return;
var offset = 1;
const result = {};
for(var i=0; i<this.tokens.length; i++){
const segment = this.tokens[i];
// This segment is not an expression, there's nothing to parse here
if(typeof segment === 'string') continue;
for(var j=0; j<segment.variableList.length; j++){
const varname = segment.variableList[j].varname;
const value = match[offset++];
if(typeof value === 'string'){
if(segment.variableList[j].explode){
// If the variable is exploded, split it apart by the separator since toRegex matched it as a single string
if(segment.variableList[j].named){
// Also if this is a named variable, the string includes "varname=" in each segment
result[varname] = value.split(segment.separator).map(decodeURIComponent).map( v => v.replace(new RegExp('^'+regex_escape(segment.variableList[j].varname)+'=', 'ug'), '') );
}else{
result[varname] = value.split(segment.separator).map(decodeURIComponent);
}
}else if(value){
result[varname] = decodeURI(value);
}
}
}
}
return result;
}
module.exports.Expression = Expression;
function Expression(operatorChar, variableList, index){
if(typeof operatorChar !== 'string') throw new Error('Expected `operatorChar` to be a string');
if(!operators[operatorChar]) throw new Error('Unknown operator: '+JSON.stringify(operatorChar));
variableList.forEach(function(v){
if(!(v instanceof Variable)) throw new Error('Expected `variableList` to be array of Variable instances');
});
this.operatorChar = operatorChar;
this.prefix = operators[operatorChar].prefix;
this.separator = operators[operatorChar].separator;
this.range = operators[operatorChar].range;
this.variableList = variableList;
this.index = index;
}
Expression.from = function(patternBody, index){
// If the first character is part of a valid variable name, assume the default operator
// Else, assume the first character is a operator
var operatorChar = patternBody[0].match(/[a-zA-Z0-9_%]/) ? '' : patternBody[0] ;
var operator = operators[operatorChar];
if(!operator){
throw new Error('Unknown expression operator: '+JSON.stringify(operator));
}
const variableList = patternBody
.substring(operatorChar.length)
.split(/,/g)
.map( Variable.from.bind(null, operatorChar) );
return new Expression(operatorChar, variableList, index);
};
Expression.prototype.toString = function toString(params){
const operator = operators[this.operatorChar];
if(params){
const values = this.variableList.map( (v)=>v.expand(params) ).filter( (v)=>(typeof v==='string') );
if(values.length){
return operator.prefix + values.join(operator.separator);
}else{
return '';
}
}else{
// toString will join the Variable#toString() values with commas
return '{' + this.operatorChar + this.variableList.toString() + '}';
}
};
Expression.prototype.toFSM = function toFSM(uriTemplate, offset){
var offset_i = offset;
const fsm_0 = [];
for(var i=0; i<this.variableList.length; i++){
if(i==0 && this.prefix){
fsm_0.push(concat([ fromString(this.prefix), this.variableList[i].toFSM(uriTemplate, offset_i) ]));
offset_i += 1;
}else if(i>0 && this.separator){
fsm_0.push(concat([ fromString(this.separator), this.variableList[i].toFSM(uriTemplate, offset_i) ]));
offset_i += 1;
}else{
fsm_0.push(this.variableList[i].toFSM(uriTemplate, offset_i));
}
offset_i += this.variableList[i].varname.length;
}
return optional(concat(fsm_0));
};
Expression.prototype.toRegex = function toRegex(){
var fsm_0 = '';
if(this.prefix){
fsm_0 += regex_escape(this.prefix);
}
for(var i=0; i<this.variableList.length; i++){
if(i>0 && this.separator){
fsm_0 += '(?:' + regex_escape(this.separator) + this.variableList[i].toRegex().source + ')?';
}else{
fsm_0 += this.variableList[i].toRegex().source;
}
}
// The entire expression is optional
return new RegExp('(?:'+fsm_0+')?', 'u');
}
module.exports.Variable = Variable;
function Variable(operatorChar, varname, explode, maxLength){
if(typeof varname !== 'string') throw new Error('Expected `varname` to be a string');
if(typeof operatorChar !== 'string') throw new Error('Expected `operatorChar` to be a string');
const operator = operators[operatorChar];
if(!operators[operatorChar]) throw new Error('Expected `operator` to be a valid operator');
if(typeof explode !== 'boolean') throw new Error('Expected `explode` to be a boolean');
if(maxLength!==null && typeof maxLength !== 'number') throw new Error('Expected `maxLength` to be a number');
this.operatorChar = operatorChar;
this.varname = varname;
this.explode = explode;
this.maxLength = maxLength;
this.optional = true;
this.prefix = operator.prefix;
this.separator = operator.separator;
this.delimiter = operator.delimiter;
this.range = operator.range;
this.named = operator.named;
}
Variable.from = function(operatorChar, varspec){
if(!varspec.match(rule_varspec)){
throw new Error('Malformed variable '+JSON.stringify(varspec));
}
const separator = operators[operatorChar];
// Test for explode operator
const explode = !!varspec.match(/\*$/);
const varnameMaxLength = explode ? varspec.substring(0, varspec.length-1) : varspec;
if(explode && !separator){
throw new Error('Variable operator '+JSON.stringify(operatorChar)+' does not work with explode modifier');
}
// Test for substring modifier
const varnameMaxLength_i = varnameMaxLength.indexOf(':');
const varname = varnameMaxLength_i<0 ? varnameMaxLength : varnameMaxLength.substring(0, varnameMaxLength_i);
const maxLengthStr = varnameMaxLength_i<0 ? null : varnameMaxLength.substring(varnameMaxLength_i+1);
const maxLength = maxLengthStr ? parseInt(maxLengthStr, 10) : null;
return new Variable(
operatorChar,
varname,
explode,
maxLength,
);
};
Variable.prototype.toString = function(params){
if(params) return this.expand(params);
return this.varname +
(this.explode ? '*' : '') +
(typeof this.maxLength==='number' ? ':'+this.maxLength : '');
};
Variable.prototype.expand = function(params){
const t = this;
const op = operators[t.operatorChar];
const varvalue = params[t.varname];
const encode = op.encode;
if(typeof varvalue=='string' || typeof varvalue=='number'){
let value = varvalue;
if(t.maxLength) value = value.substring(0, t.maxLength);
if(op.named){
if(op.form || value) return t.varname + '=' + encode(value);
else return t.varname;
}else{
return encode(value);
}
}else if(Array.isArray(varvalue) && varvalue.length>0){
if(t.explode){
const items = varvalue.map(function(value){
if(t.maxLength) value = value.toString().substring(0, t.maxLength);
if(op.named){
if(op.form || value) return t.varname + '=' + encode(value);
else return t.varname;
}else{
return encode(value);
}
});
return items.length ? items.join(t.separator) : null;
}else{
let value = varvalue;
if(t.maxLength) value = value.substring(0, t.maxLength);
if(value.length===0) return null;
if(op.named){
return t.varname + '=' + value.map(function(v){ return encode(v); }).join(',');
}else{
return value.map(function(v){ return encode(v); }).join(',');
}
}
}else if(typeof varvalue == 'object' && varvalue){
if(t.maxLength){
throw new Error('Cannot substring object');
}
if(t.explode){
// Apparently op.named doesn't matter in this case
const items = Object.keys(varvalue).map(function(key){
if(op.form || varvalue[key]) return encode(key) + '=' + encode(varvalue[key]);
else return key;
});
return items.length ? items.join(t.separator) : null;
}else{
if(op.named){
const items = Object.keys(varvalue).map(function(key){
return encode(key) + ',' + encode(varvalue[key]);
});
return items.length ? t.varname + '=' + items.join(',') : null;
}else{
const items = Object.keys(varvalue).map(function(key){
return encode(key) + ',' + encode(varvalue[key]);
});
return items.length ? items.join(',') : null;
}
}
}
return null;
};
Variable.prototype.toFSM = function toFSM(uriTemplate, offset){
const op = operators[this.operatorChar];
const fsm = range_fsm(this.range, uriTemplate, offset);
if(this.explode){
if(op.named){
return optional(concat([concat([fromString(this.varname), optional(concat([fromString('='), fsm]))]), star(concat([fromString(this.separator), fromString(this.varname), optional(concat([fromString('='), fsm]))]))]));
}else{
return optional(concat([fsm, star(concat([fromString(this.separator), fsm]))]));
}
}else if(op.named){
return optional(concat([fromString(this.varname), optional(concat([fromString('='), fsm]))]));
}else{
return fsm;
}
}
Variable.prototype.toRegex = function toRegex(){
const op = operators[this.operatorChar];
if(this.explode){
if(op.named){
return new RegExp('((?:'+regex_escape(this.varname)+'(?:=('+range_regex(this.range)+'*)))(?:'+this.separator+'(?:'+regex_escape(this.varname)+'(='+range_regex(this.range)+'*)))*)?', 'u');
}else{
// Include the separator in the range, we will split() it later
return new RegExp('('+range_regex(this.range+this.separator)+'*)', 'u');
}
}else if(op.named){
return new RegExp('(?:'+regex_escape(this.varname)+'(?:=('+range_regex(this.range)+'*))?)?', 'u');
}else{
return new RegExp('('+range_regex(this.range)+'*)', 'u');
}
}
module.exports.Result = Result;
function Result(router, uri, options, history, final_states){
const final_match = final_states[0];
if(!final_match) throw new Error();
const route = final_match.route;
this.router = router;
this.uri = uri;
this.options = options;
this.route = route;
this.uriTemplate = route.uriTemplate;
this.matchValue = route.matchValue;
this.params = route.decode(this.uri);
this.history = history;
this.final_states = final_states;
}
Result.prototype.rewrite = function rewrite(uriTemplate, options, name){
if(typeof uriTemplate==='string'){
uriTemplate = new Route(uriTemplate, options, name);
}
var uri = uriTemplate.gen(this.params);
return new Result(this.router, uri, options, [], [ { route: uriTemplate } ]);
};
Result.prototype.next = function next(){
// return this.router.resolveURI(this.uri, this.options, this.remaining_state);
// With all of the characters parsed, the current "state" contains the solution
const remaining_states = this.final_states.slice(1);
// ... If it lists one
if(!remaining_states.length) return;
return new Result(this.router, this.uri, this.options, this.history, remaining_states);
};
Object.defineProperty(Result.prototype, "template", {
get: function templateGet(){ return this.uriTemplate; },
set: function templateSet(v){ this.uriTemplate = v; },
});
Object.defineProperty(Result.prototype, "name", {
get: function templateGet(){ return this.matchValue; },
});
Router.prototype.addTemplate = function addTemplate(uriTemplate, options, matchValue){
if(typeof uriTemplate=='object' && options===undefined && matchValue===undefined){
var route = uriTemplate;
uriTemplate = route.uriTemplate;
options = route.options;
matchValue = route.matchValue;
}else{
route = new Route(uriTemplate, options, matchValue);
}
// Verify the template doesn't re-use a variable name
const varnames = new Set;
route.tokens.forEach(function(token){
if(typeof token === 'string') return;
token.variableList.forEach(function(varspec){
if(varnames.has(varspec.varname)){
throw new Error('Duplicate variable name '+varspec.varname);
}
varnames.add(varspec.varname);
});
});
const fsm = route.fsm;
fsm.forEach(function(state){
if(!state.partials[uriTemplate]){
// state.partials[uriTemplate] = new PartialMatch(0, 9);
}
if(state.final){
state.final = [new FinalMatch(route)];
}
});
this.routeSet.add(route);
this.templateRouteMap.set(uriTemplate, route);
if(!this.valueRouteMap.has(matchValue)){
this.valueRouteMap.set(matchValue, route);
}
// Update the route tree that maintains ordering
// For every node, all of its children must be disjoint
// Scan through each of the children:
children: for(var current=this.hierarchy; current;){
const compares = current.children.map(v => compare([fsm, v.node.fsm]));
// 1. if new route is a subset of exactly one of them, then descend into that child.
for(var i=0; i<compares.length; i++){
if(compares[i][0]===false && compares[i][1]===true){
current = current.children[i];
continue children;
}
}
// 2. if the new route is a superset of any number of them (and disjoint with all others), then insert new route as a child and move all matching children underneath it.
const route_siblings = [], route_children = [];
for(var i=0; i<compares.length; i++){
if(compares[i][0]===true){
// Move subsets into this route
route_children.push(current.children[i]);
}else if(compares[i][2]===true){
// Record disjoint nodes and make them siblings
route_siblings.push(current.children[i]);
}else{
throw new Error('Inserted route '+uriTemplate+' partially overlaps with other routes '+current.children[i].uriTemplate);
}
}
route_siblings.push({node: route, uriTemplate, children: route_children});
current.children = route_siblings;
break;
}
this.reindex();
return route;
};
Router.prototype.reindex = function reindex(){
// Update the sort index based on the hierarchy
const order_map = new Map;
var order = 0;
function visit(hierarchy){
hierarchy.children.forEach(visit);
if(hierarchy.node) order_map.set(hierarchy.node, order++);
}
visit(this.hierarchy);
this.states = union(this.routes.map(r => r.fsm), order_map);
};
// like resolveString, but additionally verify that the URI matches the legal HTTP form
// userinfo and fragment components are not allowed
// Router.prototype.resolveRequest = function resolveRequest(scheme, host, target, flags, initial_state){
// };
// like resolveString, but additionally verify that the URI matches the legal HTTP form
// userinfo and fragment components are not allowed
Router.prototype.resolveRequestURI = function resolveRequestURI(uri, flags, initial_state){
if(typeof uri!=='string') throw new Error('Expected arguments[0] `uri` to be a string');
// First verify the URI looks OK, save the components, then parse it normally
// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
const scheme_m = uri.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
if(!scheme_m) throw new Error('parseURI: `uri` missing valid scheme');
// const hierpart_m = uri.substring(scheme_m[0].length).match(/^\/\/(?:\x5b(?:[\x2e0-:a-f]*|v[0-9a-f]+\x2e[!\x24&-\x2e0-;=_a-z~]+)\x5d|(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\x2e(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\x2e(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])\x2e(?:\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])|(?:[\x2d\x2e0-9_a-z~]|%[0-9a-f][0-9a-f]|[!\x24&-,;=])*)(?::\d*)?/);
// URI appears to be valid, now resolve it normally
return this.resolveURI(uri, flags, initial_state);
};
// TODO rename this to `resolveString`
Router.prototype.resolveURI = function resolveString(uri, flags){
if(typeof uri!=='string') throw new Error('Expected arguments[0] `uri` to be a string');
const self = this;
// 0 is the initial state
var state = this.states[0];
const history = [{state}];
const pctenc = /^%[0-9A-F]{2}$/;
for(var offset = 0; state && offset < uri.length; offset++){
if(!state) break;
const symbol = uri[offset];
// Double-check that pct-encoded sequences are valid (in addition to what the FSM should prohibit)
if(symbol==='%'){
if(!pctenc.test(uri.substring(offset, offset+3))){
return;
}
}
const nextStateId = state.get(symbol);
if(nextStateId === undefined) return;
state = this.states[nextStateId];
history.push({symbol, nextStateId, state});
}
// With all of the characters parsed, the current "state" contains the solution
const solution = state.final[0];
if(!solution) return;
return new Result(self, uri, flags, history, state.final);
};
},{"./lib/fsm.js":2}],2:[function(require,module,exports){
'use strict';
module.exports.range = range;
function *range(str){
for(var i=0; i<str.length; i++){
const chr = str[i];
if(chr !== '-' && str[i+1]==='-' && str[i+2]){
for(var j=str.charCodeAt(i), end=str.charCodeAt(i+2); j<=end; j++){
yield String.fromCharCode(j);
}
i += 2;
}else{
yield chr;
}
}
}
const rangeSets = new Map;
// A node on the tree is a list of various options to try to match against an input character.
// The "next" and "list_set" options specify another branch to also try and match against the current input character.
// The "template_match" option specifies the end of the template was reached, and to return a successful match result. This is usually only reachable immediately after matching an EOF.
module.exports.Node = Node;
function Node(transitions, partials, final){
if(typeof partials === 'number') throw new Error;
partials = partials || {};
this.transitions = transitions || {};
// if(Object.keys(partials).length===0){
// throw new Error('Expected partial match info');
// }
// Maps final state -> information about the meaning of this transition given the final state
this.partials = partials || {};
// If we reach this branch, declare a match for this template
this.final = final || false;
this.classes = Object.keys(transitions).filter(v => v.length>1).sort((a,b) => a.length-b.length);
}
Node.prototype.get = function get(chr){
if(this.transitions[chr] !== undefined){
return this.transitions[chr];
}
for(const tr of this.classes){
if(!rangeSets.has(tr)){
rangeSets.set(tr, new Set(range(tr)));
}
if(rangeSets.get(tr).has(chr)){
return this.transitions[tr];
}
}
// FIXME this is a huge hack that does not scale to multiple character classes
if(chr === "-.0-9A-Z_a-z~" && this.transitions["-.0-9A-Z_a-z~:/?#[]@!$&'()*+,;="]!==undefined){
return this.transitions["-.0-9A-Z_a-z~:/?#[]@!$&'()*+,;="];
}
};
module.exports.verify = verify;
function verify(fsm){
if(!Array.isArray(fsm)){
throw new Error('Expected `fsm` to be an Array');
}
fsm.forEach(function(st, i){
if(typeof st !== 'object'){
throw new Error('Expected fsm[i] to be an object');
}
for(var symbol in st.transitions){
if(typeof st.transitions[symbol] !== 'number'){
throw new Error('Expected fsm['+JSON.stringify(i)+']['+JSON.stringify(symbol)+'] to be a number, got '+typeof st[symbol]);
}
if(st.transitions[symbol] >= fsm.length){
throw new Error('Expected fsm['+JSON.stringify(i)+']['+JSON.stringify(symbol)+'] to be a state in `fsm`');
}
}
});
}
module.exports.union = union;
function union(fsms, ordering){
function partial_union(states){
const map = {};
for(var i=0; i<states.length; i++){
if(states[i]===undefined) continue;
for(var k in states[i].partials){
map[k] = states[i].partials[k];
}
}
return map;
}
function final_union(states){
function sort_final(a, b){
return ordering.get(a.route) - ordering.get(b.route);
}
if(states.some(final => final && (Array.isArray(final) ? final.length : final))){
const items = states.flatMap(final => (final && Array.isArray(final)) ? final : []);
if(ordering) items.sort(sort_final);
return items.length ? items : true;
}else{
return false;
}
}
return parallel(fsms, partial_union, final_union);
}
module.exports.parallel = parallel;
function parallel(fsms, partial, final){
if(!Array.isArray(fsms)) throw new Error('Expected `fsms` to be an array of arrays');
fsms.forEach(verify);
// By convention, start on the 0 state
const cross_product_list = [ fsms.map(v=>0) ];
// A handy mapping of each cross-product state to its new state
const cross_product_map = new Map([[fsms.map(v=>0).join(','), 0]]);
// The new states
const combination_states = [];
// iterate over a growing list
for(var i=0; i<cross_product_list.length; i++){
const state_i = cross_product_list[i];
const state = state_i.map( (i,j) => fsms[j][i] );
// Compute the symbols used by each state
// const alphabet = new Set(fsms.flatMap(v => [...v.transitions.keys()]));
const alphabet = new Set(state.flatMap(fsm_st => fsm_st ? Object.keys(fsm_st.transitions) : []));
// compute map for this state
const transitions = {};
for(const symbol of alphabet){
const next = state.map(function(fsm_st){
// Returning undefined is OK
return fsm_st && fsm_st.get(symbol);
});
// Generate a key name for this cross-product
const nextKey = next.join(',');
const nextId = cross_product_map.get(nextKey);
if(nextId !== undefined){
// If there is already a state representing this cross-product, point to that
transitions[symbol] = nextId;
}else{
// Create a new state
transitions[symbol] = cross_product_list.length;
cross_product_map.set(nextKey, cross_product_list.length);
cross_product_list.push(next);
}
}
combination_states[i] = new Node(transitions, partial(state), final(state.map(v => v && v.final)));
}
return combination_states;
}
module.exports.concat = concat;
function concat(fsms){
if(!Array.isArray(fsms)) throw new Error('Expected `fsms` to be an array of arrays');
fsms.forEach(verify);
function connect_all(fsm_i, substate){
/*
Take a state in the numbered FSM and return a set containing it, plus
(if it's final) the first state from the next FSM, plus (if that's
final) the first state from the next but one FSM, plus...
*/
const result = [ [fsm_i, substate] ];
for(var i=fsm_i; i<fsms.length-1 && fsms[i][substate].final; i++){
result.push([i+1, 0]);
substate = 0;
}
// TODO Ignore states that have no outgoing transitions
return result.sort();
}
// Maps new state ids to one of the items in the powerset
// Start with the first fsm's (0) initial state (0)
const powerset_list = [ connect_all(0, 0) ];
// A handy mapping of each cross-product state to its new state
const powetset_id_map = new Map([['0,0', 0]]);
// The new fsm after concatenation
const concat_states = [];
// iterate over a growing list
for(var i=0; i<powerset_list.length; i++){
const powetset_item = powerset_list[i];
const state = powetset_item.map( ([j,i])=>[j, fsms[j][i]] );
// Compute the symbols used by each state
const alphabet = new Set(state.flatMap(fsm_st => Object.keys(fsm_st[1].transitions)));
// compute map for this state
const transitions = {};
for(const symbol of alphabet){
const next_all = state.flatMap(function(fsm_st){
const [fsm_i, fsm_node] = fsm_st;
// returning undefined is OK
if(fsm_node.get(symbol) !== undefined){
return connect_all(fsm_i, fsm_node.get(symbol));
}
return [];
}).sort();
// Remove duplicates
var previous='', next = next_all.filter(function(v){ return (previous.toString()!==(previous=v).toString()); });
if(!next.length) continue;
// Generate a key name for this cross-product
const nextKey = next.join(',');
const nextId = powetset_id_map.get(nextKey);
if(nextId !== undefined){
// Use an existing state representing this cross-product, if available
transitions[symbol] = nextId;
}else{
// Create a state for a new cross-product combination
transitions[symbol] = powerset_list.length;
powetset_id_map.set(nextKey, powerset_list.length);
powerset_list.push(next);
}
}
const partial_matches = Object.fromEntries(state.flatMap(function(v){
const [_, part] = v;
return part ? Object.entries(part.partials) : [];
}));
const final = powetset_item.some(function(state_val){
const [fsm_i, substate] = state_val;
if(fsms[fsm_i][substate].final.length===0) throw new Error('Zero-length final');
return fsm_i==fsms.length-1 && fsms[fsm_i][substate].final;
});
concat_states[i] = new Node(transitions, partial_matches, final);
}
return concat_states;
}
module.exports.fromString = fromString;
function fromString(expression, partial){
const fsm = [];
for(var i=0; i<expression.length; i++){
var chr = expression[i];
fsm.push(new Node({[chr]:fsm.length+1}, partial && partial(i)));
}
fsm.push(new Node({}, {}, true));
return fsm;
}
module.exports.optional = optional;
function optional(f){
const epsilon = [ new Node({}, {}, true) ];
return union([f, epsilon]);
}
module.exports.star = star;
function star(f){
const alphabet = f.flatMap(state => Object.keys(state.transitions));
const initial = [0];
function follow(current, symbol){
const next = new Set();
current.forEach(function(substate){
if(f[substate] && f[substate].get(symbol)){
next.add(f[substate].get(symbol));
}
// If one of our substates is final, then we can also consider
// transitions from the initial state of the original FSM.
if(f[substate].final && f[0].get(symbol)){
next.add(f[0].get(symbol));
}
});
if(next.size === 0) return;
return [...next].sort();
}
function final(state){
return state.some( (substate)=> f[substate].final );
}
const states_list = [initial];
const states_map = new Map([[initial.toString(), 0]]);
const states = [];
// iterate over a growing list
for(var i=0; i<states_list.length; i++){
const state = states_list[i];
// compute map for this state
const transitions = {};
for(const symbol of alphabet){
const next = follow(state, symbol);
if(next===undefined) continue;
const nextKey = next.join(',');
const nextId = states_map.get(nextKey);
if(nextId !== undefined){
// If there is already a state representing this cross-product, point to that
transitions[symbol] = nextId;
}else{
// Create a new state
transitions[symbol] = states_list.length;
states_map.set(nextKey, states_list.length);
states_list.push(next);
}
}
states[i] = new Node(transitions, {}, final(state));
}
return optional(states);
}
// This probably isn't needed
module.exports.reverse = reverse;
function reverse(f){
const alphabet = f.flatMap(state => Object.keys(state.transitions));
const initial = f.map((v, i) => v.final ? i : undefined).filter(v => v!==undefined);
const powerset_list = [initial];
const powerset_stateid_map = new Map([[initial.join(','), 0]]);
const states = []; // states[0] will be filled in by the first iteration of this loop
// iterate over a growing list
for(var i=0; i<powerset_list.length; i++){
const powerset_combination = powerset_list[i];
// compute map for this state
const transitions = {};
for(const symbol of alphabet){
const next = f.map(function(state, i){
return powerset_combination.some(function(state0){
return state.get(symbol)===state0;
}) ? i : undefined;
}).filter(v => (v!==undefined)).sort();
if(next.length===0) continue;
const nextKey = next.join(',');
const nextIdx = powerset_stateid_map.get(nextKey);
if(nextIdx !== undefined){
// If there is already a state with identical transitions, point to that
transitions[symbol] = nextIdx;
}else{
// Create a new state
transitions[symbol] = powerset_list.length;
powerset_stateid_map.set(nextKey, powerset_list.length);
powerset_list.push(next);
}
}
// Trim character transitions that are the same as the group transition
for(const tr in transitions){
if(tr.length <= 1) continue;
var shadowed = true;
for(const chr of range(tr)){
if(transitions[chr]===transitions[tr]){
delete transitions[chr];
}
if(!transitions[chr]){
shadowed = false;
}
}
if(shadowed){
delete transitions[tr];
}
}
states[i] = new Node(transitions, {}, powerset_combination.indexOf(0) >= 0);
}
return states;
}
module.exports.reduce = reduce;
function reduce(f){
return reverse(reverse(f));
}
module.exports.compare = compare;
function compare(fsms){
if(fsms.length !== 2){
throw new Error('Expected 2 fsms to compare');
}
var isSuperset = true, isSubset = true, isDisjoint = true;
function partial_union(states){
}
function final_union(states){
const a = Array.isArray(states[0]) ? states[0].length : states[0];
const b = Array.isArray(states[1]) ? states[1].length : states[1];
if(!a && b) isSuperset = false;
if(a && !b) isSubset = false;
if(!!a && !!b) isDisjoint = false; // set to false when there are some final elements in common
}
parallel(fsms, partial_union, final_union);
return [isSuperset, isSubset, isDisjoint];
}
},{}]},{},[1])(1)
});