Skip to content

Commit d659769

Browse files
committed
feat(matcher): encode query params & parse params when matching
1 parent 22ec985 commit d659769

File tree

5 files changed

+61
-29
lines changed

5 files changed

+61
-29
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -812,15 +812,15 @@ This means navigation will be a three-step process:
812812
- Parameters can be optional by adding a question mark.
813813

814814
```
815-
/path/:param?
815+
/path/:param:?
816816
```
817817

818818
Parameters can also have a type constraint by adding `:{string}` or `:{number}` before the parameter name.
819819

820820
The router will only match the route if the parameter matches the type constraint.
821821

822822
```
823-
/path/:{string}:param?
823+
/path/:{string}:param:?
824824
```
825825

826826
Will match `/path/param` but not `/path/1`.

src/lib/models/matcher.model.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Route, RouteName, RouteParams } from '~/models/route.model.js';
1+
import type { Route, RouteName, RouteParams, RouteParamValue } from '~/models/route.model.js';
22

33
import { MatcherInvalidPathError, ParsingMissingRequiredParamError } from '~/models/error.model.js';
44

@@ -59,6 +59,20 @@ export function replaceTitleParams(title: string, params: RouteParams = {}) {
5959
.trim();
6060
}
6161

62+
const nullRegex = /^\d+(?:\.\d+)?$/;
63+
const booleanRegex = /^(?:true|false)$/i;
64+
function decodeValue(value?: string): RouteParamValue {
65+
if (value === undefined || value === null) return value;
66+
if (nullRegex.test(value)) return Number(value);
67+
if (booleanRegex.test(value)) return value.toLowerCase() === 'true';
68+
return decodeURIComponent(value);
69+
}
70+
71+
function encodeValue(value?: RouteParamValue): string {
72+
if (value === undefined || value === null) return '';
73+
return encodeURIComponent(String(value));
74+
}
75+
6276
/**
6377
* Replaces template params with their values
6478
* @param template
@@ -69,12 +83,12 @@ export function replaceTemplateParams(template: string, params: RouteParams = {}
6983
return template?.replace(templateParamRegexPrefix, templateParamReplacePrefix).replace(templateParamRegex, (match) => {
7084
const { param, value, optional } = replacer(match, params);
7185

72-
if (value === undefined) {
86+
if (value === undefined || value === null) {
7387
if (optional) return '';
7488
throw new ParsingMissingRequiredParamError({ template, missing: param, params });
7589
}
7690

77-
return `/${value}`;
91+
return `/${encodeValue(value)}`;
7892
});
7993
}
8094

@@ -86,8 +100,7 @@ export function replaceTemplateParams(template: string, params: RouteParams = {}
86100
export function templateToRegex(template: string) {
87101
let _template = template?.trim();
88102
if (!_template?.length) throw new MatcherInvalidPathError(template);
89-
if (relativePathRegex.test(_template))
90-
throw new MatcherInvalidPathError(template, `Path should be absolute, but "${_template}" seems to be relative.`);
103+
if (relativePathRegex.test(_template)) throw new MatcherInvalidPathError(template, `Path should be absolute, but "${_template}" seems to be relative.`);
91104
if (!_template.startsWith('/')) _template = `/${_template}`;
92105

93106
const strRegex = _template
@@ -124,8 +137,8 @@ export function templateToParams(template: string) {
124137
}
125138

126139
export interface PathParamsResult {
127-
params: Record<string, string>;
128-
wildcards: Record<string, string>;
140+
params: Record<string, string | number | boolean | undefined | null>;
141+
wildcards: Record<string, string | number | boolean | undefined | null>;
129142
}
130143
export interface IMatcher {
131144
/**
@@ -182,8 +195,8 @@ export class Matcher<Name extends RouteName = RouteName> implements IMatcher {
182195
if (index === 0) return;
183196
if (index > this.#params.length) return;
184197
const paramName = this.#params[index - 1];
185-
if (paramName === '*') result.wildcards[index] = match;
186-
else result.params[paramName] = match;
198+
if (paramName === '*') result.wildcards[index] = decodeValue(match);
199+
else result.params[paramName] = decodeValue(match);
187200
});
188201
return result;
189202
}

src/lib/models/route.model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ export type HistoryState<Key extends string | number = string | number> = {
2525
type PrimitiveKey = string | number | symbol;
2626
export type RouteName = PrimitiveKey;
2727
export type RouteMeta<T = unknown> = Record<PrimitiveKey, T>;
28-
export type RouteParamValue = string | number | boolean;
28+
export type RouteParamValue = string | number | boolean | undefined | null;
2929
export type RouteQuery = Record<string, RouteParamValue>;
3030
export type RouteParams = Record<string, RouteParamValue>;
31-
export type RouteWildcards = Record<string, string>;
31+
export type RouteWildcards = Record<string, RouteParamValue>;
3232

3333
export interface RouteNavigationOptions {
3434
/**

test/router/matcher.test.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ describe('matcher', () => {
3737
expect(replaceTemplateParams(template, params)).toBe(path);
3838
});
3939

40+
it('should replace template params with valid URI component', () => {
41+
expect.assertions(1);
42+
43+
const template = '/base/path/:id/:lastName/end';
44+
const params = { id: 12, lastName: "Hello World!@#$%^&*()_+[]{}|;:',.<>?/`~ =" };
45+
const path = `/base/path/12/${encodeURIComponent(params.lastName)}/end`;
46+
47+
expect(replaceTemplateParams(template, params)).toBe(path);
48+
});
49+
4050
it('should strip the type from the template path then replace path params with the provided params', () => {
4151
expect.assertions(1);
4252

@@ -684,7 +694,16 @@ describe('matcher', () => {
684694
expect.assertions(1);
685695
const matcher = new Matcher(template);
686696
const path = '/base/path/something/12/path/john/doe/end';
687-
const params = { id: '12', name: 'john', lastName: 'doe' };
697+
const params = { id: 12, name: 'john', lastName: 'doe' };
698+
const wildcards = { 1: 'something' };
699+
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
700+
});
701+
702+
it('should extract params from a location with uri component params', () => {
703+
expect.assertions(1);
704+
const matcher = new Matcher(template);
705+
const params = { id: 12, name: 'john', lastName: "Hello World!@#$%^&*()_+[]{}|;:',.<>?/`~ =" };
706+
const path = `/base/path/something/12/path/john/${encodeURIComponent(params.lastName)}/end`;
688707
const wildcards = { 1: 'something' };
689708
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
690709
});
@@ -693,7 +712,7 @@ describe('matcher', () => {
693712
expect.assertions(1);
694713
const matcher = new Matcher('/base/path/:{number}:id/end');
695714
const path = '/base/path/12/end';
696-
const params = { id: '12' };
715+
const params = { id: 12 };
697716
const wildcards = {};
698717
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
699718
});
@@ -729,7 +748,7 @@ describe('matcher', () => {
729748
expect.assertions(1);
730749
const matcher = new Matcher('/base/path/:{number}:id/:{string}:name/end');
731750
const path = '/base/path/12/john/end';
732-
const params = { id: '12', name: 'john' };
751+
const params = { id: 12, name: 'john' };
733752
const wildcards = {};
734753
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
735754
});
@@ -738,7 +757,7 @@ describe('matcher', () => {
738757
expect.assertions(1);
739758
const matcher = new Matcher('/base/path/:id/:first:?/:name/end');
740759
const path = '/base/path/12/john/doe/end';
741-
const params = { id: '12', first: 'john', name: 'doe' };
760+
const params = { id: 12, first: 'john', name: 'doe' };
742761
const wildcards = {};
743762
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
744763
});
@@ -747,26 +766,26 @@ describe('matcher', () => {
747766
expect.assertions(1);
748767
const matcher = new Matcher('/base/path/:id/:first:?/:name/end');
749768
const path = '/base/path/12/doe/end';
750-
const params = { id: '12', first: undefined, name: 'doe' };
769+
const params = { id: 12, first: undefined, name: 'doe' };
751770
const wildcards = {};
752771
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
753772
});
754773

755774
it('should extract params from a location with wildcards', () => {
756775
expect.assertions(1);
757-
const matcher = new Matcher('/base/path/*/segment/*/end');
758-
const path = '/base/path/something/segment/else/end';
759-
const params = {};
760-
const wildcards = { 1: 'something', 2: 'else' };
776+
const matcher = new Matcher('/base/:boolean/path/*/segment/*/end');
777+
const path = '/base/FALSE/path/something/segment/else/end';
778+
const params = { boolean: false };
779+
const wildcards = { 2: 'something', 3: 'else' };
761780
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
762781
});
763782

764783
it('should extract params from a location with params and wildcards', () => {
765784
expect.assertions(1);
766-
const matcher = new Matcher('/base/path/*/segment/:id/*');
767-
const path = '/base/path/something/segment/12/segment/end';
768-
const params = { id: '12' };
769-
const wildcards = { 1: 'something', 3: 'segment/end' };
785+
const matcher = new Matcher('/base/:boolean/path/*/segment/:id/*');
786+
const path = '/base/true/path/something/segment/12/segment/end';
787+
const params = { id: 12, boolean: true };
788+
const wildcards = { 2: 'something', 4: 'segment/end' };
770789
expect(matcher.extract(path)).toStrictEqual({ params, wildcards });
771790
});
772791

test/router/router.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ describe('router', () => {
743743
expect(route.name).toBe(ParamRoute.name);
744744
expect(route.path).toBe(path);
745745
expect(route.route.path).toBe(ParamRoute.path);
746-
expect(route.params).toStrictEqual({ id: '2', firstName: 'Jane', lastName: 'Smith' });
746+
expect(route.params).toStrictEqual({ id: 2, firstName: 'Jane', lastName: 'Smith' });
747747
});
748748

749749
it('should resolve a route from a name with default parameters', async () => {
@@ -755,7 +755,7 @@ describe('router', () => {
755755
expect(route.name).toBe(ParamRoute.name);
756756
expect(route.path).toBe('/param/1/user/John/Doe');
757757
expect(route.route.path).toBe(ParamRoute.path);
758-
expect(route.params).toStrictEqual({ id: '1', firstName: 'John', lastName: 'Doe' });
758+
expect(route.params).toStrictEqual({ id: 1, firstName: 'John', lastName: 'Doe' });
759759
});
760760

761761
it('should resolve a route from a location with query parameters', async () => {
@@ -792,7 +792,7 @@ describe('router', () => {
792792
expect(route.name).toBe(ParamQueryRoute.name);
793793
expect(route.path).toBe(path);
794794
expect(route.route.path).toBe(ParamQueryRoute.path);
795-
expect(route.params).toStrictEqual({ id: '2', firstName: 'Jane', lastName: 'Smith' });
795+
expect(route.params).toStrictEqual({ id: 2, firstName: 'Jane', lastName: 'Smith' });
796796
expect(route.query).toStrictEqual({ page: '2', limit: '5' });
797797
});
798798

0 commit comments

Comments
 (0)