Skip to content

Commit 176807d

Browse files
authored
feat(routing): add named route path helpers (#149)
* feat: add named route path catalog * feat: resolve named route path templates * feat: add named route path helpers * chore: cover named route path helpers * chore: code refactoring * fix: reject duplicate named route paths
1 parent 447496f commit 176807d

File tree

7 files changed

+364
-0
lines changed

7 files changed

+364
-0
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { createPathFor } from './create-path-for';
3+
import { Get } from './http-verb-helpers';
4+
import { RouteGroup } from './route-group';
5+
6+
describe('create path for', () => {
7+
describe('path resolution', () => {
8+
test('it creates a pure path resolver for named routes', () => {
9+
const pathFor = createPathFor([
10+
Get(
11+
'/users/:id',
12+
'users.show',
13+
vi.fn(async () => undefined),
14+
),
15+
]);
16+
17+
const path = pathFor('users.show', { id: '42' });
18+
19+
expect(path).toBe('/users/42');
20+
});
21+
22+
test('it resolves nested grouped route names', () => {
23+
const pathFor = createPathFor([
24+
RouteGroup(
25+
{
26+
prefix: '/api',
27+
namePrefix: 'api.',
28+
},
29+
() => [
30+
RouteGroup(
31+
{
32+
prefix: '/users',
33+
namePrefix: 'users.',
34+
},
35+
() => [
36+
Get(
37+
'/:id',
38+
'show',
39+
vi.fn(async () => undefined),
40+
),
41+
],
42+
),
43+
],
44+
),
45+
]);
46+
47+
const path = pathFor('api.users.show', { id: '42' });
48+
49+
expect(path).toBe('/api/users/42');
50+
});
51+
});
52+
53+
describe('errors', () => {
54+
test('it throws when the route name does not exist', () => {
55+
const pathFor = createPathFor([]);
56+
57+
const act = () => pathFor('users.show');
58+
59+
expect(act).toThrow('Unknown route name: users.show.');
60+
});
61+
62+
test('it throws when the named route requires a missing path parameter', () => {
63+
const pathFor = createPathFor([
64+
Get(
65+
'/users/:id',
66+
'users.show',
67+
vi.fn(async () => undefined),
68+
),
69+
]);
70+
71+
const act = () => pathFor('users.show');
72+
73+
expect(act).toThrow('Missing required path parameter: id.');
74+
});
75+
});
76+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createPathCatalog } from '@/routing/source/create-path-catalog';
2+
import { createPathTemplateResolver } from '@/routing/source/resolve-path-template';
3+
import type { RouteSource } from '@/routing/source/route-source';
4+
5+
type PathParamValue = string | number | boolean;
6+
type PathParams = Record<string, PathParamValue>;
7+
type PathFor = (name: string, params?: PathParams) => string;
8+
9+
export function createPathFor(routeSources: RouteSource[]): PathFor {
10+
const resolvers = new Map<string, (params?: PathParams) => string>();
11+
12+
for (const [name, pathTemplate] of createPathCatalog(routeSources)) {
13+
resolvers.set(name, createPathTemplateResolver(pathTemplate));
14+
}
15+
16+
return (name: string, params?: PathParams) => {
17+
const resolvePath = resolvers.get(name);
18+
19+
if (resolvePath === undefined) {
20+
throw new Error(`Unknown route name: ${name}.`);
21+
}
22+
23+
return resolvePath(params);
24+
};
25+
}

src/routing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Route } from './route';
2+
export { createPathFor } from './helpers/create-path-for';
23
export { RouteGroup } from './helpers/route-group';
34
export { Any, Delete, Get, Head, Options, Patch, Post, Put } from './helpers/http-verb-helpers';
45
export type { RouteDeclaration } from './route';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { Get } from '@/routing/helpers/http-verb-helpers';
3+
import { RouteGroup } from '@/routing/helpers/route-group';
4+
import { Route } from '@/routing/route';
5+
import { createPathCatalog } from './create-path-catalog';
6+
7+
describe('create path catalog', () => {
8+
test('it collects named route paths from route sources', () => {
9+
const routes = [
10+
Get(
11+
'/users',
12+
'users.index',
13+
vi.fn(async () => undefined),
14+
),
15+
Route({
16+
name: 'users.show',
17+
method: 'GET',
18+
path: '/users/:id',
19+
handler: vi.fn(async () => undefined),
20+
}),
21+
];
22+
23+
const catalog = createPathCatalog(routes);
24+
25+
expect(catalog).toEqual(
26+
new Map([
27+
['users.index', '/users'],
28+
['users.show', '/users/:id'],
29+
]),
30+
);
31+
});
32+
33+
test('it collects grouped route names using their normalized paths', () => {
34+
const routes = [
35+
RouteGroup(
36+
{
37+
prefix: '/api',
38+
namePrefix: 'api.',
39+
},
40+
() => [
41+
Get(
42+
'/users/:id',
43+
'users.show',
44+
vi.fn(async () => undefined),
45+
),
46+
],
47+
),
48+
];
49+
50+
const catalog = createPathCatalog(routes);
51+
52+
expect(catalog).toEqual(new Map([['api.users.show', '/api/users/:id']]));
53+
});
54+
55+
test('it ignores unnamed routes', () => {
56+
const routes = [
57+
Get(
58+
'/users',
59+
vi.fn(async () => undefined),
60+
),
61+
Get(
62+
'/users/:id',
63+
'users.show',
64+
vi.fn(async () => undefined),
65+
),
66+
];
67+
68+
const catalog = createPathCatalog(routes);
69+
70+
expect(catalog).toEqual(new Map([['users.show', '/users/:id']]));
71+
});
72+
73+
test('it throws when normalized route names are duplicated', () => {
74+
const routes = [
75+
RouteGroup(
76+
{
77+
prefix: '/api',
78+
namePrefix: 'api.',
79+
},
80+
() => [
81+
Get(
82+
'/users',
83+
'users.index',
84+
vi.fn(async () => undefined),
85+
),
86+
],
87+
),
88+
Route({
89+
name: 'api.users.index',
90+
method: 'GET',
91+
path: '/members',
92+
handler: vi.fn(async () => undefined),
93+
}),
94+
];
95+
96+
const createCatalog = () => createPathCatalog(routes);
97+
98+
expect(createCatalog).toThrow('Duplicate route name detected: api.users.index.');
99+
});
100+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { normalizeRouteSources } from '@/routing/source/normalize-route-sources';
2+
import type { RouteSource } from '@/routing/source/route-source';
3+
4+
export function createPathCatalog(routeSources: RouteSource[]): Map<string, string> {
5+
const catalog = new Map<string, string>();
6+
7+
for (const route of normalizeRouteSources(routeSources)) {
8+
if (route.name === undefined) {
9+
continue;
10+
}
11+
12+
if (catalog.has(route.name)) {
13+
throw new Error(`Duplicate route name detected: ${route.name}.`);
14+
}
15+
16+
catalog.set(route.name, route.path);
17+
}
18+
19+
return catalog;
20+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { createPathTemplateResolver, resolvePathTemplate } from './resolve-path-template';
3+
4+
describe('resolve path template', () => {
5+
describe('path resolution', () => {
6+
test('it returns a static path unchanged', () => {
7+
const path = resolvePathTemplate('/users');
8+
9+
expect(path).toBe('/users');
10+
});
11+
12+
test('it returns an empty path template unchanged', () => {
13+
const path = resolvePathTemplate('');
14+
15+
expect(path).toBe('');
16+
});
17+
18+
test('it replaces a path parameter with its value', () => {
19+
const path = resolvePathTemplate('/users/:id', { id: '42' });
20+
21+
expect(path).toBe('/users/42');
22+
});
23+
24+
test('it replaces multiple path parameters', () => {
25+
const path = resolvePathTemplate('/teams/:teamId/users/:userId', {
26+
teamId: 'alpha',
27+
userId: '42',
28+
});
29+
30+
expect(path).toBe('/teams/alpha/users/42');
31+
});
32+
33+
test('it resolves a template that starts with a path parameter', () => {
34+
const path = resolvePathTemplate(':id/details', { id: '42' });
35+
36+
expect(path).toBe('42/details');
37+
});
38+
39+
test('it creates a reusable resolver for a path template', () => {
40+
const resolvePath = createPathTemplateResolver('/teams/:teamId/users/:userId');
41+
42+
const path = resolvePath({
43+
teamId: 'alpha',
44+
userId: '42',
45+
});
46+
47+
expect(path).toBe('/teams/alpha/users/42');
48+
});
49+
});
50+
51+
describe('errors', () => {
52+
test('it throws when a required path parameter is missing', () => {
53+
const act = () => resolvePathTemplate('/users/:id');
54+
55+
expect(act).toThrow('Missing required path parameter: id.');
56+
});
57+
});
58+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
type PathParamValue = string | number | boolean;
2+
type PathParams = Record<string, PathParamValue>;
3+
type PathSegment =
4+
| {
5+
kind: 'static';
6+
value: string;
7+
}
8+
| {
9+
kind: 'param';
10+
key: string;
11+
};
12+
13+
export function resolvePathTemplate(pathTemplate: string, params: PathParams = {}): string {
14+
return createPathTemplateResolver(pathTemplate)(params);
15+
}
16+
17+
export function createPathTemplateResolver(pathTemplate: string): (params?: PathParams) => string {
18+
const segments = parsePathTemplate(pathTemplate);
19+
20+
return (params: PathParams = {}) => {
21+
let path = '';
22+
23+
for (const segment of segments) {
24+
if (segment.kind === 'static') {
25+
path += segment.value;
26+
continue;
27+
}
28+
29+
const value = params[segment.key];
30+
31+
if (value === undefined) {
32+
throw new Error(`Missing required path parameter: ${segment.key}.`);
33+
}
34+
35+
path += encodeURIComponent(String(value));
36+
}
37+
38+
return path;
39+
};
40+
}
41+
42+
function parsePathTemplate(pathTemplate: string): PathSegment[] {
43+
const segments: PathSegment[] = [];
44+
const pattern = /:([A-Za-z0-9_]+)/g;
45+
let lastIndex = 0;
46+
47+
for (const match of pathTemplate.matchAll(pattern)) {
48+
const index = match.index as number;
49+
const placeholder = match[0];
50+
const key = match[1] as string;
51+
52+
if (index > lastIndex) {
53+
segments.push({
54+
kind: 'static',
55+
value: pathTemplate.slice(lastIndex, index),
56+
});
57+
}
58+
59+
segments.push({
60+
kind: 'param',
61+
key,
62+
});
63+
64+
lastIndex = index + placeholder.length;
65+
}
66+
67+
if (lastIndex < pathTemplate.length) {
68+
segments.push({
69+
kind: 'static',
70+
value: pathTemplate.slice(lastIndex),
71+
});
72+
}
73+
74+
if (segments.length === 0) {
75+
return [
76+
{
77+
kind: 'static',
78+
value: pathTemplate,
79+
},
80+
];
81+
}
82+
83+
return segments;
84+
}

0 commit comments

Comments
 (0)