route-parser-ts 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -2
- package/dist/index.js +168 -16
- package/dist/test.js +112 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ export interface RouteParams {
|
|
|
11
11
|
declare class RouteParser {
|
|
12
12
|
spec: string;
|
|
13
13
|
private tokens;
|
|
14
|
-
private
|
|
15
|
-
private
|
|
14
|
+
private pathRegex;
|
|
15
|
+
private pathParamNames;
|
|
16
|
+
private queryParamDefs;
|
|
17
|
+
private hasQueryInSpec;
|
|
16
18
|
constructor(spec: string);
|
|
17
19
|
/**
|
|
18
20
|
* Match a path against this route
|
package/dist/index.js
CHANGED
|
@@ -53,10 +53,15 @@ function tokenize(spec) {
|
|
|
53
53
|
const children = tokenize(optionalContent);
|
|
54
54
|
tokens.push({ type: 'optional', value: optionalContent, children });
|
|
55
55
|
}
|
|
56
|
+
else if (char === '?' || char === '&') {
|
|
57
|
+
// Query separator - mark it specially
|
|
58
|
+
tokens.push({ type: 'querySeparator', value: char });
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
56
61
|
else {
|
|
57
62
|
// Static text - consume until we hit a special character
|
|
58
63
|
let text = '';
|
|
59
|
-
while (i < spec.length && !':*()'.includes(spec[i])) {
|
|
64
|
+
while (i < spec.length && !':*()&?'.includes(spec[i])) {
|
|
60
65
|
text += spec[i];
|
|
61
66
|
i++;
|
|
62
67
|
}
|
|
@@ -67,6 +72,79 @@ function tokenize(spec) {
|
|
|
67
72
|
}
|
|
68
73
|
return tokens;
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Check if tokens contain any query-related content (? or &)
|
|
77
|
+
*/
|
|
78
|
+
function hasQueryTokens(tokens) {
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
if (token.type === 'querySeparator')
|
|
81
|
+
return true;
|
|
82
|
+
if (token.type === 'optional' && token.children && hasQueryTokens(token.children))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Extract query parameter definitions from tokens
|
|
89
|
+
*/
|
|
90
|
+
function extractQueryParams(tokens, optional = false) {
|
|
91
|
+
const params = [];
|
|
92
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
93
|
+
const token = tokens[i];
|
|
94
|
+
if (token.type === 'param') {
|
|
95
|
+
// Look back for the key name (e.g., 'page=' before ':page')
|
|
96
|
+
let key = token.value; // Default to param name
|
|
97
|
+
if (i > 0) {
|
|
98
|
+
const prevToken = tokens[i - 1];
|
|
99
|
+
if (prevToken.type === 'static' && prevToken.value.endsWith('=')) {
|
|
100
|
+
// Extract key from "key="
|
|
101
|
+
const match = prevToken.value.match(/([^=&?]+)=$/);
|
|
102
|
+
if (match) {
|
|
103
|
+
key = match[1];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
params.push({ name: token.value, key, optional });
|
|
108
|
+
}
|
|
109
|
+
else if (token.type === 'optional' && token.children) {
|
|
110
|
+
// Recursively extract from optional segments
|
|
111
|
+
params.push(...extractQueryParams(token.children, true));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return params;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Split tokens into path tokens and query tokens
|
|
118
|
+
*/
|
|
119
|
+
function splitPathAndQuery(tokens) {
|
|
120
|
+
const pathTokens = [];
|
|
121
|
+
const queryTokens = [];
|
|
122
|
+
let inQuery = false;
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
if (token.type === 'querySeparator' && token.value === '?') {
|
|
125
|
+
inQuery = true;
|
|
126
|
+
queryTokens.push(token);
|
|
127
|
+
}
|
|
128
|
+
else if (inQuery) {
|
|
129
|
+
queryTokens.push(token);
|
|
130
|
+
}
|
|
131
|
+
else if (token.type === 'optional' && token.children) {
|
|
132
|
+
// Check if this optional contains query content
|
|
133
|
+
if (hasQueryTokens(token.children)) {
|
|
134
|
+
// This optional is part of query
|
|
135
|
+
queryTokens.push(token);
|
|
136
|
+
inQuery = true; // After query optional, we're in query mode
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
pathTokens.push(token);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
pathTokens.push(token);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { pathTokens, queryTokens };
|
|
147
|
+
}
|
|
70
148
|
/**
|
|
71
149
|
* Escape special regex characters in a string
|
|
72
150
|
*/
|
|
@@ -74,9 +152,9 @@ function escapeRegex(str) {
|
|
|
74
152
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
153
|
}
|
|
76
154
|
/**
|
|
77
|
-
* Build a regex pattern from tokens
|
|
155
|
+
* Build a regex pattern from path tokens only (no query handling)
|
|
78
156
|
*/
|
|
79
|
-
function
|
|
157
|
+
function buildPathRegex(tokens) {
|
|
80
158
|
let pattern = '';
|
|
81
159
|
const paramNames = [];
|
|
82
160
|
for (const token of tokens) {
|
|
@@ -97,19 +175,41 @@ function buildRegex(tokens) {
|
|
|
97
175
|
case 'optional':
|
|
98
176
|
// Optional segment - recursively build and wrap in optional group
|
|
99
177
|
if (token.children) {
|
|
100
|
-
const { pattern: childPattern, paramNames: childNames } =
|
|
178
|
+
const { pattern: childPattern, paramNames: childNames } = buildPathRegex(token.children);
|
|
101
179
|
pattern += `(?:${childPattern})?`;
|
|
102
180
|
paramNames.push(...childNames);
|
|
103
181
|
}
|
|
104
182
|
break;
|
|
183
|
+
case 'querySeparator':
|
|
184
|
+
// Should not appear in path tokens
|
|
185
|
+
break;
|
|
105
186
|
}
|
|
106
187
|
}
|
|
107
188
|
return { pattern, paramNames };
|
|
108
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Parse a query string into key-value pairs
|
|
192
|
+
*/
|
|
193
|
+
function parseQueryString(queryString) {
|
|
194
|
+
const params = new Map();
|
|
195
|
+
if (!queryString)
|
|
196
|
+
return params;
|
|
197
|
+
// Remove leading ? if present
|
|
198
|
+
const qs = queryString.startsWith('?') ? queryString.slice(1) : queryString;
|
|
199
|
+
if (!qs)
|
|
200
|
+
return params;
|
|
201
|
+
for (const pair of qs.split('&')) {
|
|
202
|
+
const [key, value] = pair.split('=');
|
|
203
|
+
if (key) {
|
|
204
|
+
params.set(key, value || '');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return params;
|
|
208
|
+
}
|
|
109
209
|
/**
|
|
110
210
|
* Attempt to reverse a route with given parameters
|
|
111
211
|
*/
|
|
112
|
-
function reverseTokens(tokens, params) {
|
|
212
|
+
function reverseTokens(tokens, params, isFirstQueryParam) {
|
|
113
213
|
let result = '';
|
|
114
214
|
for (const token of tokens) {
|
|
115
215
|
switch (token.type) {
|
|
@@ -125,10 +225,31 @@ function reverseTokens(tokens, params) {
|
|
|
125
225
|
result += String(value);
|
|
126
226
|
break;
|
|
127
227
|
}
|
|
228
|
+
case 'querySeparator':
|
|
229
|
+
// For reverse, we handle ? and & smartly
|
|
230
|
+
if (token.value === '?') {
|
|
231
|
+
if (isFirstQueryParam.value) {
|
|
232
|
+
result += '?';
|
|
233
|
+
isFirstQueryParam.value = false;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result += '&';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (token.value === '&') {
|
|
240
|
+
if (isFirstQueryParam.value) {
|
|
241
|
+
result += '?';
|
|
242
|
+
isFirstQueryParam.value = false;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
result += '&';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
128
249
|
case 'optional':
|
|
129
250
|
if (token.children) {
|
|
130
251
|
// Try to fill the optional segment
|
|
131
|
-
const optionalResult = reverseTokens(token.children, params);
|
|
252
|
+
const optionalResult = reverseTokens(token.children, params, isFirstQueryParam);
|
|
132
253
|
if (optionalResult !== false) {
|
|
133
254
|
result += optionalResult;
|
|
134
255
|
}
|
|
@@ -163,31 +284,61 @@ function canFulfill(tokens, params) {
|
|
|
163
284
|
class RouteParser {
|
|
164
285
|
spec;
|
|
165
286
|
tokens;
|
|
166
|
-
|
|
167
|
-
|
|
287
|
+
pathRegex;
|
|
288
|
+
pathParamNames;
|
|
289
|
+
queryParamDefs;
|
|
290
|
+
hasQueryInSpec;
|
|
168
291
|
constructor(spec) {
|
|
169
292
|
if (!spec) {
|
|
170
293
|
throw new Error('spec is required');
|
|
171
294
|
}
|
|
172
295
|
this.spec = spec;
|
|
173
296
|
this.tokens = tokenize(spec);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
297
|
+
// Split into path and query parts
|
|
298
|
+
const { pathTokens, queryTokens } = splitPathAndQuery(this.tokens);
|
|
299
|
+
// Build regex for path part only
|
|
300
|
+
const { pattern, paramNames } = buildPathRegex(pathTokens);
|
|
301
|
+
this.pathRegex = new RegExp(`^${pattern}(?:\\?.*)?$`);
|
|
302
|
+
this.pathParamNames = paramNames;
|
|
303
|
+
// Extract query parameter definitions
|
|
304
|
+
this.queryParamDefs = extractQueryParams(queryTokens);
|
|
305
|
+
this.hasQueryInSpec = queryTokens.length > 0;
|
|
178
306
|
}
|
|
179
307
|
/**
|
|
180
308
|
* Match a path against this route
|
|
181
309
|
* Returns params object on match, false otherwise
|
|
182
310
|
*/
|
|
183
311
|
match(path) {
|
|
184
|
-
|
|
312
|
+
// Split path into pathname and query string
|
|
313
|
+
const queryIndex = path.indexOf('?');
|
|
314
|
+
const pathname = queryIndex >= 0 ? path.slice(0, queryIndex) : path;
|
|
315
|
+
const queryString = queryIndex >= 0 ? path.slice(queryIndex + 1) : '';
|
|
316
|
+
// Match only the pathname part (not query string) to avoid capturing ? in params
|
|
317
|
+
const match = this.pathRegex.exec(pathname);
|
|
185
318
|
if (!match) {
|
|
186
319
|
return false;
|
|
187
320
|
}
|
|
188
321
|
const params = {};
|
|
189
|
-
|
|
190
|
-
|
|
322
|
+
// Extract path parameters
|
|
323
|
+
for (let i = 0; i < this.pathParamNames.length; i++) {
|
|
324
|
+
params[this.pathParamNames[i]] = match[i + 1];
|
|
325
|
+
}
|
|
326
|
+
// If route has query params in spec, match them from URL's query string
|
|
327
|
+
if (this.hasQueryInSpec) {
|
|
328
|
+
const urlQueryParams = parseQueryString(queryString);
|
|
329
|
+
for (const paramDef of this.queryParamDefs) {
|
|
330
|
+
const value = urlQueryParams.get(paramDef.key);
|
|
331
|
+
if (value !== undefined) {
|
|
332
|
+
params[paramDef.name] = value;
|
|
333
|
+
}
|
|
334
|
+
else if (paramDef.optional) {
|
|
335
|
+
params[paramDef.name] = undefined;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// Required query param not found
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
191
342
|
}
|
|
192
343
|
return params;
|
|
193
344
|
}
|
|
@@ -200,7 +351,8 @@ class RouteParser {
|
|
|
200
351
|
if (!this.canFulfillRequired(this.tokens, params)) {
|
|
201
352
|
return false;
|
|
202
353
|
}
|
|
203
|
-
|
|
354
|
+
const isFirstQueryParam = { value: true };
|
|
355
|
+
return reverseTokens(this.tokens, params, isFirstQueryParam);
|
|
204
356
|
}
|
|
205
357
|
/**
|
|
206
358
|
* Check if required params (non-optional) can be fulfilled
|
package/dist/test.js
CHANGED
|
@@ -107,6 +107,118 @@ describe('Route', () => {
|
|
|
107
107
|
assert.strictEqual(route.reverse({ id: '456' }), '~/users/456');
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
describe('query parameters', () => {
|
|
111
|
+
it('should match a path with query string when route has no query params', () => {
|
|
112
|
+
const route = Route('/foo');
|
|
113
|
+
assert.deepStrictEqual(route.match('/foo?a=1&b=2'), {});
|
|
114
|
+
});
|
|
115
|
+
it('should match a path without query string when route has no query params', () => {
|
|
116
|
+
const route = Route('/foo');
|
|
117
|
+
assert.deepStrictEqual(route.match('/foo'), {});
|
|
118
|
+
});
|
|
119
|
+
it('should match query parameters in same order', () => {
|
|
120
|
+
const route = Route('/?a=:a&b=:b');
|
|
121
|
+
assert.deepStrictEqual(route.match('/?a=1&b=2'), { a: '1', b: '2' });
|
|
122
|
+
});
|
|
123
|
+
it('should match query parameters in different order (issue #17)', () => {
|
|
124
|
+
const route = Route('/?a=:a&b=:b');
|
|
125
|
+
// This test documents the expected behavior - query order should not matter
|
|
126
|
+
assert.deepStrictEqual(route.match('/?b=2&a=1'), { a: '1', b: '2' });
|
|
127
|
+
});
|
|
128
|
+
it('should match path with parameters and query string', () => {
|
|
129
|
+
const route = Route('/users/:id');
|
|
130
|
+
assert.deepStrictEqual(route.match('/users/123?tab=profile'), { id: '123' });
|
|
131
|
+
});
|
|
132
|
+
it('should match path with parameters and multiple query params', () => {
|
|
133
|
+
const route = Route('/users/:id');
|
|
134
|
+
assert.deepStrictEqual(route.match('/users/123?tab=profile&view=grid'), { id: '123' });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('optional query parameters', () => {
|
|
138
|
+
it('should match route with optional query part - without query', () => {
|
|
139
|
+
const route = Route('/search(?q=:query)');
|
|
140
|
+
assert.deepStrictEqual(route.match('/search'), { query: undefined });
|
|
141
|
+
});
|
|
142
|
+
it('should match route with optional query part - with query', () => {
|
|
143
|
+
const route = Route('/search(?q=:query)');
|
|
144
|
+
assert.deepStrictEqual(route.match('/search?q=test'), { query: 'test' });
|
|
145
|
+
});
|
|
146
|
+
it('should match route with optional query params - without any query', () => {
|
|
147
|
+
const route = Route('/api/items(?page=:page(&limit=:limit))');
|
|
148
|
+
assert.deepStrictEqual(route.match('/api/items'), { page: undefined, limit: undefined });
|
|
149
|
+
});
|
|
150
|
+
it('should match route with optional query params - with first param only', () => {
|
|
151
|
+
const route = Route('/api/items(?page=:page(&limit=:limit))');
|
|
152
|
+
assert.deepStrictEqual(route.match('/api/items?page=1'), { page: '1', limit: undefined });
|
|
153
|
+
});
|
|
154
|
+
it('should match route with optional query params - with all params', () => {
|
|
155
|
+
const route = Route('/api/items(?page=:page(&limit=:limit))');
|
|
156
|
+
assert.deepStrictEqual(route.match('/api/items?page=1&limit=10'), { page: '1', limit: '10' });
|
|
157
|
+
});
|
|
158
|
+
it('should reverse route with optional query part - without params', () => {
|
|
159
|
+
const route = Route('/search(?q=:query)');
|
|
160
|
+
assert.strictEqual(route.reverse({}), '/search');
|
|
161
|
+
});
|
|
162
|
+
it('should reverse route with optional query part - with params', () => {
|
|
163
|
+
const route = Route('/search(?q=:query)');
|
|
164
|
+
assert.strictEqual(route.reverse({ query: 'test' }), '/search?q=test');
|
|
165
|
+
});
|
|
166
|
+
it('should reverse route with nested optional query params - partial', () => {
|
|
167
|
+
const route = Route('/api/items(?page=:page(&limit=:limit))');
|
|
168
|
+
assert.strictEqual(route.reverse({ page: '1' }), '/api/items?page=1');
|
|
169
|
+
});
|
|
170
|
+
it('should reverse route with nested optional query params - all', () => {
|
|
171
|
+
const route = Route('/api/items(?page=:page(&limit=:limit))');
|
|
172
|
+
assert.strictEqual(route.reverse({ page: '1', limit: '10' }), '/api/items?page=1&limit=10');
|
|
173
|
+
});
|
|
174
|
+
describe('independently optional query parameters', () => {
|
|
175
|
+
// Pattern: /search(?param1=:param1)(¶m2=:param2)
|
|
176
|
+
// The ? and & should be intelligently handled during matching
|
|
177
|
+
it('should match with no query params', () => {
|
|
178
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
179
|
+
assert.deepStrictEqual(route.match('/search'), { param1: undefined, param2: undefined });
|
|
180
|
+
});
|
|
181
|
+
it('should match with first param only', () => {
|
|
182
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
183
|
+
assert.deepStrictEqual(route.match('/search?param1=foo'), { param1: 'foo', param2: undefined });
|
|
184
|
+
});
|
|
185
|
+
it('should match with second param only', () => {
|
|
186
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
187
|
+
// When only second param present, ? should be used instead of &
|
|
188
|
+
assert.deepStrictEqual(route.match('/search?param2=bar'), { param1: undefined, param2: 'bar' });
|
|
189
|
+
});
|
|
190
|
+
it('should match with both params', () => {
|
|
191
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
192
|
+
assert.deepStrictEqual(route.match('/search?param1=foo¶m2=bar'), { param1: 'foo', param2: 'bar' });
|
|
193
|
+
});
|
|
194
|
+
it('should match with both params in different order', () => {
|
|
195
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
196
|
+
assert.deepStrictEqual(route.match('/search?param2=bar¶m1=foo'), { param1: 'foo', param2: 'bar' });
|
|
197
|
+
});
|
|
198
|
+
it('should reverse with no params', () => {
|
|
199
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
200
|
+
assert.strictEqual(route.reverse({}), '/search');
|
|
201
|
+
});
|
|
202
|
+
it('should reverse with first param only', () => {
|
|
203
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
204
|
+
assert.strictEqual(route.reverse({ param1: 'foo' }), '/search?param1=foo');
|
|
205
|
+
});
|
|
206
|
+
it('should reverse with both params', () => {
|
|
207
|
+
const route = Route('/search(?param1=:param1)(¶m2=:param2)');
|
|
208
|
+
assert.strictEqual(route.reverse({ param1: 'foo', param2: 'bar' }), '/search?param1=foo¶m2=bar');
|
|
209
|
+
});
|
|
210
|
+
// Route defined with params in reverse order - ? and & should not affect matching
|
|
211
|
+
it('should match when route defines params in reverse order', () => {
|
|
212
|
+
const route = Route('/search(?param2=:param2)(¶m1=:param1)');
|
|
213
|
+
// URL has param1 first, route has param2 first - should still match
|
|
214
|
+
assert.deepStrictEqual(route.match('/search?param1=foo¶m2=bar'), { param1: 'foo', param2: 'bar' });
|
|
215
|
+
});
|
|
216
|
+
it('should match when route and URL have same reverse order', () => {
|
|
217
|
+
const route = Route('/search(?param2=:param2)(¶m1=:param1)');
|
|
218
|
+
assert.deepStrictEqual(route.match('/search?param2=bar¶m1=foo'), { param1: 'foo', param2: 'bar' });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
110
222
|
describe('reverse', () => {
|
|
111
223
|
it('reverses routes without params', () => {
|
|
112
224
|
const route = Route('/foo');
|
package/package.json
CHANGED