1 /** 2 * <p>RouteMap holds an internal table of route patterns and method names in addition to some 3 * adding/removing/utility methods and a handler for request routing.</p> 4 * <p>It does not have any dependencies and is written in "plain old" JS, but it does require JS 1.8 array methods, so 5 * if the environment it will run in does not have those, the reference implementations from 6 * <a href="https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/">Mozilla</a> should be 7 * supplied external to this library.</p> 8 * <p>It is designed to be used in both a browser setting and a server-side context (for example in node.js).</p> 9 * <strong>LICENSING INFORMATION:</strong> 10 * <blockquote><pre> 11 * Copyright 2011 OpenGamma Inc. and the OpenGamma group of companies 12 * Licensed under the Apache License, Version 2.0 (the "License"); 13 * you may not use this file except in compliance with the License. 14 * You may obtain a copy of the License at 15 * 16 * http://www.apache.org/licenses/LICENSE-2.0 17 * 18 * Unless required by applicable law or agreed to in writing, software 19 * distributed under the License is distributed on an "AS IS" BASIS, 20 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 * See the License for the specific language governing permissions and 22 * limitations under the License. 23 * </pre></blockquote> 24 * @see <a href="http://www.opengamma.com/">OpenGamma</a> 25 * @see <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache License, Version 2.0</a> 26 * @see <a href="https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/">Mozilla Developer 27 * Network</a> 28 * @name RouteMap 29 * @namespace RouteMap 30 * @author Afshin Darian 31 * @static 32 * @throws {Error} if JS 1.8 Array.prototype methods don't exist 33 */ 34 (function (pub, namespace) { // defaults to exports, uses window if exports does not exist 35 (function (arr, url) { // plain old JS, but needs some JS 1.8 array methods 36 if (!arr.every || !arr.filter || !arr.indexOf || !arr.map || !arr.reduce || !arr.some || !arr.forEach) 37 throw new Error('See ' + url + ' for reference versions of Array.prototype methods available in JS 1.8'); 38 })([], 'https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/'); 39 var routes /* internal reference to RouteMap */, active_routes = {}, added_routes = {}, flat_pages = [], 40 last = 0, current = 0, encode = encodeURIComponent, decode = decodeURIComponent, has = 'hasOwnProperty', 41 EQ = '=' /* equal string */, SL = '/' /* slash string */, PR = '#' /* default prefix string */, 42 token_exp = /\*|:|\?/, star_exp = /(^([^\*:\?]+):\*)|(^\*$)/, scalar_exp = /^:([^\*:\?]+)(\??)$/, 43 keyval_exp = /^([^\*:\?]+):(\??)$/, slash_exp = new RegExp('([^' + SL + '])$'), 44 context = typeof window !== 'undefined' ? window : {}, // where listeners reside, routes.context() overwrites it 45 /** @ignore */ 46 invalid_str = function (str) {return typeof str !== 'string' || !str.length;}, 47 /** @ignore */ 48 fingerprint = function (rule) {return [rule.method, rule.route].join('|');}, 49 /** 50 * merges one or more objects into a new object by value (nothing is a reference), useful for cloning 51 * @name RouteMap#merge 52 * @inner 53 * @function 54 * @type Object 55 * @returns {Object} a merged object 56 * @throws {TypeError} if one of the arguments is not a mergeable object (i.e. a primitive, null or array) 57 */ 58 merge = function () { 59 var self = 'merge', to_string = Object.prototype.toString, clone = function (obj) { 60 return typeof obj !== 'object' || obj === null ? obj // primitives 61 : to_string.call(obj) === '[object Array]' ? obj.map(clone) // arrays 62 : merge(obj); // objects 63 }; 64 return Array.prototype.reduce.call(arguments, function (acc, obj) { 65 if (!obj || typeof obj !== 'object' || to_string.call(obj) === '[object Array]') 66 throw new TypeError(self + ': ' + to_string.call(obj) + ' is not mergeable'); 67 for (var key in obj) if (obj[has](key)) acc[key] = clone(obj[key]); 68 return acc; 69 }, {}); 70 }, 71 /** 72 * parses a path and returns a list of objects that contain argument dictionaries, methods, and raw hash values 73 * @name RouteMap#parse 74 * @inner 75 * @function 76 * @param {String} path 77 * @type Array 78 * @returns {Array} a list of parsed objects in descending order of matched hash length 79 * @throws {TypeError} if the method specified by a rule specification does not exist during parse time 80 */ 81 parse = function (path) { 82 // go with the first matching page (longest) or any pages with * rules 83 var self = 'parse', pages = flat_pages.filter(function (val) { // add slash to paths so all vals match 84 return path.replace(slash_exp, '$1' + SL).indexOf(val) === 0; 85 }) 86 .filter(function (page, index) { 87 return !index || active_routes[page].some(function (val) {return !!val.rules.star;}); 88 }); 89 return !pages.length ? [] : pages.reduce(function (acc, page) { // flatten parsed rules for all pages 90 var current_page = active_routes[page].map(function (rule_set) { 91 var args = {}, scalars = rule_set.rules.scalars, keyvals = rule_set.rules.keyvals, method, 92 // populate the current request object as a collection of keys/values and scalars 93 request = path.replace(page, '').split(SL).reduce(function (acc, val) { 94 var split = val.split(EQ), key = split[0], value = split.slice(1).join(EQ); 95 return !val.length ? acc // discard empty values, separate rest into scalars or keyvals 96 : (value ? acc.keyvals[key] = value : acc.scalars.push(val)), acc; 97 }, {keyvals: {}, scalars: []}), star, keyval, 98 keyval_keys = keyvals.reduce(function (acc, val) {return (acc[val.name] = 0) || acc;}, {}), 99 required_scalars_length = scalars.filter(function (val) {return val.required;}).length, 100 required_keyvals = keyvals.filter(function (val) {return val.required;}) 101 .every(function (val) {return request.keyvals[has](val.name);}); 102 // not enough parameters are supplied in the request for this rule 103 if (required_scalars_length > request.scalars.length || !required_keyvals) return 0; 104 if (!rule_set.rules.star) { // too many params are only a problem if the rule isn't a wildcard 105 if (request.scalars.length > scalars.length) return 0; // if too many scalars are supplied 106 for (keyval in request.keyvals) // if too many keyvals are supplied 107 if (request.keyvals[has](keyval) && !keyval_keys[has](keyval)) return 0; 108 } 109 request.scalars.slice(0, scalars.length) // populate args scalars 110 .forEach(function (scalar, index) {args[scalars[index].name] = decode(scalar);}); 111 keyvals.forEach(function (keyval) { // populate args keyvals 112 if (request.keyvals[keyval.name]) args[keyval.name] = decode(request.keyvals[keyval.name]); 113 delete request.keyvals[keyval.name]; // remove so that * can be constructed 114 }); 115 if (rule_set.rules.star) { // all unused scalars and keyvals go into the * argument (still encoded) 116 star = request.scalars.slice(scalars.length, request.scalars.length); 117 for (keyval in request.keyvals) if (request.keyvals[has](keyval)) 118 star.push([keyval, request.keyvals[keyval]].join(EQ)); 119 args[rule_set.rules.star] = star.join(SL); 120 } 121 try { // make sure the rule's method actually exists and can be accessed 122 method = rule_set.method.split('.').reduce(function (acc, val) {return acc[val];}, context); 123 if (typeof method !== 'function') throw new Error; 124 } catch (error) { 125 throw new TypeError(self + ': ' + rule_set.method + ' is not a function in current context'); 126 } 127 return {page: page, hash: routes.hash({route: rule_set.raw}, args), method: method, args: args}; 128 }); 129 return acc.concat(current_page).filter(Boolean); // only return the parsed rules that matched 130 }, []).sort(function (a, b) {return b.hash.length - a.hash.length;}); // order in descending hash length 131 }, 132 /** 133 * builds the internal representation of a rule based on the route definition 134 * @inner 135 * @name RouteMap#compile 136 * @function 137 * @param {String} route 138 * @throws {SyntaxError} if any portion of a rule definition follows a <code>*</code> directive 139 * @throws {SyntaxError} if a required scalar follows an optional scalar 140 * @throws {SyntaxError} if a rule cannot be parsed 141 * @type {Object} 142 * @returns {Object} a compiled object, for example, the rule <code>'/foo/:id/type:?/rest:*'</code> would return 143 * an object of the form: <blockquote><pre>{ 144 * page:'/foo', 145 * rules:{ 146 * keyvals:[{name: 'type', required: false}], 147 * scalars:[{name: 'id', required: true}], 148 * star:'rest' // false if not defined 149 * } 150 * } 151 * @see RouteMap.add 152 * @see RouteMap.hash 153 * @see RouteMap.remove 154 */ 155 compile = (function (memo) { // compile is slow so cache compiled objects in a memo 156 return function (orig) { 157 var self = 'compile', compiled, index, names = {}, 158 route = orig[0] === SL ? orig : ~(index = orig.indexOf(SL)) ? orig.slice(index) : 0, 159 /** @ignore */ 160 valid_name = function (name) { 161 if (names[has](name) || (names[name] = 0)) 162 throw new SyntaxError(self + ': "' + name + '" is repeated in: ' + orig); 163 }; 164 if (!route) throw new SyntaxError(self + ': the route ' + orig + ' was not understood'); 165 if (memo[route]) return memo[route]; 166 compiled = route.split(SL).reduce(function (acc, val) { 167 var rules = acc.rules, scalars = rules.scalars, keyvals = rules.keyvals; 168 if (rules.star) throw new SyntaxError(self + ': no rules can follow a * directive in: ' + orig); 169 // construct the name of the page 170 if (!~val.search(token_exp) && !scalars.length && !keyvals.length) return acc.page.push(val), acc; 171 // construct the parameters 172 if (val.match(star_exp)) return (rules.star = RegExp.$2 || RegExp.$3), valid_name(rules.star), acc; 173 if (val.match(scalar_exp)) { 174 if (acc.has_optional_scalar) // no scalars can follow optional scalars 175 throw new SyntaxError(self + ': "' + val + '" cannot follow an optional rule in: ' + orig); 176 if (!!RegExp.$2) acc.has_optional_scalar = val; 177 return scalars.push({name: RegExp.$1, required: !RegExp.$2}), valid_name(RegExp.$1), acc; 178 } 179 if (val.match(keyval_exp)) 180 return keyvals.push({name: RegExp.$1, required: !RegExp.$2}), valid_name(RegExp.$1), acc; 181 throw new SyntaxError(self + ': the rule "' + val + '" was not understood in: ' + orig); 182 }, {page: [], rules: {scalars: [], keyvals: [], star: false}, has_optional_scalar: ''}); 183 delete compiled.has_optional_scalar; // this is just a temporary value and should not be exposed 184 compiled.page = compiled.page.join(SL).replace(new RegExp(SL + '$'), '') || SL; 185 return memo[route] = compiled; 186 }; 187 })({}); 188 pub[namespace] = (routes) = { // parens around routes to satisfy JSDoc's caprice 189 /** 190 * adds a rule to the internal table of routes and methods 191 * @name RouteMap.add 192 * @function 193 * @type undefined 194 * @param {Object} rule rule specification 195 * @param {String} rule.route route pattern definition; there are three types of pattern arguments: scalars, 196 * keyvals, and stars; scalars are individual values in a URL (all URL values are separate by the 197 * <code>'/'</code> character), keyvals are named values, e.g. 'foo=bar', and star values are wildcards; so for 198 * example, the following pattern represents all the possible options:<blockquote> 199 * <code>'/foo/:id/:sub?/attr:/subattr:?/rest:*'</code></blockquote>the <code>?</code> means that argument is 200 * optional, the star rule is named <code>rest</code> but it could have just simply been left as <code>*</code>, 201 * which means the resultant dictionary would have put the wildcard remainder into <code>args['*']</code> 202 * instead of <code>args.rest</code>; so the following URL would match the pattern above:<blockquote> 203 * <code>/foo/23/45/attr=something/subattr=something_else</code></blockquote> 204 * when its method is called, it will receive this arguments dictionary:<blockquote> 205 * <code><pre>{ 206 * id:'23', 207 * subid:'45', 208 * attr:'something', 209 * subattr:'something_else', 210 * rest:'' 211 * }</pre></code></blockquote> 212 * <code>add</code> uses {@link #compile} and does not catch any errors thrown by that function 213 * @param {String} rule.method listener method for this route 214 * @throws {TypeError} if <code>rule.route</code> or <code>rule.method</code> are not strings or empty strings 215 * @throws {Error} if <code>rule</code> has already been added 216 * @see RouteMap.post_add 217 */ 218 add: function (rule) { 219 var self = 'add', method = rule.method, route = rule.route, compiled, id = fingerprint(rule); 220 if ([route, method].some(invalid_str)) 221 throw new TypeError(self + ': rule.route and rule.method must both be non-empty strings'); 222 if (added_routes[id]) throw new Error(self + ': ' + route + ' to ' + method + ' already exists'); 223 compiled = compile(route); 224 added_routes[id] = true; 225 if (!active_routes[compiled.page] && (active_routes[compiled.page] = [])) // add route to list and sort 226 flat_pages = flat_pages.concat(compiled.page).sort(function (a, b) {return b.length - a.length;}); 227 active_routes[compiled.page].push(routes.post_add({method: method, rules: compiled.rules, raw: route})); 228 }, 229 /** 230 * overrides the context where listener methods are sought, the default scope is <code>window</code> 231 * (in a browser setting), returns the current context, if no <code>scope</code> object is passed in, just 232 * returns current context without setting context 233 * @name RouteMap.context 234 * @function 235 * @type {Object} 236 * @returns {Object} the current context within which RouteMap searches for handlers 237 * @param {Object} scope the scope within which methods for mapped routes will be looked for 238 */ 239 context: function (scope) {return context = typeof scope === 'object' ? scope : context;}, 240 /** 241 * returns the parsed (see {@link #parse}) currently accessed route; after listeners have finished 242 * firing, <code>current</code> and <code>last</code> are the same 243 * @name RouteMap.current 244 * @function 245 * @type Object 246 * @returns {Object} the current parsed URL object 247 * @see RouteMap.last 248 */ 249 current: function () {return current ? merge(current) : null;}, 250 /** 251 * this function is fired when no rule is matched by a URL, by default it does nothing, but it could be set up 252 * to handle things like <code>404</code> responses on the server-side or bad hash fragments in the browser 253 * @name RouteMap.default_handler 254 * @function 255 * @type undefined 256 */ 257 default_handler: function () {}, 258 /** 259 * URL grabber function, defaults to checking the URL fragment (<code>hash</code>); this function should be 260 * overwritten in a server-side environment; this method is called by {@link RouteMap.handler}; without 261 * <code>window.location.hash</code> it will return <code>'/'</code> 262 * @name RouteMap.get 263 * @function 264 * @returns {String} by default, this returns a subset of the URL hash (everything after the first 265 * <code>'/'</code> character ... if nothing follows a slash, it returns <code>'/'</code>); if overwritten, it 266 * must be a function that returns URL path strings (beginning with <code>'/'</code>) to match added rules 267 * @type String 268 */ 269 get: function () { 270 if (typeof window === 'undefined') return SL; 271 var hash = window.location.hash, index = hash.indexOf(SL); 272 return ~index ? hash.slice(index) : SL; 273 }, 274 /** 275 * in a browser setting, it changes <code>window.location.hash</code>, in other settings, it should be 276 * overwritten to do something useful (if necessary); it will not throw an error if <code>window</code> does 277 * not exist 278 * @name RouteMap.go 279 * @function 280 * @type undefined 281 * @param {String} hash the hash fragment to go to 282 */ 283 go: function (hash) { 284 if (typeof window !== 'undefined') window.location.hash = (hash.indexOf(PR) === 0 ? '' : PR) + hash; 285 }, 286 /** 287 * main handler function for routing, this should be bound to <code>hashchange</code> events in the browser, or 288 * (in conjunction with updating {@link RouteMap.get}) used with the HTML5 <code>history</code> API, it detects 289 * all the matching route patterns, parses the URL parameters and fires their methods with the arguments from 290 * the parsed URL; the timing of {@link RouteMap.current} and {@link RouteMap.last} being set is as follows 291 * (pseudo-code): 292 * <blockquote><pre> 293 * path: get_route // {@link RouteMap.get} 294 * parsed: parse path // {@link #parse} 295 * current: longest parsed // {@link RouteMap.current} 296 * parsed: pre_dispatch parsed // {@link RouteMap.pre_dispatch} 297 * current: longest parsed // reset current 298 * fire matched rules in parsed 299 * last: current // {@link RouteMap.last} 300 * </pre></blockquote> 301 * <code>RouteMap.handler</code> calls {@link #parse} and does not catch any errors that function throws 302 * @name RouteMap.handler 303 * @function 304 * @type undefined 305 * @see RouteMap.pre_dispatch 306 */ 307 handler: function () { 308 var url = routes.get(), parsed = parse(url), args = Array.prototype.slice.call(arguments); 309 if (!parsed.length) return routes.default_handler.apply(null, [url].concat(args)); 310 current = parsed[0]; // set current to the longest hash before pre_dispatch touches it 311 parsed = routes.pre_dispatch(parsed); // pre_dispatch might change the contents of parsed 312 current = parsed[0]; // set current to the longest hash again after pre_dispatch 313 parsed.forEach(function (val) {val.method.apply(null, [val.args].concat(args));}); // fire requested methods 314 last = parsed[0]; 315 }, 316 /** 317 * returns a URL fragment by applying parameters to a rule; uses {@link #compile} and does not catch any errors 318 * thrown by that function 319 * @name RouteMap.hash 320 * @function 321 * @type String 322 * @param {Object} rule the rule specification; it typically looks like: <blockquote> 323 * <code>{route:'/foo', method:'bar'}</code></blockquote> but only <code>route</code> is strictly necessary 324 * @param {Object} params a dictionary of argument key/value pairs required by the rule 325 * @returns {String} URL fragment resulting from applying arguments to rule pattern 326 * @throws {TypeError} if a required parameter is not present 327 */ 328 hash: function (rule, params) { 329 var self = 'hash', hash, compiled, params = params || {}; 330 if (invalid_str(rule.route)) throw new TypeError(self + ': rule.route must be a non-empty string'); 331 compiled = compile(rule.route); 332 hash = compiled.page + (compiled.page === SL ? '' : SL) + // 1. start with page, then add params 333 compiled.rules.scalars.map(function (val) { // 2. add scalar values next 334 var value = encode(params[val.name]), bad_param = params[val.name] === void 0 || invalid_str(value); 335 if (val.required && bad_param) 336 throw new TypeError(self + ': params.' + val.name + ' is undefined, route: ' + rule.route); 337 return bad_param ? 0 : value; 338 }) 339 .concat(compiled.rules.keyvals.map(function (val) { // 3. then concat keyval values 340 var value = encode(params[val.name]), bad_param = params[val.name] === void 0 || invalid_str(value); 341 if (val.required && bad_param) 342 throw new TypeError(self + ': params.' + val.name + ' is undefined, route: ' + rule.route); 343 return bad_param ? 0 : val.name + EQ + value; 344 })) 345 .filter(Boolean).join(SL); // remove empty (0) values 346 if (compiled.rules.star && params[compiled.rules.star]) // 4. add star value if it exists 347 hash += (hash[hash.length - 1] === SL ? '' : SL) + params[compiled.rules.star]; 348 return hash; 349 }, 350 /** 351 * returns the parsed (see {@link #parse}) last accessed route; when route listeners are being called, 352 * <code>last</code> is the previously accessed route, after listeners have finished firing, the current parsed 353 * route replaces <code>last</code>'s value 354 * @name RouteMap.last 355 * @function 356 * @type Object 357 * @returns {Object} the last parsed URL object, will be <code>null</code> on first load 358 * @see RouteMap.current 359 */ 360 last: function () {return last ? merge(last) : null;}, 361 /** 362 * parses a URL fragment into a data structure only if there is a route whose pattern matches the fragment 363 * @name RouteMap.parse 364 * @function 365 * @type Object 366 * @returns {Object} of the form: <blockquote><code>{page:'/foo', args:{bar:'some_value'}}</code></blockquote> 367 * only if a rule with the route: <code>'/foo/:bar'</code> has already been added 368 * @throws {TypeError} if hash is not a string, is empty, or does not contain a <code>'/'</code> character 369 * @throws {SyntaxError} if hash cannot be parsed by {@link #parse} 370 */ 371 parse: function (hash) { 372 var self = 'parse', parsed, index = hash.indexOf(SL); 373 hash = ~index ? hash.slice(index) : ''; 374 if (invalid_str(hash)) throw new TypeError(self + ': hash must be a string with a ' + SL + ' character'); 375 if (!(parsed = parse(hash)).length) throw new SyntaxError(self + ': ' + hash + ' cannot be parsed'); 376 return {page: parsed[0].page, args: parsed[0].args}; 377 }, 378 /** 379 * this function is called by {@link RouteMap.add}, it receives a compiled rule object, e.g. for the rule: 380 * <blockquote><code>{route:'/foo/:id/:sub?/attr:/subattr:?/rest:*', method:'console.log'}</code></blockquote> 381 * <code>post_add</code> would receive the following object: 382 * <blockquote><code><pre>{ 383 * method:'console.log', 384 * rules:{ 385 * scalars:[{name:'id',required:true},{name:'sub',required:false}], 386 * keyvals:[{name:'attr',required:true},{name:'subattr',required:false}], 387 * star:'rest' 388 * }, 389 * raw:'/foo/:id/:sub?/attr:/subattr:?/rest:*' 390 * }</pre></code></blockquote> 391 * and it is expected to pass back an object of the same format; it can be overwritten to post-process added 392 * rules e.g. to add extra default application-wide parameters; by default, it simply returns what was passed 393 * into it 394 * @name RouteMap.post_add 395 * @function 396 * @type Object 397 * @returns {Object} the default function returns the exact object it received; a custom function needs to 398 * an object that is of the same form (but could possibly have more or fewer parameters, etc.) 399 * @param {Object} compiled the compiled rule 400 */ 401 post_add: function (compiled) {return compiled;}, 402 /** 403 * like {@link RouteMap.post_add} this function can be overwritten to add application-specific code into 404 * route mapping, it is called before a route begins being dispatched to all matching rules; it receives the 405 * list of matching parsed route objects ({@link #parse}) and is expected to return it; one application of this 406 * function might be to set application-wide variables like debug flags 407 * @name RouteMap.pre_dispatch 408 * @function 409 * @type Array 410 * @returns {Array} a list of the same form as the one it receives 411 * @param {Array} parsed the parsed request 412 */ 413 pre_dispatch: function (parsed) {return parsed;}, 414 /** 415 * if a string is passed in, it overwrites the prefix that is removed from each URL before parsing; primarily 416 * used for hashbang (<code>#!</code>); either way, it returns the current prefix 417 * @name RouteMap.prefix 418 * @function 419 * @type undefined 420 * @param {String} prefix (optional) the prefix string 421 */ 422 prefix: function (prefix) {return PR = typeof prefix !== 'undefined' ? prefix + '' : PR;}, 423 /** 424 * counterpart to {@link RouteMap.add}, removes a rule specification; * <code>remove</code> uses 425 * {@link #compile} and does not catch any errors thrown by that function 426 * @name RouteMap.remove 427 * @function 428 * @type undefined 429 * @param {Object} rule the rule specification that was used in {@link RouteMap.add} 430 * @throws {TypeError} if <code>rule.route</code> or <code>rule.method</code> are not strings or empty strings 431 */ 432 remove: function (rule) { 433 var self = 'remove', method = rule.method, route = rule.route, compiled, id = fingerprint(rule), index; 434 if ([route, method].some(invalid_str)) 435 throw new TypeError(self + ': rule.route and rule.method must both be non-empty strings'); 436 if (!added_routes[id]) return; 437 compiled = compile(route); 438 delete added_routes[id]; 439 active_routes[compiled.page] = active_routes[compiled.page] 440 .filter(function (rule) {return (rule.raw !== route) || (rule.method !== method);}); 441 if (!active_routes[compiled.page].length && (delete active_routes[compiled.page])) // delete active route 442 if (~(index = flat_pages.indexOf(compiled.page))) flat_pages.splice(index, 1); // then flat page 443 } 444 }; 445 })(typeof exports === 'undefined' ? window : exports, 'RouteMap');