├── .gitignore ├── README.md ├── package.json └── uniloc.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uniloc 2 | 3 | Uniloc is a utility to match URIs to named routes, and to generate URIs given a route name and options. 4 | 5 | In contrast to many popular routing systems, uniloc's behavior is not affected by the order in which routes are specified. 6 | 7 | Additionally, by assuming that each location in your app has a single canonical URI/method, and assuming the format of your URIs follow a number of fairly uncontroversial conventions, uniloc is able to detect conflicts in your config which may would have resulted in hours of debugging pain in a first-match-wins system. 8 | 9 | ## Installation 10 | 11 | Use as a [standalone file](https://raw.githubusercontent.com/unicorn-standard/uniloc/master/uniloc.js), or install with npm: 12 | 13 | ``` 14 | npm install uniloc 15 | ``` 16 | 17 | ## Example 18 | 19 | ```javascript 20 | /* 21 | * Configure routes and aliases 22 | */ 23 | 24 | var ROUTER = uniloc( 25 | // Routes 26 | { 27 | listContacts: 'GET /contacts', 28 | postContact: 'POST /contacts', 29 | editContact: 'GET /contacts/:id/edit', 30 | }, 31 | 32 | // Aliases 33 | { 34 | 'GET /': 'listContacts', 35 | } 36 | ); 37 | 38 | 39 | /* 40 | * Lookup URIs 41 | */ 42 | 43 | ROUTER.lookup('/contacts/13/edit?details=true') 44 | // {name: 'editContact', options: {id: 13, details: true}} 45 | 46 | ROUTER.lookup('/?page=10') 47 | // {name: 'listContacts', options: {page: 10}} 48 | 49 | ROUTER.lookup('/?page=10', 'PATCH') 50 | // null 51 | 52 | 53 | /* 54 | * Generate URIs 55 | */ 56 | 57 | ROUTER.generate('listContacts', {page: 10}) 58 | // '/contacts?page=10' 59 | 60 | ROUTER.generate('editContact', {id: 'james'}) 61 | // '/contacts/james/edit' 62 | ``` 63 | 64 | ## Location strings 65 | 66 | Uniloc assumes that each of the locations in your app is associated with a single HTTP method, and a single URI or set of URIs where the only varying parts represent the **route parameters**. 67 | 68 | Given this assumption, it is possible to represent each location in the application with a **location string**, which includes the HTTP method, and a URI where the varying parts are marked as parameters. For example: 69 | 70 | ``` 71 | PATCH /contacts/:contactId/photos/:photoId 72 | ``` 73 | 74 | ### Format 75 | 76 | - Location strings must consist of a HTTP method and URI template joined by whitespace 77 | - The HTTP method must be in UPPERCASE 78 | - The URI template must start with a `/`, end without a `/`, and must not include the `?` or `#` characters 79 | - Route parameters are delineated with `/` character or the end of the string, and start with a `:` character followed by the route parameter's name. 80 | 81 | ## API 82 | 83 | ### `uniloc(routes, aliases={}) -> {lookup, generate}` 84 | 85 | `uniloc` takes two objects: 86 | 87 | - `routes`: an object mapping route names to their canonical location string 88 | - `aliases`: *optional* an object mapping other location strings to the routes named in `routes` 89 | 90 | Route names are just strings. The `.` character is reserved, as it may be used in a future release to specify hierarchy. 91 | 92 | While two routes or aliases cannot use the same location string, a route without a route parameter in one position *can* overlap another URI with a route parameter in the same position. In this case, the route without the route parameter will be given priority. 93 | 94 | #### Example 95 | 96 | ```javascript 97 | var ROUTER = uniloc( 98 | // Routes 99 | { 100 | listContacts: 'GET /contacts', 101 | postContact: 'POST /contacts', 102 | editContact: 'GET /contacts/:id/edit', 103 | }, 104 | 105 | // Aliases 106 | { 107 | 'GET /': 'listContacts', 108 | } 109 | ); 110 | ``` 111 | 112 | ### `lookup(URI, method='GET') -> {name, options}` 113 | 114 | `lookup` is one of the properties of the object returned by `uniloc`. 115 | 116 | `lookup` takes a URI and HTTP method, and returns the route name which matches that URI/method, with an options object containing any route and query parameters. If no location is defined which matches the passed in URI/method, it returns null. 117 | 118 | #### Examples 119 | 120 | ```javascript 121 | ROUTER.lookup('/contacts/13/edit?details=true') 122 | // Returns {name: 'editContact', options: {id: 13, details: true}} 123 | 124 | ROUTER.lookup('/?page=10') 125 | // Returns {name: 'listContacts', options: {page: 10}} 126 | 127 | ROUTER.lookup('/?page=10', 'PATCH') 128 | // Returns null 129 | 130 | ``` 131 | 132 | ### `generate(name, options) -> URI` 133 | 134 | `generate` is one of the properties of the object returned by `uniloc`. 135 | 136 | `generate` takes a route name and options object, and returns a URI string 137 | based on the canonical URI for the given route. Options are substituted in 138 | for route parameters where possible, and the remaining options are appended 139 | as a query string. 140 | 141 | If the given route name doesn't exist, an exception is thrown. 142 | 143 | #### Examples 144 | 145 | ```javascript 146 | ROUTER.generate('listContacts', {page: 10}) 147 | // Returns '/contacts?page=10' 148 | 149 | ROUTER.generate('editContact', {id: 'james'}) 150 | // Returns '/contacts/james/edit' 151 | 152 | ROUTER.generate('editFoo', {id: 'james'}) 153 | // Exception! 154 | ``` 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uniloc", 3 | "version": "0.3.0", 4 | "description": "Universal JavaScript Route Parsing and Generation in 1.3KB compressed/minified", 5 | "main": "uniloc.js", 6 | "scripts": { 7 | "test": "npm run test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/unicorn-standard/uniloc.git" 12 | }, 13 | "keywords": [ 14 | "uniloc", 15 | "routing", 16 | "unicorn-standard", 17 | "tiny" 18 | ], 19 | "author": "James K Nelson ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/unicorn-standard/uniloc/issues" 23 | }, 24 | "homepage": "http://unicornstandard.com/packages/uniloc.html" 25 | } 26 | -------------------------------------------------------------------------------- /uniloc.js: -------------------------------------------------------------------------------- 1 | (function(root) { 2 | function assert(condition, format) { 3 | if (!condition) { 4 | var args = [].slice.call(arguments, 2); 5 | var argIndex = 0; 6 | throw new Error( 7 | 'Unirouter Assertion Failed: ' + 8 | format.replace(/%s/g, function() { return args[argIndex++]; }) 9 | ); 10 | } 11 | } 12 | 13 | function pathParts(path) { 14 | return path == '' ? [] : path.split('/') 15 | } 16 | 17 | function routeParts(route) { 18 | var split = route.split(/\s+/) 19 | var method = split[0] 20 | var path = split[1] 21 | 22 | // Validate route format 23 | assert( 24 | split.length == 2, 25 | "Route `%s` separates method and path with a single block of whitespace", route 26 | ) 27 | 28 | // Validate method format 29 | assert( 30 | /^[A-Z]+$/.test(method), 31 | "Route `%s` starts with an UPPERCASE method", route 32 | ) 33 | 34 | // Validate path format 35 | assert( 36 | !/\/{2,}/.test(path), 37 | "Path `%s` has no adjacent `/` characters: `%s`", path 38 | ) 39 | assert( 40 | path[0] == '/', 41 | "Path `%s` must start with the `/` character", path 42 | ) 43 | assert( 44 | path == '/' || !/\/$/.test(path), 45 | "Path `%s` does not end with the `/` character", path 46 | ) 47 | assert( 48 | path.indexOf('#') === -1 && path.indexOf('?') === -1, 49 | "Path `%s` does not contain the `#` or `?` characters", path 50 | ) 51 | 52 | return pathParts(path.slice(1)).concat(method) 53 | } 54 | 55 | 56 | function LookupTree() { 57 | this.tree = {} 58 | } 59 | 60 | function lookupTreeReducer(tree, part) { 61 | return tree && (tree[part] || tree[':']) 62 | } 63 | 64 | LookupTree.prototype.find = function(parts) { 65 | return (parts.reduce(lookupTreeReducer, this.tree) || {})[''] 66 | } 67 | 68 | LookupTree.prototype.add = function(parts, route) { 69 | var i, branch 70 | var branches = parts.map(function(part) { return part[0] == ':' ? ':' : part }) 71 | var currentTree = this.tree 72 | 73 | for (i = 0; i < branches.length; i++) { 74 | branch = branches[i] 75 | if (!currentTree[branch]) { 76 | currentTree[branch] = {} 77 | } 78 | currentTree = currentTree[branch] 79 | } 80 | 81 | assert( 82 | !currentTree[branch], 83 | "Path `%s` conflicts with another path", parts.join('/') 84 | ) 85 | 86 | currentTree[''] = route 87 | } 88 | 89 | 90 | function createRouter(routes, aliases) { 91 | var parts, name, route; 92 | var routesParams = {}; 93 | var lookupTree = new LookupTree; 94 | 95 | // By default, there are no aliases 96 | aliases = aliases || {}; 97 | 98 | // Copy routes into lookup tree 99 | for (name in routes) { 100 | if (routes.hasOwnProperty(name)) { 101 | route = routes[name] 102 | 103 | assert( 104 | typeof route == 'string', 105 | "Route '%s' must be a string", name 106 | ) 107 | assert( 108 | name.indexOf('.') == -1, 109 | "Route names must not contain the '.' character", name 110 | ) 111 | 112 | parts = routeParts(route) 113 | 114 | routesParams[name] = parts 115 | .map(function(part, i) { return part[0] == ':' && [part.substr(1), i] }) 116 | .filter(function(x) { return x }) 117 | 118 | lookupTree.add(parts, name) 119 | } 120 | } 121 | 122 | // Copy aliases into lookup tree 123 | for (route in aliases) { 124 | if (aliases.hasOwnProperty(route)) { 125 | name = aliases[route] 126 | 127 | assert( 128 | routes[name], 129 | "Alias from '%s' to non-existent route '%s'.", route, name 130 | ) 131 | 132 | lookupTree.add(routeParts(route), name); 133 | } 134 | } 135 | 136 | 137 | return { 138 | lookup: function(uri, method) { 139 | method = method ? method.toUpperCase() : 'GET' 140 | 141 | var i, x 142 | 143 | var split = uri 144 | // Strip leading and trailing '/' (at end or before query string) 145 | .replace(/^\/|\/($|\?)/g, '') 146 | // Strip fragment identifiers 147 | .replace(/#.*$/, '') 148 | .split('?', 2) 149 | 150 | var parts = pathParts(split[0]).map(decodeURIComponent).concat(method) 151 | var name = lookupTree.find(parts) 152 | if (!name) { 153 | return null 154 | } 155 | var options = {} 156 | var params, queryParts 157 | 158 | params = routesParams[name] || [] 159 | queryParts = split[1] ? split[1].split('&') : [] 160 | 161 | for (i = 0; i != queryParts.length; i++) { 162 | x = queryParts[i].split('=') 163 | options[x[0]] = decodeURIComponent(x[1]) 164 | } 165 | 166 | // Named parameters overwrite query parameters 167 | for (i = 0; i != params.length; i++) { 168 | x = params[i] 169 | options[x[0]] = parts[x[1]] 170 | } 171 | 172 | return {name: name, options: options} 173 | }, 174 | 175 | 176 | generate: function(name, options) { 177 | options = options || {} 178 | 179 | var params = routesParams[name] || [] 180 | var paramNames = params.map(function(x) { return x[0]; }) 181 | var route = routes[name] 182 | var query = [] 183 | var inject = [] 184 | var key 185 | 186 | assert(route, "No route with name `%s` exists", name) 187 | 188 | var path = route.split(' ')[1] 189 | 190 | for (key in options) { 191 | if (options.hasOwnProperty(key)) { 192 | if (paramNames.indexOf(key) === -1) { 193 | assert( 194 | /^[a-zA-Z0-9-_]+$/.test(key), 195 | "Non-route parameters must use only the following characters: A-Z, a-z, 0-9, -, _" 196 | ) 197 | 198 | query.push(key+'='+encodeURIComponent(options[key])) 199 | } 200 | else { 201 | inject.push(key) 202 | } 203 | } 204 | } 205 | 206 | assert( 207 | inject.sort().join() == paramNames.slice(0).sort().join(), 208 | "You must specify options for all route params when using `uri`." 209 | ) 210 | 211 | var uri = 212 | paramNames.reduce(function pathReducer(injected, key) { 213 | return injected.replace(':'+key, encodeURIComponent(options[key])) 214 | }, path) 215 | 216 | if (query.length) { 217 | uri += '?' + query.join('&') 218 | } 219 | 220 | return uri 221 | } 222 | }; 223 | } 224 | 225 | 226 | if (typeof module !== 'undefined' && module.exports) { 227 | module.exports = createRouter 228 | } 229 | else { 230 | root.unirouter = createRouter 231 | } 232 | })(this); 233 | --------------------------------------------------------------------------------