Skip to content

Commit ae0e55d

Browse files
committed
feat: add new utility that help to valdiate route params and/or migrate from previous versions to v14+
1 parent df1e0bf commit ae0e55d

File tree

10 files changed

+611
-2
lines changed

10 files changed

+611
-2
lines changed

FULL_MIGRATION_TO_V15+.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,35 @@ The new version uses **`path-to-regexp` v8** via a wrapper (`src/utils/path-to-r
134134

135135
> **Note:** Custom regex patterns in parameters (`:param(regex)`) are **no longer supported** in v15+ due to path-to-regexp v8. Use validation in handlers or middleware instead.
136136

137+
- **Helper available (since v15.2):** Use `createParameterValidationMiddleware(name, regexp)` to keep regex validation while moving it into middleware. The same helper can also be used inline on specific routes.
138+
139+
```js
140+
import Router, { createParameterValidationMiddleware } from '@koa/router';
141+
142+
const validateUserId = createParameterValidationMiddleware(
143+
'id',
144+
/^[0-9]+$/
145+
);
146+
147+
router.param('id', validateUserId).get('/user/:id', (ctx) => {
148+
ctx.body = { id: Number(ctx.params.id) };
149+
});
150+
```
151+
152+
Inline per-route example (same helper):
153+
154+
```js
155+
import Router, { createParameterValidationMiddleware } from '@koa/router';
156+
157+
router.get(
158+
'/user/:id',
159+
createParameterValidationMiddleware('id', /^[0-9]+$/),
160+
(ctx) => {
161+
ctx.body = { id: Number(ctx.params.id) };
162+
}
163+
);
164+
```
165+
137166
- **Migration strategy:**
138167
- **Before (v10):**
139168

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,36 @@ router.get('/files/{/*path}', (ctx) => {
490490

491491
**Note:** Custom regex patterns in parameters (`:param(regex)`) are **no longer supported** in v14+ due to path-to-regexp v8. Use validation in handlers or middleware instead.
492492

493+
**Helper for parameter validation (v14+)** <small>(Added on v15.2)</small>
494+
495+
If you want to keep regex-style validation, register a param middleware instead:
496+
497+
```javascript
498+
import Router, { createParameterValidationMiddleware } from '@koa/router';
499+
500+
const uuid =
501+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
502+
503+
const validateId = createParameterValidationMiddleware('id', uuid);
504+
505+
router.param('id', validateId).get('/role/:id', middleware);
506+
```
507+
508+
Or validate inline on a specific route:
509+
510+
```javascript
511+
import Router, { createParameterValidationMiddleware } from '@koa/router';
512+
513+
const uuid =
514+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
515+
516+
router.get(
517+
'/role/:id',
518+
createParameterValidationMiddleware('id', uuid),
519+
middleware
520+
);
521+
```
522+
493523
### router.routes()
494524

495525
Returns router middleware which dispatches matched routes.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"test:recipes": "TS_NODE_PROJECT=tsconfig.recipes.json node --require ts-node/register --test recipes/**/*.test.ts",
111111
"pretest:all": "npm run lint && npm run build",
112112
"test:all": "TS_NODE_PROJECT=tsconfig.ts-node.json node --require ts-node/register --test test/*.test.ts test/**/*.test.ts recipes/**/*.test.ts",
113-
"test:coverage": "c8 npm run test:all",
113+
"test:coverage": "c8 --all --exclude eslint.config.js --exclude tsup.config.ts --exclude src/types.ts --exclude \"bench/**\" --exclude \"dist/**\" --exclude \"recipes/**\" --exclude \"test/**\" --exclude-after-remap npm run test:all",
114114
"ts:check": "tsc --noEmit --project tsconfig.typecheck.json",
115115
"ts:check:test": "tsc --noEmit --project tsconfig.test.json",
116116
"prebuild": "rimraf dist",

recipes/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Common patterns and recipes for building real-world applications with @koa/route
1212
| **[Authentication & Authorization](./authentication-authorization/)** | JWT-based authentication with role-based access control |
1313
| **[Request Validation](./request-validation/)** | Validate request data with Joi middleware |
1414
| **[Parameter Validation](./parameter-validation/)** | Validate and transform URL parameters using `router.param()` |
15+
| **[Regex Parameter Validation](./regex-parameter-validation/)** | Validate URL parameters with regex (replacement for `:param(regex)` in v14+) |
1516
| **[API Versioning](./api-versioning/)** | Implement API versioning with multiple routers |
1617
| **[Error Handling](./error-handling/)** | Centralized error handling with custom error classes |
1718
| **[Pagination](./pagination/)** | Pagination middleware with configurable limits and metadata |
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Tests for Regex Parameter Validation Recipe
3+
*/
4+
5+
import { describe, it } from 'node:test';
6+
import * as assert from 'node:assert';
7+
import * as http from 'node:http';
8+
import request from 'supertest';
9+
import Koa from 'koa';
10+
11+
import Router from '../router-module-loader';
12+
import { createParameterValidationMiddleware } from '../router-module-loader';
13+
14+
describe('Regex Parameter Validation', () => {
15+
it('should validate UUID via router.param()', async () => {
16+
const app = new Koa();
17+
const router = new Router();
18+
19+
const uuidRegex =
20+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
21+
22+
router.param(
23+
'id',
24+
createParameterValidationMiddleware('id', uuidRegex, {
25+
status: 400,
26+
message: 'Invalid id (expected UUID)'
27+
})
28+
);
29+
30+
router.get('/role/:id', (ctx) => {
31+
ctx.body = { id: ctx.params.id, ok: true };
32+
});
33+
34+
app.use(router.routes());
35+
36+
const validUUID = '123e4567-e89b-12d3-a456-426614174000';
37+
const res1 = await request(http.createServer(app.callback()))
38+
.get(`/role/${validUUID}`)
39+
.expect(200);
40+
41+
assert.strictEqual(res1.body.ok, true);
42+
assert.strictEqual(res1.body.id, validUUID);
43+
44+
await request(http.createServer(app.callback()))
45+
.get('/role/invalid-id')
46+
.expect(400);
47+
});
48+
49+
it('should validate UUID inline on a specific route', async () => {
50+
const app = new Koa();
51+
const router = new Router();
52+
53+
const uuidRegex =
54+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
55+
56+
router.get(
57+
'/roles/:id',
58+
createParameterValidationMiddleware('id', uuidRegex, {
59+
status: 400,
60+
message: 'Invalid id (expected UUID)'
61+
}),
62+
(ctx) => {
63+
ctx.body = { id: ctx.params.id, ok: true };
64+
}
65+
);
66+
67+
// A different route with same param name, but without validation
68+
router.get('/roles-unvalidated/:id', (ctx) => {
69+
ctx.body = { id: ctx.params.id, ok: true };
70+
});
71+
72+
app.use(router.routes());
73+
74+
const validUUID = '123e4567-e89b-12d3-a456-426614174000';
75+
await request(http.createServer(app.callback()))
76+
.get(`/roles/${validUUID}`)
77+
.expect(200);
78+
79+
await request(http.createServer(app.callback()))
80+
.get('/roles/invalid-id')
81+
.expect(400);
82+
83+
// This should NOT be validated (inline middleware applies only to /roles/:id)
84+
await request(http.createServer(app.callback()))
85+
.get('/roles-unvalidated/invalid-id')
86+
.expect(200);
87+
});
88+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Regex Parameter Validation Recipe (v14+)
3+
*
4+
* Demonstrates how to validate route parameters with regex in v14+,
5+
* where `:param(regex)` is no longer supported in the path string.
6+
*
7+
* Shows both styles:
8+
* - router.param('id', createParameterValidationMiddleware(...))
9+
* - router.get('/role/:id', createParameterValidationMiddleware(...), handler)
10+
*/
11+
12+
import Router, {
13+
createParameterValidationMiddleware
14+
} from '../router-module-loader';
15+
16+
const uuidRegex =
17+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
18+
19+
const router = new Router();
20+
21+
// ===========================================
22+
// Style A: router.param() (applies to all routes using :id)
23+
// ===========================================
24+
router.param(
25+
'id',
26+
createParameterValidationMiddleware('id', uuidRegex, {
27+
status: 400,
28+
message: 'Invalid id (expected UUID)'
29+
})
30+
);
31+
32+
router.get('/role/:id', (ctx) => {
33+
ctx.body = { id: ctx.params.id, source: 'router.param' };
34+
});
35+
36+
// ===========================================
37+
// Style B: Inline per-route middleware (applies to this route only)
38+
// ===========================================
39+
router.get(
40+
'/roles/:id',
41+
createParameterValidationMiddleware('id', uuidRegex, {
42+
status: 400,
43+
message: 'Invalid id (expected UUID)'
44+
}),
45+
(ctx) => {
46+
ctx.body = { id: ctx.params.id, source: 'inline' };
47+
}
48+
);
49+
50+
export { router };

recipes/router-module-loader.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@
99
* - For dist build: '../dist/index'
1010
* - For published package: '@koa/router'
1111
*/
12-
export { default, default as Router } from '../src/index';
12+
export {
13+
default,
14+
default as Router,
15+
createParameterValidationMiddleware
16+
} from '../src/index';
1317
export type * from '../src/index';

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ export type {
2020
RouterWithMethods
2121
} from './types';
2222

23+
export {
24+
createParameterValidationMiddleware,
25+
type ParameterValidationOptions
26+
} from './utils/parameter-match';
27+
2328
export { default, Router, type RouterInstance } from './router';

src/utils/parameter-match.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Parameter validation helpers.
3+
*
4+
* These utilities exist to help migrate legacy `:param(regex)` usage from older
5+
* router/path-to-regexp versions to v14+ (path-to-regexp v8), where inline
6+
* parameter regexes are no longer supported in route strings.
7+
*/
8+
9+
import createHttpError from 'http-errors';
10+
11+
import type {
12+
RouterContext,
13+
RouterMiddleware,
14+
RouterParameterMiddleware
15+
} from '../types';
16+
17+
/**
18+
* Options for createParameterValidationMiddleware helper
19+
*/
20+
export type ParameterValidationOptions = {
21+
/**
22+
* HTTP status to use when the value does not match
23+
* @default 400
24+
*/
25+
status?: number;
26+
27+
/**
28+
* Error message to use when the value does not match
29+
* @default `Invalid value for parameter "<parameterName>"`
30+
*/
31+
message?: string;
32+
33+
/**
34+
* Whether the error message should be exposed to the client.
35+
* Passed through to HttpError#expose.
36+
*/
37+
expose?: boolean;
38+
39+
/**
40+
* Optional custom error factory. If provided, it is used
41+
* instead of the default HttpError.
42+
*/
43+
createError?: (parameterName: string, value: string) => Error;
44+
};
45+
46+
/**
47+
* Convenience helper to recreate legacy `:param(regex)` validation.
48+
*
49+
* @example
50+
* const validateUuid = createParameterValidationMiddleware('id', uuidRegex);
51+
* router.param('id', validateUuid).get('/role/:id', handler);
52+
* router.get('/role/:id', createParameterValidationMiddleware('id', uuidRegex)
53+
*/
54+
export function createParameterValidationMiddleware(
55+
parameterName: string,
56+
pattern: RegExp,
57+
options: ParameterValidationOptions = {}
58+
): RouterMiddleware<any, any, any> & RouterParameterMiddleware<any, any, any> {
59+
if (!(pattern instanceof RegExp)) {
60+
throw new TypeError('pattern must be a RegExp instance');
61+
}
62+
63+
// clone the RegExp once so we do not mutate the caller's instance
64+
const matcher = new RegExp(pattern.source, pattern.flags);
65+
66+
const createDefaultHttpError = (message: string) => {
67+
const httpError = createHttpError(options.status ?? 400, message);
68+
if (options.expose !== undefined) {
69+
httpError.expose = options.expose;
70+
}
71+
72+
return httpError;
73+
};
74+
75+
const validateValue = (value: string) => {
76+
// ensure deterministic behavior even when /g or /y flags are present
77+
if (matcher.global || matcher.sticky) {
78+
matcher.lastIndex = 0;
79+
}
80+
81+
if (matcher.test(value)) {
82+
return;
83+
}
84+
85+
if (options.createError) {
86+
throw options.createError(parameterName, value);
87+
}
88+
89+
throw createDefaultHttpError(
90+
options.message ??
91+
`Invalid value for parameter "${parameterName}": "${value}"`
92+
);
93+
};
94+
95+
const middleware: RouterMiddleware<any, any, any> &
96+
RouterParameterMiddleware<any, any, any> = async (
97+
argument1: unknown,
98+
argument2: unknown,
99+
argument3?: unknown
100+
) => {
101+
// called as a normal route middleware: (ctx, next)
102+
if (typeof argument1 !== 'string') {
103+
const context = argument1 as RouterContext<any, any, any> & {
104+
params?: Record<string, string>;
105+
};
106+
const next = argument2 as Parameters<RouterMiddleware<any, any, any>>[1];
107+
108+
const parameterValue =
109+
context.params && parameterName in context.params
110+
? context.params[parameterName]
111+
: undefined;
112+
113+
if (typeof parameterValue !== 'string') {
114+
throw createDefaultHttpError(
115+
options.message ??
116+
`Missing required parameter "${parameterName}" in route params`
117+
);
118+
}
119+
120+
validateValue(parameterValue);
121+
return next();
122+
}
123+
124+
// called as router.param middleware: (value, ctx, next)
125+
const value = argument1 as string;
126+
// // keep for compatibility (router.param passes ctx as second arg)
127+
// void argument2;
128+
const next = argument3 as () => Promise<unknown>;
129+
130+
validateValue(value);
131+
return next();
132+
};
133+
134+
return middleware;
135+
}

0 commit comments

Comments
 (0)