From d5bd6562fab9ab34184e279ebbfc7b54b6200817 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Tue, 19 Aug 2014 11:53:53 -0700 Subject: [PATCH] [changed] path matching algorithm [added] Support for ? in paths [changed] :param no longer matches . [added] Support for arrays in query strings Fixes #142 --- modules/helpers/Path.js | 127 +++++++++++++++++++++------------------- modules/helpers/URL.js | 22 ------- package.json | 2 +- specs/Path.spec.js | 77 +++++++++++++++++------- 4 files changed, 122 insertions(+), 106 deletions(-) delete mode 100644 modules/helpers/URL.js diff --git a/modules/helpers/Path.js b/modules/helpers/Path.js index 19d6f26a8f..052ee07d89 100644 --- a/modules/helpers/Path.js +++ b/modules/helpers/Path.js @@ -1,112 +1,117 @@ var invariant = require('react/lib/invariant'); -var copyProperties = require('react/lib/copyProperties'); -var qs = require('querystring'); -var URL = require('./URL'); +var merge = require('qs/lib/utils').merge; +var qs = require('qs'); -var paramMatcher = /((?::[a-z_$][a-z0-9_$]*)|\*)/ig; -var queryMatcher = /\?(.+)/; - -function getParamName(pathSegment) { - return pathSegment === '*' ? 'splat' : pathSegment.substr(1); +function encodeURL(url) { + return encodeURIComponent(url).replace(/%20/g, '+'); } -var _compiledPatterns = {}; +function decodeURL(url) { + return decodeURIComponent(url.replace(/\+/g, ' ')); +} -function compilePattern(pattern) { - if (_compiledPatterns[pattern]) - return _compiledPatterns[pattern]; +function encodeURLPath(path) { + return String(path).split('/').map(encodeURL).join('/'); +} - var compiled = _compiledPatterns[pattern] = {}; - var paramNames = compiled.paramNames = []; +var paramMatcher = /:([a-zA-Z_$][a-zA-Z0-9_$]*)|[*.()\[\]\\+|{}^$]/g; +var queryMatcher = /\?(.+)/; - var source = pattern.replace(paramMatcher, function (match, pathSegment) { - paramNames.push(getParamName(pathSegment)); - return pathSegment === '*' ? '(.*?)' : '([^/?#]+)'; - }); +var _compiledPatterns = {}; - compiled.matcher = new RegExp('^' + source + '$', 'i'); +function compilePattern(pattern) { + if (!(pattern in _compiledPatterns)) { + var paramNames = []; + var source = pattern.replace(paramMatcher, function (match, paramName) { + if (paramName) { + paramNames.push(paramName); + return '([^./?#]+)'; + } else if (match === '*') { + paramNames.push('splat'); + return '(.*?)'; + } else { + return '\\' + match; + } + }); - return compiled; -} + _compiledPatterns[pattern] = { + matcher: new RegExp('^' + source + '$', 'i'), + paramNames: paramNames + }; + } -function isDynamicPattern(pattern) { - return pattern.indexOf(':') !== -1 || pattern.indexOf('*') !== -1; + return _compiledPatterns[pattern]; } var Path = { + /** + * Returns an array of the names of all parameters in the given pattern. + */ + extractParamNames: function (pattern) { + return compilePattern(pattern).paramNames; + }, + /** * Extracts the portions of the given URL path that match the given pattern * and returns an object of param name => value pairs. Returns null if the * pattern does not match the given path. */ extractParams: function (pattern, path) { - if (!pattern) - return null; - - if (!isDynamicPattern(pattern)) { - if (pattern === URL.decode(path)) - return {}; // No dynamic segments, but the paths match. - - return null; - } - - var compiled = compilePattern(pattern); - var match = URL.decode(path).match(compiled.matcher); + var object = compilePattern(pattern); + var match = decodeURL(path).match(object.matcher); if (!match) return null; var params = {}; - compiled.paramNames.forEach(function (paramName, index) { + object.paramNames.forEach(function (paramName, index) { params[paramName] = match[index + 1]; }); return params; }, - /** - * Returns an array of the names of all parameters in the given pattern. - */ - extractParamNames: function (pattern) { - if (!pattern) - return []; - return compilePattern(pattern).paramNames; - }, - /** * Returns a version of the given route path with params interpolated. Throws * if there is a dynamic segment of the route path for which there is no param. */ injectParams: function (pattern, params) { - if (!pattern) - return null; - - if (!isDynamicPattern(pattern)) - return pattern; - params = params || {}; - return pattern.replace(paramMatcher, function (match, pathSegment) { - var paramName = getParamName(pathSegment); + var splatIndex = 0; + + return pattern.replace(paramMatcher, function (match, paramName) { + paramName = paramName || 'splat'; invariant( params[paramName] != null, 'Missing "' + paramName + '" parameter for path "' + pattern + '"' ); - // Preserve forward slashes. - return String(params[paramName]).split('/').map(URL.encode).join('/'); + var segment; + if (paramName === 'splat' && Array.isArray(params[paramName])) { + segment = params[paramName][splatIndex++]; + + invariant( + segment != null, + 'Missing splat # ' + splatIndex + ' for path "' + pattern + '"' + ); + } else { + segment = params[paramName]; + } + + return encodeURLPath(segment); }); }, /** - * Returns an object that is the result of parsing any query string contained in - * the given path, null if the path contains no query string. + * Returns an object that is the result of parsing any query string contained + * in the given path, null if the path contains no query string. */ extractQuery: function (path) { - var match = path.match(queryMatcher); + var match = decodeURL(path).match(queryMatcher); return match && qs.parse(match[1]); }, @@ -118,14 +123,14 @@ var Path = { }, /** - * Returns a version of the given path with the parameters in the given query - * added to the query string. + * Returns a version of the given path with the parameters in the given + * query merged into the query string. */ withQuery: function (path, query) { var existingQuery = Path.extractQuery(path); if (existingQuery) - query = query ? copyProperties(existingQuery, query) : existingQuery; + query = query ? merge(existingQuery, query) : existingQuery; var queryString = query && qs.stringify(query); diff --git a/modules/helpers/URL.js b/modules/helpers/URL.js deleted file mode 100644 index 18b9f8f4f9..0000000000 --- a/modules/helpers/URL.js +++ /dev/null @@ -1,22 +0,0 @@ -var urlEncodedSpaceRE = /\+/g; -var encodedSpaceRE = /%20/g; - -var URL = { - - /* These functions were copied from the https://github.com/cujojs/rest source, MIT licensed */ - - decode: function (str) { - // spec says space should be encoded as '+' - str = str.replace(urlEncodedSpaceRE, ' '); - return decodeURIComponent(str); - }, - - encode: function (str) { - str = encodeURIComponent(str); - // spec says space should be encoded as '+' - return str.replace(encodedSpaceRE, '+'); - } - -}; - -module.exports = URL; diff --git a/package.json b/package.json index f513d6e52f..fbaca1a1ac 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "dependencies": { "es6-promise": "^1.0.0", "events": "^1.0.1", - "querystring": "^0.2.0" + "qs": "^1.2.2" }, "keywords": [ "react", diff --git a/specs/Path.spec.js b/specs/Path.spec.js index b0cf65378d..79f2a0be10 100644 --- a/specs/Path.spec.js +++ b/specs/Path.spec.js @@ -1,6 +1,26 @@ require('./helper'); var Path = require('../modules/helpers/Path'); +describe('Path.extractParamNames', function () { + describe('when a pattern contains no dynamic segments', function () { + it('returns an empty array', function () { + expect(Path.extractParamNames('a/b/c')).toEqual([]); + }); + }); + + describe('when a pattern contains :a and :b dynamic segments', function () { + it('returns the correct names', function () { + expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]); + }); + }); + + describe('when a pattern has a *', function () { + it('uses the name "splat"', function () { + expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]); + }); + }); +}); + describe('Path.extractParams', function () { describe('when a pattern does not have dynamic segments', function () { var pattern = 'a/b/c'; @@ -19,11 +39,11 @@ describe('Path.extractParams', function () { }); describe('when a pattern has dynamic segments', function () { - var pattern = 'comments/:id/edit'; + var pattern = 'comments/:id.:ext/edit'; describe('and the path matches', function () { it('returns an object with the params', function () { - expect(Path.extractParams(pattern, 'comments/abc/edit')).toEqual({ id: 'abc' }); + expect(Path.extractParams(pattern, 'comments/abc.js/edit')).toEqual({ id: 'abc', ext: 'js' }); }); }); @@ -35,7 +55,7 @@ describe('Path.extractParams', function () { describe('and the path matches with a segment containing a .', function () { it('returns an object with the params', function () { - expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo.bar' }); + expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo', ext: 'bar' }); }); }); }); @@ -73,38 +93,37 @@ describe('Path.extractParams', function () { }); describe('when a pattern has a *', function () { - var pattern = '/files/*.jpg'; - describe('and the path matches', function () { it('returns an object with the params', function () { - expect(Path.extractParams(pattern, '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' }); + expect(Path.extractParams('/files/*', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo.jpg' }); + expect(Path.extractParams('/files/*', '/files/my/photo.jpg.zip')).toEqual({ splat: 'my/photo.jpg.zip' }); + expect(Path.extractParams('/files/*.jpg', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' }); }); }); describe('and the path does not match', function () { it('returns null', function () { - expect(Path.extractParams(pattern, '/files/my/photo.png')).toBe(null); + expect(Path.extractParams('/files/*.jpg', '/files/my/photo.png')).toBe(null); }); }); }); -}); -describe('Path.extractParamNames', function () { - describe('when a pattern contains no dynamic segments', function () { - it('returns an empty array', function () { - expect(Path.extractParamNames('a/b/c')).toEqual([]); - }); - }); + describe('when a pattern has a ?', function () { + var pattern = '/archive/?:name?'; - describe('when a pattern contains :a and :b dynamic segments', function () { - it('returns the correct names', function () { - expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]); + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, '/archive')).toEqual({ name: undefined }); + expect(Path.extractParams(pattern, '/archive/')).toEqual({ name: undefined }); + expect(Path.extractParams(pattern, '/archive/foo')).toEqual({ name: 'foo' }); + expect(Path.extractParams(pattern, '/archivefoo')).toEqual({ name: 'foo' }); + }); }); - }); - describe('when a pattern has a *', function () { - it('uses the name "splat"', function () { - expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]); + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, '/archiv')).toBe(null); + }); }); }); }); @@ -151,12 +170,22 @@ describe('Path.injectParams', function () { }); }); }); + + describe('when a pattern has multiple splats', function () { + it('returns the correct path', function () { + expect(Path.injectParams('/a/*/c/*', { splat: [ 'b', 'd' ] })).toEqual('/a/b/c/d'); + }); + }); }); describe('Path.extractQuery', function () { describe('when the path contains a query string', function () { it('returns the parsed query object', function () { - expect(Path.extractQuery('/a/b/c?id=def&show=true')).toEqual({ id: 'def', show: 'true' }); + expect(Path.extractQuery('/?id=def&show=true')).toEqual({ id: 'def', show: 'true' }); + }); + + it('properly handles arrays', function () { + expect(Path.extractQuery('/?id%5B%5D=a&id%5B%5D=b')).toEqual({ id: [ 'a', 'b' ] }); }); }); @@ -177,6 +206,10 @@ describe('Path.withQuery', function () { it('appends the query string', function () { expect(Path.withQuery('/a/b/c', { id: 'def' })).toEqual('/a/b/c?id=def'); }); + + it('merges two query strings', function () { + expect(Path.withQuery('/path?a=b', { c: [ 'd', 'e' ]})).toEqual('/path?a=b&c%5B0%5D=d&c%5B1%5D=e'); + }); }); describe('Path.normalize', function () {