Skip to content

Commit 781470f

Browse files
committed
feat: support query parameters with square brackets
1 parent cd24b44 commit 781470f

2 files changed

Lines changed: 120 additions & 94 deletions

File tree

modules/RouteNode.js

Lines changed: 106 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,209 +1,221 @@
1-
import Path from 'path-parser'
1+
import Path from 'path-parser';
22

3-
let isSerialisable = val => val !== undefined && val !== null && val !== ''
3+
const isSerialisable = val => val !== undefined && val !== null && val !== '';
44

5-
let removeQueryParamsFromPath = (path, params) => {
6-
if (path.indexOf('?') === -1) return path
7-
const splitPath = path.split('?')
8-
const pathPart = splitPath[0]
9-
const searchPart = splitPath[1]
5+
const bracketTest = /\[\]$/;
6+
const withoutBrackets = param => param.replace(bracketTest, '');
7+
8+
const removeQueryParamsFromPath = (path, params) => {
9+
if (path.indexOf('?') === -1) return path;
10+
const splitPath = path.split('?');
11+
const pathPart = splitPath[0];
12+
const searchPart = splitPath[1];
1013

1114
let remainingSearchParams = searchPart
1215
.split('&')
1316
.reduce((obj, p) => {
14-
const splitParam = p.split('=')
15-
const key = splitParam[0]
16-
const val = decodeURIComponent(splitParam[1])
17-
if (params.indexOf(key) === -1) obj[key] = val || ''
18-
return obj
19-
}, {})
17+
const splitParam = p.split('=');
18+
const hasBrackets = bracketTest.test(splitParam[0]);
19+
const key = splitParam[0];
20+
let val = decodeURIComponent(splitParam[1]);
21+
val = hasBrackets ? [ val ] : val;
22+
23+
if (params.indexOf(withoutBrackets(key)) === -1) obj[key] = val || '';
24+
return obj;
25+
}, {});
2026

2127
let remainingSearchPart = Object.keys(remainingSearchParams)
2228
.map(p => [p].concat(isSerialisable(remainingSearchParams[p]) ? encodeURIComponent(remainingSearchParams[p]) : []))
2329
.map(p => p.join('='))
24-
.join('&')
30+
.join('&');
2531

26-
return pathPart + (remainingSearchPart ? `?${remainingSearchPart}` : '')
27-
}
32+
return pathPart + (remainingSearchPart ? `?${remainingSearchPart}` : '');
33+
};
2834

