UNPKG

useless

Version:

Use Less. Do More. JavaScript on steroids.

310 lines (229 loc) 13.4 kB
const O = Object , _ = require ('underscore') , log = require ('ololog') , extractQueryParams = (s, decode = _.identity, [, token, query = ''] = s.match (/^([^?]*)(?:\?(.*))?$/)) => [ // token | token?query) token, !query ? {} : query.split ('&') .reduce ((result, kv) => { const [k,v=''] = kv.split ('='); result[k] = decode (v); return result }, {}) ] _.tests.URIRouter = { /* Supports two way of defining URLs: 1. { key: value, .. } (object notation) 2. [[key, value], ..] (list notation) These notations can be intermixed freely. While former is more comfortable to humans, the latter is more usable for programmatic generation. */ canonicalize () { var input = { 'echo': { post: _.identity }, 'api': { 'source/:file': _.identity, // expands 'git-commits': _.identity }, 'lists-way-works-too': [ ['foo', { get: _.identity }], ['bar', { // allows inclusion of new syntax qux: _.identity, zap: _.identity }]] } var output = [ ['echo', { post: _.identity }], ['api', [ ['source', [[':file', { get: _.identity }]]], ['git-commits', { get: _.identity }] ]], ['lists-way-works-too', [ ['foo', { get: _.identity }], ['bar', [ ['qux', { get: _.identity }], ['zap', { get: _.identity }]]]]],] $assert (URIRouter.canonicalize (input), output) }, /* This algorithm allows incremental updates to API schema */ collapse () { $assert (URIRouter.collapse ([['foo', { get: _.identity } ]]), [['foo', { get: _.identity } ]]) var input = [ ['foo', { get: _.identity }], ['api', [ ['dropdb', { get: _.identity }]]], ['bar', { get: _.identity }], ['foo', { post: _.noop }], ['foo', { get: _.noop }], ['api', [ ['source', [['', { get: _.identity }]]], ['source', [[':file', { get: _.identity }]]], ['git-commits', { get: _.identity }] ]]] var result = [ ['bar', { get: _.identity }], ['foo', { get: _.noop, post: _.noop }], // merged handlers ['api', [ ['dropdb', { get: _.identity }], ['source', [ ['', { get: _.identity }], [':file', { get: _.identity }]]], ['git-commits', { get: _.identity }] ]]] $assert (URIRouter.collapse (input), result) }, queryParams () { const { match, normalize } = URIRouter const schema = normalize ({ 'say/hello?name={}': ({ name }) => 'Hello ' + name, 'export/?hash={[0-9a-f]+}&ids={(\\d+,?)+}&[optional]={}': ({ hash, ids, optional }) => ({ hash, ids: ids.split (',').map (Number), optional }), 'something/:file': file => file, }) $assert (match (schema, 'GET', '/say/hello?name=Sponge%20Bob') .fn (), 'Hello Sponge Bob') $assert (match (schema, 'GET', '/say/hello?name=Sponge%20Bob&age=33').fn (), 'Hello Sponge Bob') // should tolerate extra params $assert (match (schema, 'GET', '/say/hello'), undefined) // `name` is required $assert (match (schema, 'GET', '/export/?hash=580ea7df&ids=123,45,678').fn (), { hash: '580ea7df', ids: [123, 45, 678], optional: undefined }) $assert (match (schema, 'GET', '/export/?hash=580ea7df&ids=123,45,678&optional=42').fn (), { hash: '580ea7df', ids: [123, 45, 678], optional: '42' }) $assert (match (schema, 'GET', '/export/?hash=BLAH&ids=123,45,678'), undefined) // wrong hash (doesn't match the regex) $assert (match (schema, 'GET', '/export/?hash=580ea7df&ids=123,45,678,FOO'), undefined) // wrong ids (doesn't match the regex) $assert (match (schema, 'GET', '/export/?hash=580ea7df'), undefined) // `ids` is required $assert (match (schema, 'GET', '/something/wat.jpg?v=123').fn (), 'wat.jpg') // cuts params off } } const URIRouter = module.exports = { normalize (routes) { return __.seq ([ routes, URIRouter.canonicalize, URIRouter.collapse, URIRouter.validate ]) }, prettyPrint (routes, depth) { depth = depth || 0 _.each (routes, route => { if (URIRouter.isHandler (route[1])) { log.green.indent (depth) (route[0] || '(empty)', _.nonempty ([route[1].get && 'GET', route[1].post && 'POST']).join (' ')) } else { log.yellow.indent (depth) (route[0] || '(empty)', ':') URIRouter.prettyPrint (route[1], depth + 1) log.newline () } }) }, debugTrace (routes, method, path) { return this.match (routes, method, path, true) }, /* Requires all elements in handler chains to be functions. This is needed to prevent 'swallowing' of errors when evaluating something like [this.nonexistentFunction, ...], because __.seq allows constants as chain elements. */ validate (routes, /* optional */ path) { _.each (routes, route => { var subpath = (path || '') + '/' + route[0], subj = route[1] if (!_.isArray (subj)) { _.each (subj, handler => { if (!_.every (_.coerceToArray (handler), _.isFunction)) { log.bright.red.error ( '\nFound non-function in ', (subpath || "''").white, ' handler chain: ', subj) throw new Error ('wrong handler chain') } }) } else { URIRouter.validate (subj, subpath) } }) return routes }, match (routes, method, path, debug = false, depth = 1, vars = {}, virtualTrailSlashCase = undefined) { if (typeof path === 'string') path = path.split ('/') const trace = (debug === true) ? log.configure ({ indent: { level: depth, pattern: '→ '} }) : log.null if ((virtualTrailSlashCase === undefined) && path.length <= depth) { return false } else { const [element, elementQueryParams] = extractQueryParams (virtualTrailSlashCase ? '' : path[depth], decode = decodeURIComponent) for (var i = 0, n = routes.length; i < n; i++) { const route = routes[i] const handler = route[1] const subroutes = _.isArray (handler) ? handler : undefined const [match, queryParams] = !subroutes ? extractQueryParams (route[0]) : [route[0], {}] const isJsonBinding = (match[0] === '@') const isNumberBinding = (match[0] === '%') const isBinding = (match[0] === ':') || isJsonBinding || isNumberBinding trace (match, queryParams, '← ', element.bright, elementQueryParams) if (isBinding || element === match) { if (isBinding) { var key = match.slice (1) var value = decodeURIComponent (subroutes ? element : extractQueryParams (path.slice (depth).join ('/'))[0]) vars[key] = (isJsonBinding ? JSON.parse.catches () (value) : (isNumberBinding ? Number (value) : value)) trace (match + ' = ' + vars[key]) } else { trace (' matched ', element.bright.green) } if (subroutes) { trace (' going deeper'.dim.cyan) // here's pic of "we need to go deeper" DiCaprio from Inception if (depth < (path.length - 1)) { return URIRouter.match (subroutes, method, path, debug, depth + 1, vars) } else if (!virtualTrailSlashCase) { // makes "/foo" respond to "/foo/" handler trace.cyan (' trying to find trail-slash handler') return URIRouter.match (subroutes, method, path, debug, depth + 1, vars, true) } else { trace.yellow (' nowhere to go deeper') } } else if (virtualTrailSlashCase || (depth == (path.length - 1)) || isBinding) { const validQueryParams = {} for (let [k,vLeft] of O.entries (queryParams)) { const [optionalMatch,key] = k.match (/^\[(.+)\]$/) || [null, k] // example: [optional] const isOptional = optionalMatch !== null const vRight = elementQueryParams[key] trace.cyan (optionalMatch, isOptional, vRight) if ((vRight === undefined) || ((vLeft !== '{}') && !(new RegExp ('^' + vLeft.slice (1, -1) + '$').test (vRight)))) { // TODO: cache regexp if (!(isOptional && vRight === undefined)) { trace.red (' ' + k.bright, 'doesnt match!') return undefined } } validQueryParams[key] = vRight } const handlerForMethod = handler[method.lowercase] if (!handlerForMethod) { trace.red (' no appropriate handler found') } else { /* Prepend Promise chain with argument(s) */ var args = (O.keys (validQueryParams).length > 0) ? [O.assign (validQueryParams, vars)] : O.values (vars) var chain = (args.length > 1 ? [match.vars] : args.length > 0 ? args : []).concat (_.coerceToArray (handlerForMethod)) return { fn: function () { return __.seq (chain) }, vars: vars } } } // @hide else { trace (route) trace.red (' maxed at depth ' + (depth + 1) + ' but path has ' + path.length + ' subroutes') } } } trace.bright.red ('match not found\n') return undefined } }, /* PRIVATE */ isHandler (obj) { return obj && (URIRouter.isFunctionOrChain (obj.get) || URIRouter.isFunctionOrChain (obj.post)) }, isFunctionOrChain (obj) { return (obj instanceof Function) || (_.isArray (obj) && !(obj.isEmpty || _.isString (obj[0]) || _.isArray (obj[0]))) }, isCanonicalRoute (obj) { return _.isArray (obj) && (typeof obj[0] === 'string') }, map (obj, fn) { if (URIRouter.isCanonicalRoute (obj)) { return [fn (obj[1], obj[0])] } return _.map (obj, _.isArray (obj) ? function (route) { return fn (route[1], route[0]) } : fn) }, canonicalize (obj) { if (!obj) { return [] } else if (URIRouter.isFunctionOrChain (obj)) { return { get: obj } } else if (URIRouter.isHandler (obj)) { return obj } return URIRouter.map (obj, (value, key) => { var subpaths = key.split ('/') if (subpaths.length > 1) { return _.reduceRight (subpaths, (memo, path) => { return [path, _.isArray (memo) ? [memo] : memo] }, URIRouter.canonicalize (value)) } return [key, URIRouter.canonicalize (value)] }, this) }, collapse (routes, depth = 0) { var handlers = _.filter (routes, URIRouter.isHandler) if (handlers.length) { return _.extend.apply (null, [{}].concat (handlers)) } var groups = _.groupBy (routes, route => { return route[0] }) return _.reversed (_.filter2 (routes.reversed, route => { if (!_.isArray (route)) { return route } var name = route[0] var group = groups[name] if (group) { delete groups[name] var merged = _.flatten (_.map (group, route => route[1]), true) return [name, URIRouter.collapse (merged, depth + 1)] } return false })) } }