Skip to content

Commit d5bd656

Browse files
mjacksonryanflorence
authored andcommitted
[changed] path matching algorithm
[added] Support for ? in paths [changed] :param no longer matches . [added] Support for arrays in query strings Fixes #142
1 parent 6526e70 commit d5bd656

File tree

4 files changed

+122
-106
lines changed

4 files changed

+122
-106
lines changed

modules/helpers/Path.js

+66-61
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,117 @@
11
var invariant = require('react/lib/invariant');
2-
var copyProperties = require('react/lib/copyProperties');
3-
var qs = require('querystring');
4-
var URL = require('./URL');
2+
var merge = require('qs/lib/utils').merge;
3+
var qs = require('qs');
54

6-
var paramMatcher = /((?::[a-z_$][a-z0-9_$]*)|\*)/ig;
7-
var queryMatcher = /\?(.+)/;
8-
9-
function getParamName(pathSegment) {
10-
return pathSegment === '*' ? 'splat' : pathSegment.substr(1);
5+
function encodeURL(url) {
6+
return encodeURIComponent(url).replace(/%20/g, '+');
117
}
128

13-
var _compiledPatterns = {};
9+
function decodeURL(url) {
10+
return decodeURIComponent(url.replace(/\+/g, ' '));
11+
}
1412

15-
function compilePattern(pattern) {
16-
if (_compiledPatterns[pattern])
17-
return _compiledPatterns[pattern];
13+
function encodeURLPath(path) {
14+
return String(path).split('/').map(encodeURL).join('/');
15+
}
1816

19-
var compiled = _compiledPatterns[pattern] = {};
20-
var paramNames = compiled.paramNames = [];
17+
var paramMatcher = /:([a-zA-Z_$][a-zA-Z0-9_$]*)|[*.()\[\]\\+|{}^$]/g;
18+
var queryMatcher = /\?(.+)/;
2119

22-
var source = pattern.replace(paramMatcher, function (match, pathSegment) {
23-
paramNames.push(getParamName(pathSegment));
24-
return pathSegment === '*' ? '(.*?)' : '([^/?#]+)';
25-
});
20+
var _compiledPatterns = {};
2621

27-
compiled.matcher = new RegExp('^' + source + '$', 'i');
22+
function compilePattern(pattern) {
23+
if (!(pattern in _compiledPatterns)) {
24+
var paramNames = [];
25+
var source = pattern.replace(paramMatcher, function (match, paramName) {
26+
if (paramName) {
27+
paramNames.push(paramName);
28+
return '([^./?#]+)';
29+
} else if (match === '*') {
30+
paramNames.push('splat');
31+
return '(.*?)';
32+
} else {
33+
return '\\' + match;
34+
}
35+
});
2836

29-
return compiled;
30-
}
37+
_compiledPatterns[pattern] = {
38+
matcher: new RegExp('^' + source + '$', 'i'),
39+
paramNames: paramNames
40+
};
41+
}
3142

32-
function isDynamicPattern(pattern) {
33-
return pattern.indexOf(':') !== -1 || pattern.indexOf('*') !== -1;
43+
return _compiledPatterns[pattern];
3444
}
3545

3646
var Path = {
3747

48+
/**
49+
* Returns an array of the names of all parameters in the given pattern.
50+
*/
51+
extractParamNames: function (pattern) {
52+
return compilePattern(pattern).paramNames;
53+
},
54+
3855
/**
3956
* Extracts the portions of the given URL path that match the given pattern
4057
* and returns an object of param name => value pairs. Returns null if the
4158
* pattern does not match the given path.
4259
*/
4360
extractParams: function (pattern, path) {
44-
if (!pattern)
45-
return null;
46-
47-
if (!isDynamicPattern(pattern)) {
48-
if (pattern === URL.decode(path))
49-
return {}; // No dynamic segments, but the paths match.
50-
51-
return null;
52-
}
53-
54-
var compiled = compilePattern(pattern);
55-
var match = URL.decode(path).match(compiled.matcher);
61+
var object = compilePattern(pattern);
62+
var match = decodeURL(path).match(object.matcher);
5663

5764
if (!match)
5865
return null;
5966

6067
var params = {};
6168

62-
compiled.paramNames.forEach(function (paramName, index) {
69+
object.paramNames.forEach(function (paramName, index) {
6370
params[paramName] = match[index + 1];
6471
});
6572

6673
return params;
6774
},
6875

69-
/**
70-
* Returns an array of the names of all parameters in the given pattern.
71-
*/
72-
extractParamNames: function (pattern) {
73-
if (!pattern)
74-
return [];
75-
return compilePattern(pattern).paramNames;
76-
},
77-
7876
/**
7977
* Returns a version of the given route path with params interpolated. Throws
8078
* if there is a dynamic segment of the route path for which there is no param.
8179
*/
8280
injectParams: function (pattern, params) {
83-
if (!pattern)
84-
return null;
85-
86-
if (!isDynamicPattern(pattern))
87-
return pattern;
88-
8981
params = params || {};
9082

91-
return pattern.replace(paramMatcher, function (match, pathSegment) {
92-
var paramName = getParamName(pathSegment);
83+
var splatIndex = 0;
84+
85+
return pattern.replace(paramMatcher, function (match, paramName) {
86+
paramName = paramName || 'splat';
9387

9488
invariant(
9589
params[paramName] != null,
9690
'Missing "' + paramName + '" parameter for path "' + pattern + '"'
9791
);
9892

99-
// Preserve forward slashes.
100-
return String(params[paramName]).split('/').map(URL.encode).join('/');
93+
var segment;
94+
if (paramName === 'splat' && Array.isArray(params[paramName])) {
95+
segment = params[paramName][splatIndex++];
96+
97+
invariant(
98+
segment != null,
99+
'Missing splat # ' + splatIndex + ' for path "' + pattern + '"'
100+
);
101+
} else {
102+
segment = params[paramName];
103+
}
104+
105+
return encodeURLPath(segment);
101106
});
102107
},
103108

104109
/**
105-
* Returns an object that is the result of parsing any query string contained in
106-
* the given path, null if the path contains no query string.
110+
* Returns an object that is the result of parsing any query string contained
111+
* in the given path, null if the path contains no query string.
107112
*/
108113
extractQuery: function (path) {
109-
var match = path.match(queryMatcher);
114+
var match = decodeURL(path).match(queryMatcher);
110115
return match && qs.parse(match[1]);
111116
},
112117

@@ -118,14 +123,14 @@ var Path = {
118123
},
119124

120125
/**
121-
* Returns a version of the given path with the parameters in the given query
122-
* added to the query string.
126+
* Returns a version of the given path with the parameters in the given
127+
* query merged into the query string.
123128
*/
124129
withQuery: function (path, query) {
125130
var existingQuery = Path.extractQuery(path);
126131

127132
if (existingQuery)
128-
query = query ? copyProperties(existingQuery, query) : existingQuery;
133+
query = query ? merge(existingQuery, query) : existingQuery;
129134

130135
var queryString = query && qs.stringify(query);
131136

modules/helpers/URL.js

-22
This file was deleted.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"dependencies": {
5151
"es6-promise": "^1.0.0",
5252
"events": "^1.0.1",
53-
"querystring": "^0.2.0"
53+
"qs": "^1.2.2"
5454
},
5555
"keywords": [
5656
"react",

specs/Path.spec.js

+55-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
require('./helper');
22
var Path = require('../modules/helpers/Path');
33

4+
describe('Path.extractParamNames', function () {
5+
describe('when a pattern contains no dynamic segments', function () {
6+
it('returns an empty array', function () {
7+
expect(Path.extractParamNames('a/b/c')).toEqual([]);
8+
});
9+
});
10+
11+
describe('when a pattern contains :a and :b dynamic segments', function () {
12+
it('returns the correct names', function () {
13+
expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]);
14+
});
15+
});
16+
17+
describe('when a pattern has a *', function () {
18+
it('uses the name "splat"', function () {
19+
expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]);
20+
});
21+
});
22+
});
23+
424
describe('Path.extractParams', function () {
525
describe('when a pattern does not have dynamic segments', function () {
626
var pattern = 'a/b/c';
@@ -19,11 +39,11 @@ describe('Path.extractParams', function () {
1939
});
2040

2141
describe('when a pattern has dynamic segments', function () {
22-
var pattern = 'comments/:id/edit';
42+
var pattern = 'comments/:id.:ext/edit';
2343

2444
describe('and the path matches', function () {
2545
it('returns an object with the params', function () {
26-
expect(Path.extractParams(pattern, 'comments/abc/edit')).toEqual({ id: 'abc' });
46+
expect(Path.extractParams(pattern, 'comments/abc.js/edit')).toEqual({ id: 'abc', ext: 'js' });
2747
});
2848
});
2949

@@ -35,7 +55,7 @@ describe('Path.extractParams', function () {
3555

3656
describe('and the path matches with a segment containing a .', function () {
3757
it('returns an object with the params', function () {
38-
expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo.bar' });
58+
expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo', ext: 'bar' });
3959
});
4060
});
4161
});
@@ -73,38 +93,37 @@ describe('Path.extractParams', function () {
7393
});
7494

7595
describe('when a pattern has a *', function () {
76-
var pattern = '/files/*.jpg';
77-
7896
describe('and the path matches', function () {
7997
it('returns an object with the params', function () {
80-
expect(Path.extractParams(pattern, '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' });
98+
expect(Path.extractParams('/files/*', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo.jpg' });
99+
expect(Path.extractParams('/files/*', '/files/my/photo.jpg.zip')).toEqual({ splat: 'my/photo.jpg.zip' });
100+
expect(Path.extractParams('/files/*.jpg', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' });
81101
});
82102
});
83103

84104
describe('and the path does not match', function () {
85105
it('returns null', function () {
86-
expect(Path.extractParams(pattern, '/files/my/photo.png')).toBe(null);
106+
expect(Path.extractParams('/files/*.jpg', '/files/my/photo.png')).toBe(null);
87107
});
88108
});
89109
});
90-
});
91110

92-
describe('Path.extractParamNames', function () {
93-
describe('when a pattern contains no dynamic segments', function () {
94-
it('returns an empty array', function () {
95-
expect(Path.extractParamNames('a/b/c')).toEqual([]);
96-
});
97-
});
111+
describe('when a pattern has a ?', function () {
112+
var pattern = '/archive/?:name?';
98113

99-
describe('when a pattern contains :a and :b dynamic segments', function () {
100-
it('returns the correct names', function () {
101-
expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]);
114+
describe('and the path matches', function () {
115+
it('returns an object with the params', function () {
116+
expect(Path.extractParams(pattern, '/archive')).toEqual({ name: undefined });
117+
expect(Path.extractParams(pattern, '/archive/')).toEqual({ name: undefined });
118+
expect(Path.extractParams(pattern, '/archive/foo')).toEqual({ name: 'foo' });
119+
expect(Path.extractParams(pattern, '/archivefoo')).toEqual({ name: 'foo' });
120+
});
102121
});
103-
});
104122

105-
describe('when a pattern has a *', function () {
106-
it('uses the name "splat"', function () {
107-
expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]);
123+
describe('and the path does not match', function () {
124+
it('returns null', function () {
125+
expect(Path.extractParams(pattern, '/archiv')).toBe(null);
126+
});
108127
});
109128
});
110129
});
@@ -151,12 +170,22 @@ describe('Path.injectParams', function () {
151170
});
152171
});
153172
});
173+
174+
describe('when a pattern has multiple splats', function () {
175+
it('returns the correct path', function () {
176+
expect(Path.injectParams('/a/*/c/*', { splat: [ 'b', 'd' ] })).toEqual('/a/b/c/d');
177+
});
178+
});
154179
});
155180

156181
describe('Path.extractQuery', function () {
157182
describe('when the path contains a query string', function () {
158183
it('returns the parsed query object', function () {
159-
expect(Path.extractQuery('/a/b/c?id=def&show=true')).toEqual({ id: 'def', show: 'true' });
184+
expect(Path.extractQuery('/?id=def&show=true')).toEqual({ id: 'def', show: 'true' });
185+
});
186+
187+
it('properly handles arrays', function () {
188+
expect(Path.extractQuery('/?id%5B%5D=a&id%5B%5D=b')).toEqual({ id: [ 'a', 'b' ] });
160189
});
161190
});
162191

@@ -177,6 +206,10 @@ describe('Path.withQuery', function () {
177206
it('appends the query string', function () {
178207
expect(Path.withQuery('/a/b/c', { id: 'def' })).toEqual('/a/b/c?id=def');
179208
});
209+
210+
it('merges two query strings', function () {
211+
expect(Path.withQuery('/path?a=b', { c: [ 'd', 'e' ]})).toEqual('/path?a=b&c%5B0%5D=d&c%5B1%5D=e');
212+
});
180213
});
181214

182215
describe('Path.normalize', function () {

0 commit comments

Comments
 (0)