2935
export default class RouteNode {
3036
constructor(name = '', path = '', childRoutes = []) {
31-
this.name = name
32-
this.path = path
33-
this.parser = path ? new Path(path) : null
34-
this.children = []
37+
this.name = name;
38+
this.path = path;
39+
this.parser = path ? new Path(path) : null;
40+
this.children = [];
3541

36-
this.add(childRoutes)
42+
this.add(childRoutes);
3743

38-
return this
44+
return this;
3945
}
4046

4147
add(route) {
42-
if (route === undefined || route === null) return
48+
if (route === undefined || route === null) return;
4349

4450
if (route instanceof Array) {
45-
route.forEach(r => this.add(r))
46-
return
51+
route.forEach(r => this.add(r));
52+
return;
4753
}
4854

4955
if (!(route instanceof RouteNode) && !(route instanceof Object)) {
50-
throw new Error('RouteNode.add() expects routes to be an Object or an instance of RouteNode.')
56+
throw new Error('RouteNode.add() expects routes to be an Object or an instance of RouteNode.');
5157
}
5258
if (route instanceof Object) {
5359
if (!route.name || !route.path) {
54-
throw new Error('RouteNode.add() expects routes to have a name and a path defined.')
60+
throw new Error('RouteNode.add() expects routes to have a name and a path defined.');
5561
}
56-
route = new RouteNode(route.name, route.path, route.children)
62+
route = new RouteNode(route.name, route.path, route.children);
5763
}
5864
// Check duplicated routes
5965
if (this.children.map(child => child.name).indexOf(route.name) !== -1) {
60-
throw new Error(`Alias "${route.name}" is already defined in route node`)
66+
throw new Error(`Alias "${route.name}" is already defined in route node`);
6167
}
6268
// Check duplicated paths
6369
if (this.children.map(child => child.path).indexOf(route.path) !== -1) {
64-
throw new Error(`Path "${route.path}" is already defined in route node`)
70+
throw new Error(`Path "${route.path}" is already defined in route node`);
6571
}
6672

67-
let names = route.name.split('.')
73+
let names = route.name.split('.');
6874

6975
if (names.length === 1) {
70-
this.children.push(route)
76+
this.children.push(route);
7177
// Push greedy spats to the bottom of the pile
7278
this.children.sort((a, b) => {
7379
// '/' last
7480
if (a.path === '/') return 1;
7581
if (b.path === '/') return -1;
76-
let aHasParams = a.parser.hasUrlParams || a.parser.hasSpatParam
77-
let bHasParams = b.parser.hasUrlParams || b.parser.hasSpatParam
82+
let aHasParams = a.parser.hasUrlParams || a.parser.hasSpatParam;
83+
let bHasParams = b.parser.hasUrlParams || b.parser.hasSpatParam;
7884
// No params first, sort by length descending
7985
if (!aHasParams && !bHasParams) {
80-
return a.path && b.path ? (a.path.length < b.path.length ? 1 : -1) : 0
86+
return a.path && b.path ? (a.path.length < b.path.length ? 1 : -1) : 0;
8187
}
8288
// Params last
83-
if (aHasParams && !bHasParams) return 1
84-
if (!aHasParams && bHasParams) return -1
89+
if (aHasParams && !bHasParams) return 1;
90+
if (!aHasParams && bHasParams) return -1;
8591
// Spat params last
86-
if (!a.parser.hasSpatParam && b.parser.hasSpatParam) return -1
87-
if (!b.parser.hasSpatParam && a.parser.hasSpatParam) return 1
92+
if (!a.parser.hasSpatParam && b.parser.hasSpatParam) return -1;
93+
if (!b.parser.hasSpatParam && a.parser.hasSpatParam) return 1;
8894
// Sort by number of segments descending
89-
let aSegments = (a.path.match(/\//g) || []).length
90-
let bSegments = (b.path.match(/\//g) || []).length
91-
if (aSegments < bSegments) return 1
92-
return 0
93-
})
95+
let aSegments = (a.path.match(/\//g) || []).length;
96+
let bSegments = (b.path.match(/\//g) || []).length;
97+
if (aSegments < bSegments) return 1;
98+
return 0;
99+
});
94100
} else {
95101
// Locate parent node
96-
let segments = this.getSegmentsByName(names.slice(0, -1).join('.'))
102+
let segments = this.getSegmentsByName(names.slice(0, -1).join('.'));
97103
if (segments) {
98-
segments[segments.length - 1].add(new RouteNode(names[names.length - 1], route.path, route.children))
104+
segments[segments.length - 1].add(new RouteNode(names[names.length - 1], route.path, route.children));
99105
} else {
100-
throw new Error(`Could not add route named '${route.name}', parent is missing.`)
106+
throw new Error(`Could not add route named '${route.name}', parent is missing.`);
101107
}
102108
}
103109

104-
return this
110+
return this;
105111
}
106112

107113
addNode(name, params) {
108-
this.add(new RouteNode(name, params))
109-
return this
114+
this.add(new RouteNode(name, params));
115+
return this;
110116
}
111117

112118
getSegmentsByName(routeName) {
113119
let findSegmentByName = (name, routes) => {
114-
let filteredRoutes = routes.filter(r => r.name === name)
115-
return filteredRoutes.length ? filteredRoutes[0] : undefined
116-
}
117-
let segments = []
120+
let filteredRoutes = routes.filter(r => r.name === name);
121+
return filteredRoutes.length ? filteredRoutes[0] : undefined;
122+
};
123+
let segments = [];
118124
let names = routeName.split('.');
119-
let routes = this.children
125+
let routes = this.children;
120126

121127
let matched = names.every(name => {
122-
let segment = findSegmentByName(name, routes)
128+
let segment = findSegmentByName(name, routes);
123129
if (segment) {
124-
routes = segment.children
125-
segments.push(segment)
126-
return true
130+
routes = segment.children;
131+
segments.push(segment);
132+
return true;
127133
}
128-
return false
129-
})
134+
return false;
135+
});
130136

131-
return matched ? segments : null
137+
return matched ? segments : null;
132138
}
133139

134140
getSegmentsMatchingPath(path, options) {
135141
const { trailingSlash, strictQueryParams } = options;
136142
let matchChildren = (nodes, pathSegment, segments) => {
137143
// for (child of node.children) {
138144
for (let i in nodes) {
139-
let child = nodes[i]
145+
let child = nodes[i];
140146
// Partially match path
141-
let match = child.parser.partialMatch(pathSegment)
142-
let remainingPath, remainingSearch
147+
let match = child.parser.partialMatch(pathSegment);
148+
let remainingPath;
143149

144150
if (!match && trailingSlash) {
145151
// Try with optional trailing slash
146-
match = child.parser.match(pathSegment, true)
147-
remainingPath = ''
152+
match = child.parser.match(pathSegment, true);
153+
remainingPath = '';
148154
} else if (match) {
149155
// Remove consumed segment from path
150-
let consumedPath = child.parser.build(match, {ignoreSearch: true})
151-
remainingPath = removeQueryParamsFromPath(pathSegment.replace(consumedPath, ''), child.parser.queryParams)
156+
let consumedPath = child.parser.build(match, {ignoreSearch: true});
157+
remainingPath = removeQueryParamsFromPath(pathSegment.replace(consumedPath, ''), child.parser.queryParams.concat(child.parser.queryParamsBr));
152158

153159
if (trailingSlash && remainingPath === '/' && !/\/$/.test(consumedPath)) {
154-
remainingPath = ''
160+
remainingPath = '';
155161
}
156162
}
157163

158164
if (match) {
159-
segments.push(child)
160-
Object.keys(match).forEach(param => segments.params[param] = match[param])
165+
segments.push(child);
166+
Object.keys(match).forEach(param => segments.params[param] = match[param]);
161167

162168
if (!remainingPath.length || // fully matched
163169
!strictQueryParams && remainingPath.indexOf('?') === 0 // unmatched queryParams in non strict mode
164170
) {
165-
return segments
171+
return segments;
166172
}
167173
// If no children to match against but unmatched path left
168174
if (!child.children.length) {
169-
return null
175+
return null;
170176
}
171177
// Else: remaining path and children
172178
return matchChildren(child.children, remainingPath, segments);
173179
}
174180
}
175181
return null;
176-
}
182+
};
177183

178-
let startingNodes = this.parser ? [this] : this.children
179-
let segments = []
180-
segments.params = {}
184+
let startingNodes = this.parser ? [this] : this.children;
185+
let segments = [];
186+
segments.params = {};
181187

182-
return matchChildren(startingNodes, path, segments)
188+
return matchChildren(startingNodes, path, segments);
183189
}
184190

185191
getPathFromSegments(segments) {
186-
return segments ? segments.map(segment => segment.path).join('') : null
192+
return segments ? segments.map(segment => segment.path).join('') : null;
187193
}
188194

189195
getPath(routeName) {
190-
return this.getPathFromSegments(this.getSegmentsByName(routeName))
196+
return this.getPathFromSegments(this.getSegmentsByName(routeName));
191197
}
192198

193199
buildPathFromSegments(segments, params = {}) {
194-
if (!segments) return null
200+
if (!segments) return null;
195201

196-
let searchParams = segments
202+
const searchParams = segments
197203
.filter(s => s.parser.hasQueryParams)
198-
.map(s => s.parser.queryParams);
199-
200-
let searchPart = !searchParams.length ? null : searchParams
201-
.reduce((queryParams, params) => queryParams.concat(params))
202-
.filter(p => Object.keys(params).indexOf(p) !== -1)
203-
.map(p => Path.serialise(p, params[p]))
204-
.join('&')
205-
206-
return segments.map(segment => segment.parser.build(params, {ignoreSearch: true})).join('') + (searchPart ? '?' + searchPart : '')
204+
.reduce(
205+
(params, s) => params
206+
.concat(s.parser.queryParams)
207+
.concat(s.parser.queryParamsBr.map(p => p + '[]')),
208+
[]
209+
);
210+
211+
const searchPart = !searchParams.length ? null : searchParams
212+
.filter(p => Object.keys(params).indexOf(withoutBrackets(p)) !== -1)
213+
.map(p => Path.serialise(p, params[withoutBrackets(p)]))
214+
.join('&');
215+
216+
return segments
217+
.map(segment => segment.parser.build(params, {ignoreSearch: true}))
218+
.join('') + (searchPart ? '?' + searchPart : '');
207219
}
208220

209221
getMetaFromSegments(segments) {
@@ -227,7 +239,7 @@ export default class RouteNode {
227239
}
228240

229241
buildPath(routeName, params = {}) {
230-
return this.buildPathFromSegments(this.getSegmentsByName(routeName), params)
242+
return this.buildPathFromSegments(this.getSegmentsByName(routeName), params);
231243
}
232244

233245
buildStateFromSegments(segments) {
@@ -257,6 +269,6 @@ export default class RouteNode {
257269
matchPath(path, options) {
258270
const defaultOptions = { trailingSlash: false, strictQueryParams: true };
259271
options = { ...defaultOptions, ...options };
260-
return this.buildStateFromSegments(this.getSegmentsMatchingPath(path, options))
272+
return this.buildStateFromSegments(this.getSegmentsMatchingPath(path, options));
261273
}
262274
}

test/main.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,20 @@ describe('RouteNode', function () {
283283
withoutMeta(rootNode.matchPath('', { trailingSlash: true })).should.eql({name: 'default', params: {}});
284284
should.not.exists(rootNode.matchPath('/users/list//', { trailingSlash: true }));
285285
});
286+
287+
it('should support query parameters with square brackets', function () {
288+
var node = new RouteNode('', '', [
289+
new RouteNode('route', '/route?arr[]', [
290+
new RouteNode('deep', '/deep?arr2[]')
291+
])
292+
]);
293+
294+
// node.buildPath('route.deep', { arr: [1, 2], arr2: [3] }).should.equal('/route/deep?arr[]=1&arr[]=2&arr2[]=3');
295+
withoutMeta(node.matchPath('/route/deep?arr[]=1&arr[]=2&arr2[]=3')).should.eql({
296+
name: 'route.deep',
297+
params: { arr: ['1', '2'], arr2: ['3'] }
298+
});
299+
});
286300
});
287301

288302

0 commit comments

Comments
 (0)