Skip to content

Commit 9497786

Browse files
authored
feat(routing): validate duplicate modern routes (#146)
* feat: reject duplicate route registrations * feat: validate duplicate modern route names * chore: validate duplicate modern routes
1 parent 66a7bc7 commit 9497786

File tree

3 files changed

+123
-1
lines changed

3 files changed

+123
-1
lines changed

src/routing/register-routes.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,52 @@ describe('register routes', () => {
7979
expect(response.headers['x-route-middleware']).toBe('applied');
8080
expect(response.body).toEqual({ bodyType: 'undefined' });
8181
});
82+
83+
test('it rejects duplicate route signatures', () => {
84+
const app = new Koa() as Application;
85+
86+
expect(() =>
87+
registerRoutes(app, [
88+
Route({
89+
method: 'GET',
90+
path: '/users',
91+
handler: async scope => {
92+
scope.response.body = [{ id: 1 }];
93+
},
94+
}),
95+
Route({
96+
method: 'GET',
97+
path: '/users',
98+
handler: async scope => {
99+
scope.response.body = [{ id: 2 }];
100+
},
101+
}),
102+
]),
103+
).toThrow('Duplicate route signature detected: GET /users.');
104+
});
105+
106+
test('it rejects duplicate route names', () => {
107+
const app = new Koa() as Application;
108+
109+
expect(() =>
110+
registerRoutes(app, [
111+
Route({
112+
name: 'users.list',
113+
method: 'GET',
114+
path: '/users',
115+
handler: async scope => {
116+
scope.response.body = [{ id: 1 }];
117+
},
118+
}),
119+
Route({
120+
name: 'users.list',
121+
method: 'POST',
122+
path: '/users',
123+
handler: async scope => {
124+
scope.response.body = [{ id: 2 }];
125+
},
126+
}),
127+
]),
128+
).toThrow('Duplicate route name detected: users.list.');
129+
});
82130
});

src/routing/register-routes.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ export function registerRoutes(app: Application, routes: RouteDefinition[] = [])
2323

2424
function createRouter(routes: RouteDefinition[]): RouterInstance {
2525
const router = new Router();
26+
const registrations = expandRouteDefinitions(routes);
2627

27-
for (const route of expandRouteDefinitions(routes)) {
28+
validateUniqueRouteNames(routes);
29+
validateUniqueRouteSignatures(registrations);
30+
31+
for (const route of registrations) {
2832
router[route.method](route.path, ...(route.middleware as Middleware<DefaultState, DefaultContext & HttpScope>[]));
2933
}
3034

@@ -56,3 +60,33 @@ function resolveRouteMiddleware(route: RouteDefinition): RouteRegistration['midd
5660

5761
return route.parseBody ? [koaBody(route.bodyOptions), ...middlewareStack] : middlewareStack;
5862
}
63+
64+
function validateUniqueRouteSignatures(registrations: RouteRegistration[]): void {
65+
const signatures = new Set<string>();
66+
67+
for (const registration of registrations) {
68+
const signature = `${registration.method} ${registration.path}`;
69+
70+
if (signatures.has(signature)) {
71+
throw new Error(`Duplicate route signature detected: ${registration.method.toUpperCase()} ${registration.path}.`);
72+
}
73+
74+
signatures.add(signature);
75+
}
76+
}
77+
78+
function validateUniqueRouteNames(routes: RouteDefinition[]): void {
79+
const routeNames = new Set<string>();
80+
81+
for (const route of routes) {
82+
if (route.name === undefined) {
83+
continue;
84+
}
85+
86+
if (routeNames.has(route.name)) {
87+
throw new Error(`Duplicate route name detected: ${route.name}.`);
88+
}
89+
90+
routeNames.add(route.name);
91+
}
92+
}

tests/function-first-routing.e2e.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,4 +267,44 @@ describe('Function First Routing E2E Test', () => {
267267
expect(patchResponse.body).toEqual({ method: 'PATCH' });
268268
expect(deleteResponse.body).toEqual({ method: 'DELETE' });
269269
});
270+
271+
test('it should reject duplicate route signatures through the test agent', () => {
272+
expect(() =>
273+
createTestAgent({
274+
...koalaDefaultConfig,
275+
routes: [
276+
Route({
277+
method: 'GET',
278+
path: '/users',
279+
handler: async (scope: HttpScope) => {
280+
scope.response.body = [{ id: 1 }];
281+
},
282+
}),
283+
Route({
284+
method: 'GET',
285+
path: '/users',
286+
handler: async (scope: HttpScope) => {
287+
scope.response.body = [{ id: 2 }];
288+
},
289+
}),
290+
],
291+
}),
292+
).toThrow('Duplicate route signature detected: GET /users.');
293+
});
294+
295+
test('it should reject duplicate route names through the test agent', () => {
296+
expect(() =>
297+
createTestAgent({
298+
...koalaDefaultConfig,
299+
routes: [
300+
Get('/users', 'users.list', async (scope: HttpScope) => {
301+
scope.response.body = [{ id: 1 }];
302+
}),
303+
Get('/admins', 'users.list', async (scope: HttpScope) => {
304+
scope.response.body = [{ id: 2 }];
305+
}),
306+
],
307+
}),
308+
).toThrow('Duplicate route name detected: users.list.');
309+
});
270310
});

0 commit comments

Comments
 (0)