├── .github └── workflows │ └── ci-module.yml ├── .gitignore ├── API.md ├── LICENSE.md ├── README.md ├── lib ├── decode.js ├── index.js ├── regex.js └── segment.js ├── package.json └── test ├── decode.js ├── index.js └── regex.js /.github/workflows/ci-module.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | uses: hapijs/.github/.github/workflows/ci-module.yml@master 13 | with: 14 | min-node-version: 14 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | `call` is a simple node.js HTTP Router. It is used by popular [hapi.js](https://github.com/hapijs/hapi) web framework. It implements predictable and easy to use routing. Even if it is designed to work with Hapi.js, you can still use it as an independent router in your app. 4 | 5 | ## Example 6 | 7 | ```js 8 | const Call = require('@hapi/call'); 9 | 10 | // Create new router 11 | const router = new Call.Router(); 12 | 13 | // Add route 14 | router.add({ method: 'get', path: '/' }, { label: 'root-path' }); 15 | 16 | // Add another route 17 | router.add({ method: 'post', path: '/users' }, 'route specific data'); 18 | 19 | // Add another route with dynamic path 20 | router.add({ method: 'put', path: '/users/{userId}' }, () => { /* ...handler... */ }); 21 | 22 | // Match route 23 | router.route('post', '/users'); 24 | /* If matching route is found, it returns an object containing 25 | { 26 | params: {}, // All dynamic path parameters as key/value 27 | paramsArray: [], // All dynamic path parameter values in order 28 | route: 'route specific data'; // routeData 29 | } 30 | */ 31 | 32 | 33 | // Match route 34 | router.route('put', '/users/1234'); 35 | /* returns 36 | { 37 | params: { userId: '1234' }, 38 | paramsArray: [ '1234' ], 39 | route: [Function] 40 | } 41 | */ 42 | ``` 43 | 44 | ## Paths matching 45 | 46 | ### Exact match 47 | 48 | `{param}`: If path contains `/users/{user}` then it matches `/users/john` or `/users/1234` but not `/users`. 49 | 50 | ### Optional parameters 51 | 52 | `{param?}`: ? means parameter is optional . If path contains `/users/{user?}` It matches `/users/john` as well as `/users`. 53 | 54 | It is important to be aware that only the last named parameter in a path can be optional. That means that `/{one?}/{two}/` is an invalid path, since in this case there is another parameter after the optional one. You may also have a named parameter covering only part of a segment of the path, but you may only have one named parameter per segment. That means that /`{filename}.jpg` is valid while `/{filename}.{ext}` is not. 55 | 56 | ### Multi-segment parameters 57 | 58 | `{params*n}`: With path configuration `/users/{user*2}`, it matches `/users/john/doe` or `/users/harshal/patil` but not `/users/john`. Number **n** after asterisk sign specifies the multiplier. 59 | 60 | Like the optional parameters, a wildcard parameter (for example `/{users*}`) may only appear as the last parameter in your path. 61 | 62 | ### Catch all 63 | 64 | `{params*}`: Using this option, it matches anything. So `/users/{user*}` with match `/users/`, `/users/john`, `/users/john/doe`, `/users/john/doe/smith` 65 | 66 | For more details about path parameters, [read hapi.js docs](https://github.com/hapijs/hapi/blob/master/API.md#path-parameters). 67 | 68 | ## Routing order 69 | 70 | When determining what handler to use for a particular request, router searches paths in order from most specific to least specific. That means if you have two routes, one with the path `/filename.jpg` and a second route `/filename.{ext}` a request to /filename.jpg will match the first route, and not the second. This also means that a route with the path `/{files*}` will be the last route tested, and will only match if all other routes fail. 71 | 72 | **Call** router has deterministic order than other routers and because of this deterministic order, `call` is able to detect conflicting routes and throw exception accordingly. In comparison, Express.js has different routing mechanism based on simple RegEx pattern matching making it faster (probably it only matters in theory) but unable to catch route conflicts. 73 | 74 | ## Method 75 | 76 | ### `new Router([options])` 77 | 78 | Constructor to create a new router instance where: 79 | - `options` - an optional configuration object with the following fields: 80 | - `isCaseSensitive` - specifies if the paths should case sensitive. If set to `true`, 81 | `/users` and `/USERS` are considered as two different paths. Defaults to `true`. 82 | 83 | ```js 84 | const router = new Call.Router(); 85 | ``` 86 | 87 | ### `add(options, [data])` 88 | 89 | Adds a new route to the router where: 90 | - `options` - a configuration object with the following fields: 91 | - `method` - the HTTP method (`'get'`, `'put'`, `'post'`, `'delete'`, etc.) or the wildcard 92 | character (`'*'`) to match any methods. The method must be lowercase. 93 | - `path` - the URL path to be used for route matching. The path segment can be static like 94 | `'/users/1234'` or it can be a [dynamic path](#path-matching). 95 | - `data` - the application data to retrieve when a route match is found during lookup. This is 96 | typically the route handler or other metadata about what to do when a route is matched. 97 | 98 | Throws on invalid route configuration or on a conflict with existing routes. 99 | 100 | ### `route(method, path)` 101 | 102 | Finds a matching route where: 103 | - `method` - the requested route method. 104 | - `path` - the requested route path. 105 | 106 | Returns an object with the following when a match is found: 107 | - `params` - an object containing all path parameters where each **key** is path name and 108 | **value** is the corresponding parameter value in the requested `path`. 109 | - `paramsArray` - an array of the parameter values in order. 110 | - `route` - the `data` value provided when the route was added. 111 | 112 | If no match is found, returns (not throws) an error. 113 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2022, Project contributors 2 | Copyright (c) 2014-2020, Sideway Inc 3 | Copyright (c) 2014, Walmart. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @hapi/call 4 | 5 | #### Simple HTTP Router. 6 | 7 | **call** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. 8 | 9 | ### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support 10 | 11 | ## Useful resources 12 | 13 | - [Documentation and API](https://hapi.dev/family/call/) 14 | - [Version status](https://hapi.dev/resources/status/#call) (builds, dependencies, node versions, licenses, eol) 15 | - [Changelog](https://hapi.dev/family/call/changelog/) 16 | - [Project policies](https://hapi.dev/policies/) 17 | - [Free and commercial support options](https://hapi.dev/support/) 18 | -------------------------------------------------------------------------------- /lib/decode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Adapted from: 4 | // Copyright (c) 2017-2019 Justin Ridgewell, MIT Licensed, https://github.com/jridgewell/safe-decode-string-component 5 | // Copyright (c) 2008-2009 Bjoern Hoehrmann , MIT Licensed, http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | exports.decode = function (string) { 12 | 13 | let percentPos = string.indexOf('%'); 14 | if (percentPos === -1) { 15 | return string; 16 | } 17 | 18 | let decoded = ''; 19 | let last = 0; 20 | let codepoint = 0; 21 | let startOfOctets = percentPos; 22 | let state = internals.utf8.accept; 23 | 24 | while (percentPos > -1 && 25 | percentPos < string.length) { 26 | 27 | const high = internals.resolveHex(string[percentPos + 1], 4); 28 | const low = internals.resolveHex(string[percentPos + 2], 0); 29 | const byte = high | low; 30 | const type = internals.utf8.data[byte]; 31 | state = internals.utf8.data[256 + state + type]; 32 | codepoint = (codepoint << 6) | (byte & internals.utf8.data[364 + type]); 33 | 34 | if (state === internals.utf8.accept) { 35 | decoded += string.slice(last, startOfOctets); 36 | decoded += codepoint <= 0xFFFF 37 | ? String.fromCharCode(codepoint) 38 | : String.fromCharCode(0xD7C0 + (codepoint >> 10), 0xDC00 + (codepoint & 0x3FF)); 39 | 40 | codepoint = 0; 41 | last = percentPos + 3; 42 | percentPos = string.indexOf('%', last); 43 | startOfOctets = percentPos; 44 | continue; 45 | } 46 | 47 | if (state === internals.utf8.reject) { 48 | return null; 49 | } 50 | 51 | percentPos += 3; 52 | 53 | if (percentPos >= string.length || 54 | string[percentPos] !== '%') { 55 | 56 | return null; 57 | } 58 | } 59 | 60 | return decoded + string.slice(last); 61 | }; 62 | 63 | 64 | internals.resolveHex = function (char, shift) { 65 | 66 | const i = internals.hex[char]; 67 | return i === undefined ? 255 : i << shift; 68 | }; 69 | 70 | 71 | internals.hex = { 72 | '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, 73 | '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, 74 | 'a': 10, 'A': 10, 'b': 11, 'B': 11, 'c': 12, 75 | 'C': 12, 'd': 13, 'D': 13, 'e': 14, 'E': 14, 76 | 'f': 15, 'F': 15 77 | }; 78 | 79 | 80 | internals.utf8 = { 81 | accept: 12, 82 | reject: 0, 83 | data: [ 84 | 85 | // Maps bytes to character to a transition 86 | 87 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 88 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 89 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 90 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 91 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 92 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 93 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 94 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 95 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 96 | 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 97 | 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 98 | 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 99 | 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 100 | 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 101 | 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 102 | 10, 9, 9, 9, 11, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 103 | 104 | // Maps a state to a new state when adding a transition 105 | 106 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 107 | 12, 0, 0, 0, 0, 24, 36, 48, 60, 72, 84, 96, 108 | 0, 12, 12, 12, 0, 0, 0, 0, 0, 0, 0, 0, 109 | 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 110 | 0, 24, 24, 24, 0, 0, 0, 0, 0, 0, 0, 0, 111 | 0, 24, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112 | 0, 48, 48, 48, 0, 0, 0, 0, 0, 0, 0, 0, 113 | 0, 0, 48, 48, 0, 0, 0, 0, 0, 0, 0, 0, 114 | 0, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 115 | 116 | // Maps the current transition to a mask that needs to apply to the byte 117 | 118 | 0x7F, 0x3F, 0x3F, 0x3F, 0x00, 0x1F, 0x0F, 0x0F, 0x0F, 0x07, 0x07, 0x07 119 | ] 120 | }; 121 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('@hapi/boom'); 4 | const Hoek = require('@hapi/hoek'); 5 | 6 | const Decode = require('./decode'); 7 | const Regex = require('./regex'); 8 | const Segment = require('./segment'); 9 | 10 | 11 | const internals = { 12 | pathRegex: Regex.generate(), 13 | defaults: { 14 | isCaseSensitive: true 15 | } 16 | }; 17 | 18 | 19 | exports.Router = internals.Router = function (options) { 20 | 21 | this.settings = Hoek.applyToDefaults(internals.defaults, options || {}); 22 | 23 | this.routes = new Map(); // Key: HTTP method or * for catch-all, value: sorted array of routes 24 | this.ids = new Map(); // Key: route id, value: record 25 | this.vhosts = null; // Map where Key: hostname, value: see this.routes 26 | 27 | this.specials = { 28 | badRequest: null, 29 | notFound: null, 30 | options: null 31 | }; 32 | }; 33 | 34 | 35 | internals.Router.prototype.add = function (config, route) { 36 | 37 | const method = config.method.toLowerCase(); 38 | 39 | const vhost = config.vhost || '*'; 40 | if (vhost !== '*') { 41 | this.vhosts = this.vhosts ?? new Map(); 42 | if (!this.vhosts.has(vhost)) { 43 | this.vhosts.set(vhost, new Map()); 44 | } 45 | } 46 | 47 | const table = vhost === '*' ? this.routes : this.vhosts.get(vhost); 48 | if (!table.has(method)) { 49 | table.set(method, { routes: [], router: new Segment() }); 50 | } 51 | 52 | const analysis = config.analysis ?? this.analyze(config.path); 53 | const record = { 54 | path: config.path, 55 | route: route || config.path, 56 | segments: analysis.segments, 57 | params: analysis.params, 58 | fingerprint: analysis.fingerprint, 59 | settings: this.settings 60 | }; 61 | 62 | // Add route 63 | 64 | const map = table.get(method); 65 | map.router.add(analysis.segments, record); 66 | map.routes.push(record); 67 | map.routes.sort(internals.sort); 68 | 69 | const last = record.segments[record.segments.length - 1]; 70 | if (last.empty) { 71 | map.router.add(analysis.segments.slice(0, -1), record); 72 | } 73 | 74 | if (config.id) { 75 | Hoek.assert(!this.ids.has(config.id), 'Route id', config.id, 'for path', config.path, 'conflicts with existing path', this.ids.has(config.id) && this.ids.get(config.id).path); 76 | this.ids.set(config.id, record); 77 | } 78 | 79 | return record; 80 | }; 81 | 82 | 83 | internals.Router.prototype.special = function (type, route) { 84 | 85 | Hoek.assert(Object.keys(this.specials).indexOf(type) !== -1, 'Unknown special route type:', type); 86 | 87 | this.specials[type] = { route }; 88 | }; 89 | 90 | 91 | internals.Router.prototype.route = function (method, path, hostname) { 92 | 93 | const segments = path.length === 1 ? [''] : path.split('/').slice(1); 94 | 95 | const vhost = this.vhosts && hostname && this.vhosts.get(hostname); 96 | const route = vhost && this._lookup(path, segments, vhost, method) || 97 | this._lookup(path, segments, this.routes, method) || 98 | method === 'head' && vhost && this._lookup(path, segments, vhost, 'get') || 99 | method === 'head' && this._lookup(path, segments, this.routes, 'get') || 100 | method === 'options' && this.specials.options || 101 | vhost && this._lookup(path, segments, vhost, '*') || 102 | this._lookup(path, segments, this.routes, '*') || 103 | this.specials.notFound || Boom.notFound(); 104 | 105 | return route; 106 | }; 107 | 108 | 109 | internals.Router.prototype._lookup = function (path, segments, table, method) { 110 | 111 | const set = table.get(method); 112 | if (!set) { 113 | return null; 114 | } 115 | 116 | const match = set.router.lookup(path, segments, this.settings); 117 | if (!match) { 118 | return null; 119 | } 120 | 121 | const assignments = {}; 122 | const array = []; 123 | for (let i = 0; i < match.array.length; ++i) { 124 | const name = match.record.params[i]; 125 | const value = Decode.decode(match.array[i]); 126 | if (value === null) { 127 | return this.specials.badRequest ?? Boom.badRequest('Invalid request path'); 128 | } 129 | 130 | if (assignments[name] !== undefined) { 131 | assignments[name] = assignments[name] + '/' + value; 132 | } 133 | else { 134 | assignments[name] = value; 135 | } 136 | 137 | if (i + 1 === match.array.length || // Only include the last segment of a multi-segment param 138 | name !== match.record.params[i + 1]) { 139 | 140 | array.push(assignments[name]); 141 | } 142 | } 143 | 144 | return { params: assignments, paramsArray: array, route: match.record.route }; 145 | }; 146 | 147 | 148 | internals.Router.prototype.normalize = function (path) { 149 | 150 | if (path && 151 | path.indexOf('%') !== -1) { 152 | 153 | // Uppercase %encoded values 154 | 155 | const uppercase = path.replace(/%[0-9a-fA-F][0-9a-fA-F]/g, (encoded) => encoded.toUpperCase()); 156 | 157 | // Decode non-reserved path characters: a-z A-Z 0-9 _!$&'()*+,;=:@-.~ 158 | // ! (%21) $ (%24) & (%26) ' (%27) ( (%28) ) (%29) * (%2A) + (%2B) , (%2C) - (%2D) . (%2E) 159 | // 0-9 (%30-39) : (%3A) ; (%3B) = (%3D) 160 | // @ (%40) A-Z (%41-5A) _ (%5F) a-z (%61-7A) ~ (%7E) 161 | 162 | const decoded = uppercase.replace(/%(?:2[146-9A-E]|3[\dABD]|4[\dA-F]|5[\dAF]|6[1-9A-F]|7[\dAE])/g, (encoded) => String.fromCharCode(parseInt(encoded.substring(1), 16))); 163 | 164 | path = decoded; 165 | } 166 | 167 | // Normalize path segments 168 | 169 | if (path && 170 | (path.indexOf('/.') !== -1 || path[0] === '.')) { 171 | 172 | const hasLeadingSlash = path[0] === '/'; 173 | const segments = path.split('/'); 174 | const normalized = []; 175 | let segment; 176 | 177 | for (let i = 0; i < segments.length; ++i) { 178 | segment = segments[i]; 179 | if (segment === '..') { 180 | normalized.pop(); 181 | } 182 | else if (segment !== '.') { 183 | normalized.push(segment); 184 | } 185 | } 186 | 187 | if (segment === '.' || 188 | segment === '..') { // Add trailing slash when needed 189 | 190 | normalized.push(''); 191 | } 192 | 193 | path = normalized.join('/'); 194 | 195 | if (path[0] !== '/' && 196 | hasLeadingSlash) { 197 | 198 | path = '/' + path; 199 | } 200 | } 201 | 202 | return path; 203 | }; 204 | 205 | 206 | internals.Router.prototype.analyze = function (path) { 207 | 208 | Hoek.assert(internals.pathRegex.validatePath.test(path), 'Invalid path:', path); 209 | Hoek.assert(!internals.pathRegex.validatePathEncoded.test(path), 'Path cannot contain encoded non-reserved path characters:', path); 210 | 211 | const pathParts = path.split('/'); 212 | const segments = []; 213 | const params = []; 214 | const fingers = []; 215 | 216 | for (let i = 1; i < pathParts.length; ++i) { // Skip first empty segment 217 | let segment = pathParts[i]; 218 | 219 | // Literal 220 | 221 | if (segment.indexOf('{') === -1) { 222 | segment = this.settings.isCaseSensitive ? segment : segment.toLowerCase(); 223 | fingers.push(segment); 224 | segments.push({ literal: segment }); 225 | continue; 226 | } 227 | 228 | // Parameter 229 | 230 | const parts = internals.parseParams(segment); 231 | if (parts.length === 1) { 232 | 233 | // Simple parameter 234 | 235 | const item = parts[0]; 236 | Hoek.assert(params.indexOf(item.name) === -1, 'Cannot repeat the same parameter name:', item.name, 'in:', path); 237 | params.push(item.name); 238 | 239 | if (item.wildcard) { 240 | if (item.count) { 241 | for (let j = 0; j < item.count; ++j) { 242 | fingers.push('?'); 243 | segments.push({}); 244 | if (j) { 245 | params.push(item.name); 246 | } 247 | } 248 | } 249 | else { 250 | fingers.push('#'); 251 | segments.push({ wildcard: true }); 252 | } 253 | } 254 | else { 255 | fingers.push('?'); 256 | segments.push({ empty: item.empty }); 257 | } 258 | } 259 | else { 260 | 261 | // Mixed parameter 262 | 263 | const seg = { 264 | length: parts.length, 265 | first: typeof parts[0] !== 'string', 266 | segments: [] 267 | }; 268 | 269 | let finger = ''; 270 | let regex = '^'; 271 | for (let j = 0; j < parts.length; ++j) { 272 | const part = parts[j]; 273 | if (typeof part === 'string') { 274 | finger = finger + part; 275 | regex = regex + Hoek.escapeRegex(part); 276 | seg.segments.push(part); 277 | } 278 | else { 279 | Hoek.assert(params.indexOf(part.name) === -1, 'Cannot repeat the same parameter name:', part.name, 'in:', path); 280 | params.push(part.name); 281 | 282 | finger = finger + '?'; 283 | regex = regex + '(.' + (part.empty ? '*' : '+') + ')'; 284 | } 285 | } 286 | 287 | seg.mixed = new RegExp(regex + '$', !this.settings.isCaseSensitive ? 'i' : ''); 288 | fingers.push(finger); 289 | segments.push(seg); 290 | } 291 | } 292 | 293 | return { 294 | segments, 295 | fingerprint: '/' + fingers.join('/'), 296 | params 297 | }; 298 | }; 299 | 300 | 301 | internals.parseParams = function (segment) { 302 | 303 | const parts = []; 304 | segment.replace(internals.pathRegex.parseParam, ($0, literal, name, wildcard, count, empty) => { 305 | 306 | if (literal) { 307 | parts.push(literal); 308 | } 309 | else { 310 | parts.push({ 311 | name, 312 | wildcard: !!wildcard, 313 | count: count && parseInt(count, 10), 314 | empty: !!empty 315 | }); 316 | } 317 | 318 | return ''; 319 | }); 320 | 321 | return parts; 322 | }; 323 | 324 | 325 | internals.Router.prototype.table = function (host) { 326 | 327 | const result = []; 328 | const collect = (table) => { 329 | 330 | if (!table) { 331 | return; 332 | } 333 | 334 | for (const map of table.values()) { 335 | for (const record of map.routes) { 336 | result.push(record.route); 337 | } 338 | } 339 | }; 340 | 341 | if (this.vhosts) { 342 | const vhosts = host ? [].concat(host) : [...this.vhosts.keys()]; 343 | for (const vhost of vhosts) { 344 | collect(this.vhosts.get(vhost)); 345 | } 346 | } 347 | 348 | collect(this.routes); 349 | 350 | return result; 351 | }; 352 | 353 | 354 | internals.sort = function (a, b) { 355 | 356 | const aFirst = -1; 357 | const bFirst = 1; 358 | 359 | const as = a.segments; 360 | const bs = b.segments; 361 | 362 | if (as.length !== bs.length) { 363 | return as.length > bs.length ? bFirst : aFirst; 364 | } 365 | 366 | for (let i = 0; ; ++i) { 367 | if (as[i].literal) { 368 | if (bs[i].literal) { 369 | if (as[i].literal === bs[i].literal) { 370 | continue; 371 | } 372 | 373 | return as[i].literal > bs[i].literal ? bFirst : aFirst; 374 | } 375 | 376 | return aFirst; 377 | } 378 | 379 | if (bs[i].literal) { 380 | return bFirst; 381 | } 382 | 383 | return as[i].wildcard ? bFirst : aFirst; 384 | } 385 | }; 386 | -------------------------------------------------------------------------------- /lib/regex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const internals = {}; 4 | 5 | 6 | exports.generate = function () { 7 | 8 | /* 9 | /path/{param}/path/{param?} 10 | /path/{param*2}/path 11 | /path/{param*2} 12 | /path/x{param}x 13 | /{param*} 14 | */ 15 | 16 | const empty = '(?:^\\/$)'; 17 | 18 | const legalChars = '[\\w\\!\\$&\'\\(\\)\\*\\+\\,;\\=\\:@\\-\\.~]'; 19 | const encoded = '%[A-F0-9]{2}'; 20 | 21 | const literalChar = '(?:' + legalChars + '|' + encoded + ')'; 22 | const literal = literalChar + '+'; 23 | const literalOptional = literalChar + '*'; 24 | 25 | const midParam = '(?:\\{\\w+(?:\\*[1-9]\\d*)?\\})'; // {p}, {p*2} 26 | const endParam = '(?:\\/(?:\\{\\w+(?:(?:\\*(?:[1-9]\\d*)?)|(?:\\?))?\\})?)?'; // {p}, {p*2}, {p*}, {p?} 27 | 28 | const partialParam = '(?:\\{\\w+\\??\\})'; // {p}, {p?} 29 | const mixedParam = '(?:(?:' + literal + partialParam + ')+' + literalOptional + ')|(?:' + partialParam + '(?:' + literal + partialParam + ')+' + literalOptional + ')|(?:' + partialParam + literal + ')'; 30 | 31 | const segmentContent = '(?:' + literal + '|' + midParam + '|' + mixedParam + ')'; 32 | const segment = '\\/' + segmentContent; 33 | const segments = '(?:' + segment + ')*'; 34 | 35 | const path = '(?:^' + segments + endParam + '$)'; 36 | 37 | // 1:literal 2:name 3:* 4:count 5:? 38 | const parseParam = '(' + literal + ')|(?:\\{(\\w+)(?:(\\*)(\\d+)?)?(\\?)?\\})'; 39 | 40 | const expressions = { 41 | parseParam: new RegExp(parseParam, 'g'), 42 | validatePath: new RegExp(empty + '|' + path), 43 | validatePathEncoded: /%(?:2[146-9A-E]|3[\dABD]|4[\dA-F]|5[\dAF]|6[1-9A-F]|7[\dAE])/g 44 | }; 45 | 46 | return expressions; 47 | }; 48 | -------------------------------------------------------------------------------- /lib/segment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('@hapi/hoek'); 4 | 5 | 6 | const internals = {}; 7 | 8 | 9 | exports = module.exports = internals.Segment = function () { 10 | 11 | this._edge = null; // { segment, record } 12 | this._fulls = null; // { path: { segment, record } 13 | this._literals = null; // { literal: { segment, } } 14 | this._param = null; // 15 | this._mixed = null; // [{ segment, }] 16 | this._wildcard = null; // { segment, record } 17 | }; 18 | 19 | 20 | internals.Segment.prototype.add = function (segments, record) { 21 | 22 | /* 23 | { literal: 'x' } -> x 24 | { empty: false } -> {p} 25 | { wildcard: true } -> {p*} 26 | { mixed: /regex/ } -> a{p}b 27 | */ 28 | 29 | const current = segments[0]; 30 | const remaining = segments.slice(1); 31 | const isEdge = !remaining.length; 32 | 33 | const literals = []; 34 | let isLiteral = true; 35 | for (let i = 0; i < segments.length && isLiteral; ++i) { 36 | isLiteral = segments[i].literal !== undefined; 37 | literals.push(segments[i].literal); 38 | } 39 | 40 | if (isLiteral) { 41 | this._fulls = this._fulls ?? new Map(); 42 | let literal = '/' + literals.join('/'); 43 | if (!record.settings.isCaseSensitive) { 44 | literal = literal.toLowerCase(); 45 | } 46 | 47 | Hoek.assert(!this._fulls.has(literal), 'New route', record.path, 'conflicts with existing', this._fulls.get(literal)?.record.path); 48 | this._fulls.set(literal, { segment: current, record }); 49 | } 50 | else if (current.literal !== undefined) { // Can be empty string 51 | 52 | // Literal 53 | 54 | this._literals = this._literals ?? new Map(); 55 | const currentLiteral = record.settings.isCaseSensitive ? current.literal : current.literal.toLowerCase(); 56 | if (!this._literals.has(currentLiteral)) { 57 | this._literals.set(currentLiteral, new internals.Segment()); 58 | } 59 | 60 | this._literals.get(currentLiteral).add(remaining, record); 61 | } 62 | else if (current.wildcard) { 63 | 64 | // Wildcard 65 | 66 | Hoek.assert(!this._wildcard, 'New route', record.path, 'conflicts with existing', this._wildcard?.record.path); 67 | Hoek.assert(!this._param || !this._param._wildcard, 'New route', record.path, 'conflicts with existing', this._param?._wildcard?.record.path); 68 | this._wildcard = { segment: current, record }; 69 | } 70 | else if (current.mixed) { 71 | 72 | // Mixed 73 | 74 | this._mixed = this._mixed ?? []; 75 | 76 | let mixed = this._mixedLookup(current); 77 | if (!mixed) { 78 | mixed = { segment: current, node: new internals.Segment() }; 79 | this._mixed.push(mixed); 80 | this._mixed.sort(internals.mixed); 81 | } 82 | 83 | if (isEdge) { 84 | Hoek.assert(!mixed.node._edge, 'New route', record.path, 'conflicts with existing', mixed.node._edge?.record.path); 85 | mixed.node._edge = { segment: current, record }; 86 | } 87 | else { 88 | mixed.node.add(remaining, record); 89 | } 90 | } 91 | else { 92 | 93 | // Parameter 94 | 95 | this._param = this._param ?? new internals.Segment(); 96 | 97 | if (isEdge) { 98 | Hoek.assert(!this._param._edge, 'New route', record.path, 'conflicts with existing', this._param._edge?.record.path); 99 | this._param._edge = { segment: current, record }; 100 | } 101 | else { 102 | Hoek.assert(!this._wildcard || !remaining[0].wildcard, 'New route', record.path, 'conflicts with existing', this._wildcard?.record.path); 103 | this._param.add(remaining, record); 104 | } 105 | } 106 | }; 107 | 108 | 109 | internals.Segment.prototype._mixedLookup = function (segment) { 110 | 111 | for (let i = 0; i < this._mixed.length; ++i) { 112 | if (internals.mixed({ segment }, this._mixed[i]) === 0) { 113 | return this._mixed[i]; 114 | } 115 | } 116 | 117 | return null; 118 | }; 119 | 120 | 121 | internals.mixed = function (a, b) { 122 | 123 | const aFirst = -1; 124 | const bFirst = 1; 125 | 126 | const as = a.segment; 127 | const bs = b.segment; 128 | 129 | if (as.length !== bs.length) { 130 | return as.length > bs.length ? aFirst : bFirst; 131 | } 132 | 133 | if (as.first !== bs.first) { 134 | return as.first ? bFirst : aFirst; 135 | } 136 | 137 | for (let i = 0; i < as.segments.length; ++i) { 138 | const am = as.segments[i]; 139 | const bm = bs.segments[i]; 140 | 141 | if (am === bm) { 142 | continue; 143 | } 144 | 145 | if (am.length === bm.length) { 146 | return am > bm ? bFirst : aFirst; 147 | } 148 | 149 | return am.length < bm.length ? bFirst : aFirst; 150 | } 151 | 152 | return 0; 153 | }; 154 | 155 | 156 | internals.Segment.prototype.lookup = function (path, segments, options) { 157 | 158 | let match = null; 159 | 160 | // Literal edge 161 | 162 | if (this._fulls) { 163 | match = this._fulls.get(options.isCaseSensitive ? path : path.toLowerCase()); 164 | if (match) { 165 | return { record: match.record, array: [] }; 166 | } 167 | } 168 | 169 | // Literal node 170 | 171 | const current = segments[0]; 172 | const nextPath = path.slice(current.length + 1); 173 | const remainder = segments.length > 1 ? segments.slice(1) : null; 174 | 175 | if (this._literals) { 176 | const literal = options.isCaseSensitive ? current : current.toLowerCase(); 177 | match = this._literals.get(literal); 178 | if (match) { 179 | const record = internals.deeper(match, nextPath, remainder, [], options); 180 | if (record) { 181 | return record; 182 | } 183 | } 184 | } 185 | 186 | // Mixed 187 | 188 | if (this._mixed) { 189 | for (let i = 0; i < this._mixed.length; ++i) { 190 | match = this._mixed[i]; 191 | const params = current.match(match.segment.mixed); 192 | if (params) { 193 | const array = []; 194 | for (let j = 1; j < params.length; ++j) { 195 | array.push(params[j]); 196 | } 197 | 198 | const record = internals.deeper(match.node, nextPath, remainder, array, options); 199 | if (record) { 200 | return record; 201 | } 202 | } 203 | } 204 | } 205 | 206 | // Param 207 | 208 | if (this._param) { 209 | if (current || this._param._edge?.segment.empty) { 210 | const record = internals.deeper(this._param, nextPath, remainder, [current], options); 211 | if (record) { 212 | return record; 213 | } 214 | } 215 | } 216 | 217 | // Wildcard 218 | 219 | if (this._wildcard) { 220 | return { record: this._wildcard.record, array: [path.slice(1)] }; 221 | } 222 | 223 | return null; 224 | }; 225 | 226 | 227 | internals.deeper = function (match, path, segments, array, options) { 228 | 229 | if (!segments) { 230 | if (match._edge) { 231 | return { record: match._edge.record, array }; 232 | } 233 | 234 | if (match._wildcard) { 235 | return { record: match._wildcard.record, array }; 236 | } 237 | } 238 | else { 239 | const result = match.lookup(path, segments, options); 240 | if (result) { 241 | return { record: result.record, array: array.concat(result.array) }; 242 | } 243 | } 244 | 245 | return null; 246 | }; 247 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hapi/call", 3 | "description": "HTTP Router", 4 | "version": "9.0.1", 5 | "repository": "git://github.com/hapijs/call", 6 | "main": "lib/index.js", 7 | "files": [ 8 | "lib" 9 | ], 10 | "keywords": [ 11 | "HTTP", 12 | "router" 13 | ], 14 | "eslintConfig": { 15 | "extends": [ 16 | "plugin:@hapi/module" 17 | ] 18 | }, 19 | "dependencies": { 20 | "@hapi/boom": "^10.0.1", 21 | "@hapi/hoek": "^11.0.2" 22 | }, 23 | "devDependencies": { 24 | "@hapi/code": "^9.0.3", 25 | "@hapi/eslint-plugin": "^6.0.0", 26 | "@hapi/lab": "^25.1.2" 27 | }, 28 | "scripts": { 29 | "test": "lab -a @hapi/code -t 100 -L", 30 | "test-cov-html": "lab -a @hapi/code -r html -o coverage.html" 31 | }, 32 | "license": "BSD-3-Clause" 33 | } 34 | -------------------------------------------------------------------------------- /test/decode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Decode = require('../lib/decode'); 4 | const Code = require('@hapi/code'); 5 | const Lab = require('@hapi/lab'); 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('uri.decode()', () => { 16 | 17 | it('decodes URI strings', () => { 18 | 19 | const strings = [ 20 | '', 21 | 'abcd', 22 | '1+2+3+4', 23 | 'a b c d', 24 | '=x', 25 | '%25', 26 | 'p%C3%A5ss', 27 | '%61+%4d%4D', 28 | '\uFEFFtest', 29 | '\uFEFF', 30 | '%EF%BB%BFtest', 31 | '%EF%BB%BF', 32 | '%C2%B5', 33 | '†', 34 | '/a/b%2Fc', 35 | '¢™💩', 36 | encodeURI('¢™💩') 37 | ]; 38 | 39 | for (const string of strings) { 40 | expect(Decode.decode(string)).to.equal(decodeURIComponent(string)); 41 | } 42 | }); 43 | 44 | it('handles invalid strings', () => { 45 | 46 | const strings = [ 47 | '%', 48 | '%2', 49 | '%%25%%', 50 | '%ab', 51 | '%ab%ac%ad', 52 | 'f%C3%A5il%', 53 | 'f%C3%A5%il', 54 | '%f%C3%A5il', 55 | 'f%%C3%%A5il', 56 | '%C2%B5%', 57 | '%%C2%B5%', 58 | '%E0%A4%A', 59 | '/a/b%"Fc', 60 | '64I%C8yY3wM9tB89x2S~3Hs4AXz3TKPS', 61 | 'l3k%Dbbbxn.L5P2ilI-tLxUgndaWnr81', 62 | 'fum3GJU-DLBgO%dehn%MGDsM-jn-p-_Q', 63 | 'AWgvg5oEgIJoS%eD28Co4koKtu346v3j', 64 | 'k3%c4NVrqbGf~8IeQyDueGVwV1a8_vb4', 65 | 'QlW8P%e9ARoU4chM4ckznRJWP-6RmIL5', 66 | 'h7w6%dfcx4k.EYkPlGey._b%wfOb-Y1q', 67 | 'zFtcAt%ca9ITgiTldiF_nfNlf7a0a578', 68 | '.vQD.nCmjJNEpid%e5KglS35Sv-97GMk', 69 | '8qYKc_4Zx%eA.1C6K99CtyuN4_Xl8edp', 70 | '.Y4~dvjs%D7Qqhy8wQz3O~mLuFXGNG2T', 71 | 'kou6MHS%f3AJTpe8.%eOhfZptvsGmCAC', 72 | '-yUdrHiMrRp1%DfvjZ.vkn_dO9p~q07A', 73 | 'e6BF%demc0%52iqSGOPL3kvYePf-7LIH', 74 | 'Aeo_4FxaGyC.w~F1TAAK9uYf-y._m%ca', 75 | 'z0krVTLPXhcqW~1PxkEmke0CmNcIT%EE', 76 | '3KqqzjaF.6QH6M5gm5PnV5iR3X99n%Cb', 77 | 'Nl_0qJEX6ZBVK2E3qvFNL0sMJzpxK%DF', 78 | 'WKj35GkCYJ~ZF_mkKZnPBQzo2CJBj%D6', 79 | 'ym8WNqRjaxrK9CEf.Y.Twn0he8.6b%ca', 80 | 'S4q0CjXZW5aWtnGiJl.svb7ow8HG6%c9', 81 | '0iL5JYG96IjiQ1PHfxTobQOjaqv7.%d3', 82 | '3OzV6xpZ2xmPxSBoMTTC_LcFpnE0M%Ea', 83 | 'dvQN9Ra2UoWefWY.MEZXaD69bUHNc%Cd' 84 | ]; 85 | 86 | for (const string of strings) { 87 | expect(() => decodeURIComponent(string)).to.throw(); 88 | expect(Decode.decode(string)).to.be.null(); 89 | } 90 | }); 91 | 92 | it('decodes every character', () => { 93 | 94 | const chars = []; 95 | for (let i = 0; i < 256; ++i) { 96 | chars.push(encodeURI(String.fromCharCode(i))); 97 | } 98 | 99 | const string = chars.join('a1$#'); 100 | expect(Decode.decode(string)).to.equal(decodeURIComponent(string)); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Call = require('..'); 4 | const Code = require('@hapi/code'); 5 | const Lab = require('@hapi/lab'); 6 | 7 | 8 | const internals = {}; 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('Router', () => { 16 | 17 | it('routes request', () => { 18 | 19 | const router = new Call.Router(); 20 | router.add({ method: 'get', path: '/' }, '/'); 21 | router.add({ method: 'get', path: '/a' }, '/a'); 22 | router.add({ method: 'get', path: '/a{b?}c{d}' }, '/a{b?}c{d}'); 23 | 24 | expect(router.route('get', '/').route).to.equal('/'); 25 | expect(router.route('get', '/a').route).to.equal('/a'); 26 | expect(router.route('get', '/abcd').route).to.equal('/a{b?}c{d}'); 27 | }); 28 | 29 | it('routes request (pre-analyzed)', () => { 30 | 31 | const router = new Call.Router(); 32 | router.add({ method: 'get', path: '/', analysis: router.analyze('/') }, '/'); 33 | router.add({ method: 'get', path: '/a', analysis: router.analyze('/a') }, '/a'); 34 | router.add({ method: 'get', path: '/b', analysis: router.analyze('/b') }, '/b'); 35 | 36 | expect(router.route('get', '/').route).to.equal('/'); 37 | expect(router.route('get', '/a').route).to.equal('/a'); 38 | expect(router.route('get', '/b').route).to.equal('/b'); 39 | }); 40 | 41 | describe('sort', () => { 42 | 43 | const paths = [ 44 | '/', 45 | '/a', 46 | '/b', 47 | '/ab', 48 | '/a{p}b', 49 | '/a{p}', 50 | '/{p}b', 51 | '/{p}', 52 | '/a/b', 53 | '/a/{p}', 54 | '/b/', 55 | '/a1{p}/a', 56 | '/xx{p}/b', 57 | '/x{p}/a', 58 | '/x{p}/b', 59 | '/y{p?}/b', 60 | '/{p}xx/b', 61 | '/{p}x/b', 62 | '/{p}y/b', 63 | '/a/b/c', 64 | '/a/b/{p}', 65 | '/a/d{p}c/b', 66 | '/a/d{p}/b', 67 | '/a/{p}d/b', 68 | '/a/{p}/b', 69 | '/a/{p}/c', 70 | '/a/{p*2}', 71 | '/a/b/c/d', 72 | '/a/b/{p*2}', 73 | '/a/{p}/b/{x}', 74 | '/{p*5}', 75 | '/a/b/{p*}', 76 | '/{a}/b/{p*}', 77 | '/{p*}', 78 | '/m/n/{p*}', 79 | '/m/{n}/{o}', 80 | '/n/{p}/{o*}' 81 | ]; 82 | 83 | const requests = [ 84 | ['/', '/'], 85 | ['/a', '/a'], 86 | ['/b', '/b'], 87 | ['/ab', '/ab'], 88 | ['/axb', '/a{p}b'], 89 | ['/axc', '/a{p}'], 90 | ['/bxb', '/{p}b'], 91 | ['/c', '/{p}'], 92 | ['/a/b', '/a/b'], 93 | ['/a/c', '/a/{p}'], 94 | ['/b/', '/b/'], 95 | ['/a1larry/a', '/a1{p}/a'], 96 | ['/xx1/b', '/xx{p}/b'], 97 | ['/xx1/a', '/x{p}/a'], 98 | ['/x1/b', '/x{p}/b'], 99 | ['/y/b', '/y{p?}/b'], 100 | ['/0xx/b', '/{p}xx/b'], 101 | ['/0x/b', '/{p}x/b'], 102 | ['/ay/b', '/{p}y/b'], 103 | ['/a/b/c', '/a/b/c'], 104 | ['/a/b/d', '/a/b/{p}'], 105 | ['/a/doc/b', '/a/d{p}c/b'], 106 | ['/a/dl/b', '/a/d{p}/b'], 107 | ['/a/ld/b', '/a/{p}d/b'], 108 | ['/a/a/b', '/a/{p}/b'], 109 | ['/a/d/c', '/a/{p}/c'], 110 | ['/a/d/d', '/a/{p*2}'], 111 | ['/a/b/c/d', '/a/b/c/d'], 112 | ['/a/b/c/e', '/a/b/{p*2}'], 113 | ['/a/c/b/d', '/a/{p}/b/{x}'], 114 | ['/a/b/c/d/e', '/a/b/{p*}'], 115 | ['/a/b/c/d/e/f', '/a/b/{p*}'], 116 | ['/x/b/c/d/e/f/g', '/{a}/b/{p*}'], 117 | ['/x/y/c/d/e/f/g', '/{p*}'], 118 | ['/m/n/o', '/m/n/{p*}'], 119 | ['/m/o/p', '/m/{n}/{o}'], 120 | ['/n/a/b/c', '/n/{p}/{o*}'], 121 | ['/n/a', '/n/{p}/{o*}'] 122 | ]; 123 | 124 | const test = function (path, route) { 125 | 126 | it('matches \'' + path + '\' to \'' + route + '\'', () => { 127 | 128 | const router = new Call.Router(); 129 | for (let i = 0; i < paths.length; ++i) { 130 | router.add({ method: 'get', path: paths[i] }, paths[i]); 131 | } 132 | 133 | expect(router.route('get', path).route).to.equal(route); 134 | }); 135 | }; 136 | 137 | for (let i = 0; i < requests.length; ++i) { 138 | test(requests[i][0], requests[i][1]); 139 | } 140 | }); 141 | 142 | describe('add()', () => { 143 | 144 | it('adds a route with id', () => { 145 | 146 | const router = new Call.Router(); 147 | router.add({ method: 'get', path: '/a/b/{c}', id: 'a' }); 148 | expect(router.ids.get('a').path).to.equal('/a/b/{c}'); 149 | }); 150 | 151 | it('sorts mixed paths', () => { 152 | 153 | const paths = [ 154 | '/a{p}b{x}c', 155 | '/ac{p}b', 156 | '/ab{p}b', 157 | '/cc{p}b', 158 | '/a{p}b', 159 | '/a{p}', 160 | '/{p}b', 161 | '/a{p}b{x}' 162 | ]; 163 | 164 | const router = new Call.Router(); 165 | for (const path of paths) { 166 | router.add({ method: 'get', path }, path); 167 | } 168 | 169 | expect(router.routes.get('get').router._mixed.map((item) => [item.segment.segments, item.segment.length])).to.equal([ 170 | [['a', 'b', 'c'], 5], 171 | [['a', 'b'], 4], 172 | [['ab', 'b'], 3], 173 | [['ac', 'b'], 3], 174 | [['cc', 'b'], 3], 175 | [['a', 'b'], 3], 176 | [['a'], 2], 177 | [['b'], 2] 178 | ]); 179 | }); 180 | 181 | it('throws on duplicate route', () => { 182 | 183 | const router = new Call.Router(); 184 | router.add({ method: 'get', path: '/a/b/{c}' }); 185 | expect(() => { 186 | 187 | router.add({ method: 'get', path: '/a/b/{c}' }); 188 | }).to.throw('New route /a/b/{c} conflicts with existing /a/b/{c}'); 189 | }); 190 | 191 | it('throws on duplicate route (id)', () => { 192 | 193 | const router = new Call.Router(); 194 | router.add({ method: 'get', path: '/a/b', id: '1' }); 195 | expect(() => { 196 | 197 | router.add({ method: 'get', path: '/b', id: '1' }); 198 | }).to.throw('Route id 1 for path /b conflicts with existing path /a/b'); 199 | }); 200 | 201 | it('throws on duplicate route (optional param in first)', () => { 202 | 203 | const router = new Call.Router(); 204 | router.add({ method: 'get', path: '/a/b/{c?}' }); 205 | expect(() => { 206 | 207 | router.add({ method: 'get', path: '/a/b' }); 208 | }).to.throw('New route /a/b conflicts with existing /a/b/{c?}'); 209 | }); 210 | 211 | it('throws on duplicate route (optional param in second)', () => { 212 | 213 | const router = new Call.Router(); 214 | router.add({ method: 'get', path: '/a/b' }); 215 | expect(() => { 216 | 217 | router.add({ method: 'get', path: '/a/b/{c?}' }); 218 | }).to.throw('New route /a/b/{c?} conflicts with existing /a/b'); 219 | }); 220 | 221 | it('throws on duplicate route (same fingerprint)', () => { 222 | 223 | const router = new Call.Router(); 224 | router.add({ method: 'get', path: '/test/{p1}/{p2}/end' }); 225 | expect(() => { 226 | 227 | router.add({ method: 'get', path: '/test/{p*2}/end' }); 228 | }).to.throw('New route /test/{p*2}/end conflicts with existing /test/{p1}/{p2}/end'); 229 | }); 230 | 231 | it('throws on duplicate route (case insensitive)', () => { 232 | 233 | const router = new Call.Router({ isCaseSensitive: false }); 234 | router.add({ method: 'get', path: '/test/a' }); 235 | expect(() => { 236 | 237 | router.add({ method: 'get', path: '/test/A' }); 238 | }).to.throw('New route /test/A conflicts with existing /test/a'); 239 | }); 240 | 241 | it('throws on duplicate route (wildcards)', () => { 242 | 243 | const router = new Call.Router(); 244 | router.add({ method: 'get', path: '/a/b/{c*}' }); 245 | expect(() => { 246 | 247 | router.add({ method: 'get', path: '/a/b/{c*}' }); 248 | }).to.throw('New route /a/b/{c*} conflicts with existing /a/b/{c*}'); 249 | }); 250 | 251 | it('throws on duplicate route (mixed)', () => { 252 | 253 | const router = new Call.Router(); 254 | router.add({ method: 'get', path: '/a/b/a{c}' }); 255 | expect(() => { 256 | 257 | router.add({ method: 'get', path: '/a/b/a{c}' }); 258 | }).to.throw('New route /a/b/a{c} conflicts with existing /a/b/a{c}'); 259 | }); 260 | 261 | it('throws on duplicate route (/a/{p}/{q*}, /a/{p*})', () => { 262 | 263 | const router = new Call.Router(); 264 | router.add({ method: 'get', path: '/a/{p}/{q*}' }); 265 | expect(() => { 266 | 267 | router.add({ method: 'get', path: '/a/{p*}' }); 268 | }).to.throw('New route /a/{p*} conflicts with existing /a/{p}/{q*}'); 269 | }); 270 | 271 | it('throws on duplicate route (/a/{p*}, /a/{p}/{q*})', () => { 272 | 273 | const router = new Call.Router(); 274 | router.add({ method: 'get', path: '/a/{p*}' }); 275 | expect(() => { 276 | 277 | router.add({ method: 'get', path: '/a/{p}/{q*}' }); 278 | }).to.throw('New route /a/{p}/{q*} conflicts with existing /a/{p*}'); 279 | }); 280 | 281 | it('allows route to differ in just case', () => { 282 | 283 | const router = new Call.Router(); 284 | router.add({ method: 'get', path: '/test/a' }); 285 | expect(() => { 286 | 287 | router.add({ method: 'get', path: '/test/A' }); 288 | }).to.not.throw(); 289 | }); 290 | 291 | it('throws on duplicate route (different param name)', () => { 292 | 293 | const router = new Call.Router(); 294 | router.add({ method: 'get', path: '/test/{p}' }); 295 | expect(() => { 296 | 297 | router.add({ method: 'get', path: '/test/{P}' }); 298 | }).to.throw('New route /test/{P} conflicts with existing /test/{p}'); 299 | }); 300 | 301 | it('throws on duplicate parameter name', () => { 302 | 303 | const router = new Call.Router(); 304 | expect(() => { 305 | 306 | router.add({ method: 'get', path: '/test/{p}/{p}' }); 307 | }).to.throw('Cannot repeat the same parameter name: p in: /test/{p}/{p}'); 308 | }); 309 | 310 | it('throws on invalid path', () => { 311 | 312 | const router = new Call.Router(); 313 | expect(() => { 314 | 315 | router.add({ method: 'get', path: '/%/%' }); 316 | }).to.throw('Invalid path: /%/%'); 317 | }); 318 | 319 | it('throws on duplicate route (same vhost)', () => { 320 | 321 | const router = new Call.Router(); 322 | router.add({ method: 'get', path: '/a/b/{c}', vhost: 'example.com' }); 323 | expect(() => { 324 | 325 | router.add({ method: 'get', path: '/a/b/{c}', vhost: 'example.com' }); 326 | }).to.throw('New route /a/b/{c} conflicts with existing /a/b/{c}'); 327 | }); 328 | 329 | it('allows duplicate route (different vhost)', () => { 330 | 331 | const router = new Call.Router(); 332 | router.add({ method: 'get', path: '/a/b/{c}', vhost: 'one.example.com' }); 333 | expect(() => { 334 | 335 | router.add({ method: 'get', path: '/a/b/{c}', vhost: 'two.example.com' }); 336 | }).to.not.throw(); 337 | }); 338 | }); 339 | 340 | describe('special()', () => { 341 | 342 | it('returns special not found route', () => { 343 | 344 | const router = new Call.Router(); 345 | router.special('notFound', 'x'); 346 | expect(router.route('get', '/').route).to.equal('x'); 347 | }); 348 | 349 | it('returns special bad request route', () => { 350 | 351 | const router = new Call.Router(); 352 | router.add({ method: 'get', path: '/{p}' }); 353 | router.special('badRequest', 'x'); 354 | expect(router.route('get', '/%p').route).to.equal('x'); 355 | }); 356 | 357 | it('returns special options route', () => { 358 | 359 | const router = new Call.Router(); 360 | router.special('options', 'x'); 361 | expect(router.route('options', '/').route).to.equal('x'); 362 | }); 363 | }); 364 | 365 | describe('route()', () => { 366 | 367 | const paths = { 368 | '/path/to/|false': { 369 | '/path/to': false, 370 | '/Path/to': false, 371 | '/path/to/': true, 372 | '/Path/to/': true 373 | }, 374 | '/path/to/|true': { 375 | '/path/to': false, 376 | '/Path/to': false, 377 | '/path/to/': true, 378 | '/Path/to/': false 379 | }, 380 | '/path/{param*2}/to': { 381 | '/a/b/c/d': false, 382 | '/path/a/b/to': { 383 | param: 'a/b' 384 | } 385 | }, 386 | '/path/{x*}': { 387 | '/a/b/c/d': false, 388 | '/path/a/b/to': { 389 | x: 'a/b/to' 390 | }, 391 | '/path/': { 392 | x: '' 393 | }, 394 | '/path': {} 395 | }, 396 | '/path/{p1}/{p2?}': { 397 | '/path/a/c/d': false, 398 | '/Path/a/c/d': false, 399 | '/path/a/b': { 400 | p1: 'a', 401 | p2: 'b' 402 | }, 403 | '/path/a': { 404 | p1: 'a' 405 | }, 406 | '/path/a/': { 407 | p1: 'a', 408 | p2: '' 409 | } 410 | }, 411 | '/path/{p1}/{p2?}|false': { 412 | '/path/a/c/d': false, 413 | '/Path/a/c': { 414 | p1: 'a', 415 | p2: 'c' 416 | }, 417 | '/path/a': { 418 | p1: 'a' 419 | }, 420 | '/path/a/': { 421 | p1: 'a', 422 | p2: '' 423 | } 424 | }, 425 | '/mixedCase/|false': { 426 | '/mixedcase/': true, 427 | '/mixedCase/': true 428 | }, 429 | '/mixedCase/|true': { 430 | '/mixedcase/': false, 431 | '/mixedCase/': true 432 | }, 433 | '/{p*}': { 434 | '/path/': { 435 | p: 'path/' 436 | } 437 | }, 438 | '/{p}': { 439 | '/path': { 440 | p: 'path' 441 | }, 442 | '/': false 443 | }, 444 | '/{p}/': { 445 | '/path/': { 446 | p: 'path' 447 | }, 448 | '/p': false, 449 | '/': false, 450 | '//': false 451 | }, 452 | '/{p?}': { 453 | '/path': { 454 | p: 'path' 455 | }, 456 | '/': true 457 | }, 458 | '/{a}/b/{p*}': { 459 | '/a/b/path/': { 460 | a: 'a', 461 | p: 'path/' 462 | }, 463 | '//b/path/': false 464 | }, 465 | '/a{b?}c': { 466 | '/abc': { 467 | b: 'b' 468 | }, 469 | '/ac': { 470 | b: '' 471 | }, 472 | '/abC': false, 473 | '/Ac': false 474 | }, 475 | '/a{b?}c|false': { 476 | '/abC': { 477 | b: 'b' 478 | }, 479 | '/Ac': { 480 | b: '' 481 | } 482 | }, 483 | '/%0A': { 484 | '/%0A': true, 485 | '/%0a': true 486 | }, 487 | '/a/b/{c}': { 488 | '/a/b/c': true, 489 | '/a/b': false, 490 | '/a/b/': false 491 | }, 492 | '/a/{b}/c|false': { 493 | '/a/1/c': { 494 | b: '1' 495 | }, 496 | '/A/1/c': { 497 | b: '1' 498 | }, 499 | '/a//c': false 500 | }, 501 | '/a/{B}/c|false': { 502 | '/a/1/c': { 503 | B: '1' 504 | }, 505 | '/A/1/c': { 506 | B: '1' 507 | } 508 | }, 509 | '/a/{b}/c|true': { 510 | '/a/1/c': { 511 | b: '1' 512 | }, 513 | '/A/1/c': false 514 | }, 515 | '/a/{B}/c|true': { 516 | '/a/1/c': { 517 | B: '1' 518 | }, 519 | '/A/1/c': false 520 | }, 521 | '/aB/{p}|true': { 522 | '/aB/4': { 523 | p: '4' 524 | }, 525 | '/ab/4': false 526 | }, 527 | '/aB/{p}|false': { 528 | '/aB/4': { 529 | p: '4' 530 | }, 531 | '/ab/4': { 532 | p: '4' 533 | } 534 | }, 535 | '/{a}b{c?}d{e}|true': { 536 | '/abcde': { 537 | a: 'a', 538 | c: 'c', 539 | e: 'e' 540 | }, 541 | '/abde': { 542 | a: 'a', 543 | c: '', 544 | e: 'e' 545 | }, 546 | '/abxyzde': { 547 | a: 'a', 548 | c: 'xyz', 549 | e: 'e' 550 | }, 551 | '/aBcde': false, 552 | '/bcde': false 553 | }, 554 | '/a/{p}/b': { 555 | '/a/': false 556 | } 557 | }; 558 | 559 | const test = function (path, matches, isCaseSensitive) { 560 | 561 | const router = new Call.Router({ isCaseSensitive }); 562 | router.add({ path, method: 'get' }, path); 563 | 564 | const mkeys = Object.keys(matches); 565 | for (let i = 0; i < mkeys.length; ++i) { 566 | match(router, path, mkeys[i], matches[mkeys[i]], isCaseSensitive); 567 | } 568 | }; 569 | 570 | const match = function (router, path, compare, result, isCaseSensitive) { 571 | 572 | it((result ? 'matches' : 'unmatches') + ' the path \'' + path + '\' with ' + compare + ' (' + (isCaseSensitive ? 'case-sensitive' : 'case-insensitive') + ')', () => { 573 | 574 | const output = router.route('get', router.normalize(compare)); 575 | const isMatch = !output.isBoom; 576 | 577 | expect(isMatch).to.equal(!!result); 578 | if (typeof result === 'object') { 579 | const ps = Object.keys(result); 580 | expect(ps.length).to.equal(output.paramsArray.length); 581 | 582 | for (let i = 0; i < ps.length; ++i) { 583 | expect(output.params[ps[i]]).to.equal(result[ps[i]]); 584 | } 585 | } 586 | }); 587 | }; 588 | 589 | const keys = Object.keys(paths); 590 | for (let i = 0; i < keys.length; ++i) { 591 | const pathParts = keys[i].split('|'); 592 | const sensitive = (pathParts[1] ? pathParts[1] === 'true' : true); 593 | test(pathParts[0], paths[keys[i]], sensitive); 594 | } 595 | 596 | it('matches head routes', () => { 597 | 598 | const router = new Call.Router(); 599 | router.add({ method: 'get', path: '/a' }, 'a'); 600 | router.add({ method: 'get', path: '/a', vhost: 'special.example.com' }, 'b'); 601 | router.add({ method: 'get', path: '/b', vhost: 'special.example.com' }, 'c'); 602 | router.add({ method: 'head', path: '/a' }, 'd'); 603 | router.add({ method: 'head', path: '/a', vhost: 'special.example.com' }, 'e'); 604 | router.add({ method: 'get', path: '/b', vhost: 'x.example.com' }, 'f'); 605 | router.add({ method: 'get', path: '/c' }, 'g'); 606 | 607 | expect(router.route('get', '/a').route).to.equal('a'); 608 | expect(router.route('get', '/a', 'special.example.com').route).to.equal('b'); 609 | expect(router.route('head', '/a').route).to.equal('d'); 610 | expect(router.route('head', '/a', 'special.example.com').route).to.equal('e'); 611 | expect(router.route('head', '/b', 'special.example.com').route).to.equal('c'); 612 | expect(router.route('head', '/c', 'x.example.com').route).to.equal('g'); 613 | }); 614 | 615 | it('matches * routes', () => { 616 | 617 | const router = new Call.Router(); 618 | router.add({ method: '*', path: '/a' }, 'a'); 619 | router.add({ method: '*', path: '/a', vhost: 'special.example.com' }, 'b'); 620 | 621 | expect(router.route('get', '/a').route).to.equal('a'); 622 | expect(router.route('get', '/a', 'special.example.com').route).to.equal('b'); 623 | }); 624 | 625 | it('fails to match head request', () => { 626 | 627 | const router = new Call.Router(); 628 | expect(router.route('head', '/').output.statusCode).to.equal(404); 629 | }); 630 | 631 | it('fails to match options request', () => { 632 | 633 | const router = new Call.Router(); 634 | expect(router.route('options', '/').output.statusCode).to.equal(404); 635 | }); 636 | 637 | it('fails to match get request with vhost (table exists but not route)', () => { 638 | 639 | const router = new Call.Router(); 640 | router.add({ method: 'get', path: '/', vhost: 'special.example.com' }); 641 | expect(router.route('get', '/x', 'special.example.com').output.statusCode).to.equal(404); 642 | }); 643 | 644 | it('fails to match head request with vhost (table exists but not route)', () => { 645 | 646 | const router = new Call.Router(); 647 | router.add({ method: 'head', path: '/', vhost: 'special.example.com' }); 648 | expect(router.route('head', '/x', 'special.example.com').output.statusCode).to.equal(404); 649 | }); 650 | 651 | it('fails to match bad request', () => { 652 | 653 | const router = new Call.Router(); 654 | router.add({ method: 'get', path: '/{p}' }); 655 | expect(router.route('get', '/%p').output.statusCode).to.equal(400); 656 | }); 657 | 658 | it('fails to match bad request (mixed)', () => { 659 | 660 | const router = new Call.Router(); 661 | router.add({ method: 'get', path: '/a{p}' }); 662 | expect(router.route('get', '/a%p').output.statusCode).to.equal(400); 663 | }); 664 | 665 | it('fails to match bad request (wildcard)', () => { 666 | 667 | const router = new Call.Router(); 668 | router.add({ method: 'get', path: '/{p*}' }); 669 | expect(router.route('get', '/%p').output.statusCode).to.equal(400); 670 | }); 671 | 672 | it('fails to match bad request (deep)', () => { 673 | 674 | const router = new Call.Router(); 675 | router.add({ method: 'get', path: '/a/{p}' }); 676 | expect(router.route('get', '/a/%p').output.statusCode).to.equal(400); 677 | }); 678 | 679 | it('fails to match js object prototype properties for literals', () => { 680 | 681 | const router = new Call.Router(); 682 | router.add({ method: 'get', path: '/a/{b}' }, '/'); 683 | expect(router.route('get', '/constructor/').output.statusCode).to.equal(404); 684 | expect(router.route('get', '/hasOwnProperty/').output.statusCode).to.equal(404); 685 | }); 686 | }); 687 | 688 | describe('normalize()', () => { 689 | 690 | it('normalizes a path', () => { 691 | 692 | const rawPath = '/%0%1%2%3%4%5%6%7%8%9%a%b%c%d%e%f%10%11%12%13%14%15%16%17%18%19%1a%1b%1c%1d%1e%1f%20%21%22%23%24%25%26%27%28%29%2a%2b%2c%2d%2e%2f%30%31%32%33%34%35%36%37%38%39%3a%3b%3c%3d%3e%3f%40%41%42%43%44%45%46%47%48%49%4a%4b%4c%4d%4e%4f%50%51%52%53%54%55%56%57%58%59%5a%5b%5c%5d%5e%5f%60%61%62%63%64%65%66%67%68%69%6a%6b%6c%6d%6e%6f%70%71%72%73%74%75%76%77%78%79%7a%7b%7c%7d%7e%7f%80%81%82%83%84%85%86%87%88%89%8a%8b%8c%8d%8e%8f%90%91%92%93%94%95%96%97%98%99%9a%9b%9c%9d%9e%9f%a0%a1%a2%a3%a4%a5%a6%a7%a8%a9%aa%ab%ac%ad%ae%af%b0%b1%b2%b3%b4%b5%b6%b7%b8%b9%ba%bb%bc%bd%be%bf%c0%c1%c2%c3%c4%c5%c6%c7%c8%c9%ca%cb%cc%cd%ce%cf%d0%d1%d2%d3%d4%d5%d6%d7%d8%d9%da%db%dc%dd%de%df%e0%e1%e2%e3%e4%e5%e6%e7%e8%e9%ea%eb%ec%ed%ee%ef%f0%f1%f2%f3%f4%f5%f6%f7%f8%f9%fa%fb%fc%fd%fe%ff%0%1%2%3%4%5%6%7%8%9%A%B%C%D%E%F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20%21%22%23%24%25%26%27%28%29%2A%2B%2C%2D%2E%2F%30%31%32%33%34%35%36%37%38%39%3A%3B%3C%3D%3E%3F%40%41%42%43%44%45%46%47%48%49%4A%4B%4C%4D%4E%4F%50%51%52%53%54%55%56%57%58%59%5A%5B%5C%5D%5E%5F%60%61%62%63%64%65%66%67%68%69%6A%6B%6C%6D%6E%6F%70%71%72%73%74%75%76%77%78%79%7A%7B%7C%7D%7E%7F%80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F%90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F%A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF%B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF%C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF%D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF%E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF%F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF'; 693 | const normPath = '/%0%1%2%3%4%5%6%7%8%9%a%b%c%d%e%f%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%25&\'()*+,-.%2F0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~%7F%80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F%90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F%A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF%B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF%C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF%D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF%E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF%F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF%0%1%2%3%4%5%6%7%8%9%A%B%C%D%E%F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%25&\'()*+,-.%2F0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~%7F%80%81%82%83%84%85%86%87%88%89%8A%8B%8C%8D%8E%8F%90%91%92%93%94%95%96%97%98%99%9A%9B%9C%9D%9E%9F%A0%A1%A2%A3%A4%A5%A6%A7%A8%A9%AA%AB%AC%AD%AE%AF%B0%B1%B2%B3%B4%B5%B6%B7%B8%B9%BA%BB%BC%BD%BE%BF%C0%C1%C2%C3%C4%C5%C6%C7%C8%C9%CA%CB%CC%CD%CE%CF%D0%D1%D2%D3%D4%D5%D6%D7%D8%D9%DA%DB%DC%DD%DE%DF%E0%E1%E2%E3%E4%E5%E6%E7%E8%E9%EA%EB%EC%ED%EE%EF%F0%F1%F2%F3%F4%F5%F6%F7%F8%F9%FA%FB%FC%FD%FE%FF'; 694 | 695 | const router = new Call.Router(); 696 | expect(router.normalize(rawPath)).to.equal(normPath); 697 | }); 698 | 699 | it('applies path segment normalization', () => { 700 | 701 | const paths = { 702 | './bar': 'bar', 703 | '../bar': 'bar', 704 | '.././bar': 'bar', 705 | 'foo/bar/..': 'foo/', 706 | '..': '', 707 | '.': '', 708 | './': '', 709 | './/': '/', 710 | '/foo/./bar': '/foo/bar', 711 | '/foo/%2e/bar': '/foo/bar', 712 | '/bar/.': '/bar/', 713 | '/bar/./': '/bar/', 714 | '/bar/..': '/', 715 | '/bar/../': '/', 716 | '/bar/../.': '/', 717 | '/foo/../bar': '/bar', 718 | '/foo/./../bar': '/bar', 719 | '/foo/bar/..': '/foo/', 720 | '/..': '/', 721 | '/../': '/', 722 | '/.': '/', 723 | '/./': '/', 724 | '//.': '//', 725 | '//./': '//', 726 | '//../': '/' 727 | }; 728 | 729 | const router = new Call.Router(); 730 | const keys = Object.keys(paths); 731 | for (let i = 0; i < keys.length; ++i) { 732 | expect(router.normalize(keys[i])).to.equal(paths[keys[i]]); 733 | } 734 | }); 735 | 736 | it('does not transform specific paths', () => { 737 | 738 | const paths = [ 739 | '', 740 | '//', 741 | '%2F', 742 | '.bar', 743 | '.bar/', 744 | '.foo/bar', 745 | 'foo/.bar', 746 | 'foo/.bar/', 747 | 'foo/.bar/baz', 748 | '/.bar', 749 | '/.bar/', 750 | '/.foo/bar', 751 | '/foo/.bar', 752 | '/foo/.bar/', 753 | '/foo/.bar/baz' 754 | ]; 755 | 756 | const router = new Call.Router(); 757 | for (let i = 0; i < paths.length; ++i) { 758 | expect(router.normalize(paths[i])).to.equal(paths[i]); 759 | } 760 | }); 761 | }); 762 | 763 | describe('analyze()', () => { 764 | 765 | it('generates fingerprints', () => { 766 | 767 | const paths = { 768 | '/': '/', 769 | '/path': '/path', 770 | '/path/': '/path/', 771 | '/path/to/somewhere': '/path/to/somewhere', 772 | '/{param}': '/?', 773 | '/{param?}': '/?', 774 | '/{param*}': '/#', 775 | '/{param*5}': '/?/?/?/?/?', 776 | '/path/{param}': '/path/?', 777 | '/path/{param}/to': '/path/?/to', 778 | '/path/{param?}': '/path/?', 779 | '/path/{param}/to/{some}': '/path/?/to/?', 780 | '/path/{param}/to/{some?}': '/path/?/to/?', 781 | '/path/{param*2}/to': '/path/?/?/to', 782 | '/path/{param*}': '/path/#', 783 | '/path/{param*10}/to': '/path/?/?/?/?/?/?/?/?/?/?/to', 784 | '/path/{param*2}': '/path/?/?', 785 | '/%20path/': '/%20path/', 786 | '/a{p}': '/a?', 787 | '/{p}b': '/?b', 788 | '/a{p}b': '/a?b', 789 | '/a{p?}': '/a?', 790 | '/{p?}b': '/?b', 791 | '/a{p?}b': '/a?b' 792 | }; 793 | 794 | const router = new Call.Router({ isCaseSensitive: true }); 795 | const keys = Object.keys(paths); 796 | for (let i = 0; i < keys.length; ++i) { 797 | expect(router.analyze(keys[i]).fingerprint).to.equal(paths[keys[i]]); 798 | } 799 | }); 800 | }); 801 | 802 | describe('table()', () => { 803 | 804 | it('returns an array of the current routes', () => { 805 | 806 | const router = new Call.Router(); 807 | router.add({ path: '/test/', method: 'get' }); 808 | router.add({ path: '/test/{p}/end', method: 'get' }); 809 | 810 | const routes = router.table(); 811 | 812 | expect(routes.length).to.equal(2); 813 | expect(routes[0]).to.equal('/test/'); 814 | }); 815 | 816 | it('combines global and vhost routes', () => { 817 | 818 | const router = new Call.Router(); 819 | 820 | router.add({ path: '/test/', method: 'get' }); 821 | router.add({ path: '/test/', vhost: 'one.example.com', method: 'get' }); 822 | router.add({ path: '/test/', vhost: 'two.example.com', method: 'get' }); 823 | router.add({ path: '/test/{p}/end', method: 'get' }); 824 | 825 | const routes = router.table(); 826 | 827 | expect(routes.length).to.equal(4); 828 | }); 829 | 830 | it('combines global and vhost routes and filters based on host', () => { 831 | 832 | const router = new Call.Router(); 833 | 834 | router.add({ path: '/test/', method: 'get' }); 835 | router.add({ path: '/test/', vhost: 'one.example.com', method: 'get' }); 836 | router.add({ path: '/test/', vhost: 'two.example.com', method: 'get' }); 837 | router.add({ path: '/test/{p}/end', method: 'get' }); 838 | 839 | const routes = router.table('one.example.com'); 840 | 841 | expect(routes.length).to.equal(3); 842 | }); 843 | 844 | it('accepts a list of hosts', () => { 845 | 846 | const router = new Call.Router(); 847 | 848 | router.add({ path: '/test/', method: 'get' }); 849 | router.add({ path: '/test/', vhost: 'one.example.com', method: 'get' }); 850 | router.add({ path: '/test/', vhost: 'two.example.com', method: 'get' }); 851 | router.add({ path: '/test/{p}/end', method: 'get' }); 852 | 853 | const routes = router.table(['one.example.com', 'two.example.com']); 854 | 855 | expect(routes.length).to.equal(4); 856 | }); 857 | 858 | it('ignores unknown host', () => { 859 | 860 | const router = new Call.Router(); 861 | 862 | router.add({ path: '/test/', method: 'get' }); 863 | router.add({ path: '/test/', vhost: 'one.example.com', method: 'get' }); 864 | router.add({ path: '/test/', vhost: 'two.example.com', method: 'get' }); 865 | router.add({ path: '/test/{p}/end', method: 'get' }); 866 | 867 | const routes = router.table('three.example.com'); 868 | 869 | expect(routes.length).to.equal(2); 870 | }); 871 | }); 872 | }); 873 | -------------------------------------------------------------------------------- /test/regex.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | 6 | const Regex = require('../lib/regex'); 7 | 8 | 9 | const internals = {}; 10 | 11 | 12 | const { describe, it } = exports.lab = Lab.script(); 13 | const expect = Code.expect; 14 | 15 | 16 | describe('Call', () => { 17 | 18 | describe('Regex', () => { 19 | 20 | const pathRegex = Regex.generate(); 21 | 22 | describe('validatePath', () => { 23 | 24 | const paths = { 25 | '/': true, 26 | '/path': true, 27 | '/path/': true, 28 | '/path/to/somewhere': true, 29 | '/{param}': true, 30 | '/{param?}': true, 31 | '/{param*}': true, 32 | '/{param*5}': true, 33 | '/path/{param}': true, 34 | '/path/{param}/to': true, 35 | '/path/{param?}': true, 36 | '/path/{param}/to/{some}': true, 37 | '/path/{param}/to/{some?}': true, 38 | '/path/{param*2}/to': true, 39 | '/path/{param*27}/to': true, 40 | '/path/{param*2}': true, 41 | '/path/{param*27}': true, 42 | '/%20path/': true, 43 | 'path': false, 44 | '/%path/': false, 45 | '/path/{param*}/to': false, 46 | '/path/{param*0}/to': false, 47 | '/path/{param*0}': false, 48 | '/path/{param*01}/to': false, 49 | '/path/{param*01}': false, 50 | '/{param?}/something': false, 51 | '/{param*03}': false, 52 | '/{param*3?}': false, 53 | '/{param*?}': false, 54 | '/{param*}/': false, 55 | '/a{p}': true, 56 | '/{p}b': true, 57 | '/a{p}b': true, 58 | '/d/a{p}': true, 59 | '/d/{p}b': true, 60 | '/d/a{p}b': true, 61 | '/a{p}/d': true, 62 | '/{p}b/d': true, 63 | '/a{p}b/d': true, 64 | '/d/a{p}/e': true, 65 | '/d/{p}b/e': true, 66 | '/d/a{p}b/e': true, 67 | '/a{p}.{x}': true, 68 | '/{p}{x}': false, 69 | '/a{p}{x}': false, 70 | '/a{p}{x}b': false, 71 | '/{p}{x}b': false, 72 | '/{p?}{x}b': false, 73 | '/{a}b{c?}d{e}': true, 74 | '/a{p?}': true, 75 | '/{p*}d': false, 76 | '/a{p*3}': false 77 | }; 78 | 79 | const test = function (path, isValid) { 80 | 81 | it('validates the path \'' + path + '\' as ' + (isValid ? 'well-formed' : 'malformed'), () => { 82 | 83 | expect(!!(path.match(pathRegex.validatePath))).to.equal(isValid); 84 | }); 85 | }; 86 | 87 | const keys = Object.keys(paths); 88 | for (let i = 0; i < keys.length; ++i) { 89 | test(keys[i], paths[keys[i]]); 90 | } 91 | }); 92 | }); 93 | }); 94 | --------------------------------------------------------------------------------