Skip to content

Commit 19786bf

Browse files
sdangolsvozzadreamorosi
authored
feat(event-handler): added support for catch all route (#4582)
Co-authored-by: Stefano Vozza <[email protected]> Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 33f7334 commit 19786bf

File tree

8 files changed

+157
-37
lines changed

8 files changed

+157
-37
lines changed

packages/commons/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
isNullOrUndefined,
2323
isNumber,
2424
isRecord,
25+
isRegExp,
2526
isStrictEqual,
2627
isString,
2728
isStringUndefinedNullEmpty,

packages/commons/src/typeUtils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,25 @@ const isStringUndefinedNullEmpty = (value: unknown) => {
176176
return false;
177177
};
178178

179+
/**
180+
* Check if a Regular Expression
181+
*
182+
* @example
183+
* ```typescript
184+
* import { isRegExp } from '@aws-lambda-powertools/commons/typeUtils';
185+
*
186+
* const value = /^foo.+$/;
187+
* if (isRegExp(value)) {
188+
* // value is a Regular Expression
189+
* }
190+
* ```
191+
*
192+
* @param value - The value to check
193+
*/
194+
const isRegExp = (value: unknown): value is RegExp => {
195+
return value instanceof RegExp;
196+
};
197+
179198
/**
180199
* Get the type of a value as a string.
181200
*
@@ -337,6 +356,7 @@ export {
337356
isNull,
338357
isNullOrUndefined,
339358
isStringUndefinedNullEmpty,
359+
isRegExp,
340360
getType,
341361
isStrictEqual,
342362
};

packages/commons/tests/unit/typeUtils.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isNullOrUndefined,
77
isNumber,
88
isRecord,
9+
isRegExp,
910
isStrictEqual,
1011
isString,
1112
isStringUndefinedNullEmpty,
@@ -224,6 +225,30 @@ describe('Functions: typeUtils', () => {
224225
});
225226
});
226227

228+
describe('Function: isRegExp', () => {
229+
it('returns true when the passed value is a Regular Expression', () => {
230+
// Prepare
231+
const value = /^hello.+$/;
232+
233+
// Act
234+
const result = isRegExp(value);
235+
236+
// Assess
237+
expect(result).toBe(true);
238+
});
239+
240+
it('returns false when the passed value is not a Regular Expression', () => {
241+
// Prepare
242+
const value = 123;
243+
244+
// Act
245+
const result = isRegExp(value);
246+
247+
// Assess
248+
expect(result).toBe(false);
249+
});
250+
});
251+
227252
describe('Function: getType', () => {
228253
it.each([
229254
{

packages/event-handler/src/rest/RouteHandlerRegistry.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2+
import { isRegExp } from '@aws-lambda-powertools/commons/typeutils';
23
import type {
34
DynamicRoute,
45
HttpMethod,
@@ -11,11 +12,13 @@ import { ParameterValidationError } from './errors.js';
1112
import { Route } from './Route.js';
1213
import {
1314
compilePath,
15+
getPathString,
1416
resolvePrefixedPath,
1517
validatePathPattern,
1618
} from './utils.js';
1719

1820
class RouteHandlerRegistry {
21+
readonly #regexRoutes: Map<string, DynamicRoute> = new Map();
1922
readonly #staticRoutes: Map<string, Route> = new Map();
2023
readonly #dynamicRoutesSet: Set<string> = new Set();
2124
readonly #dynamicRoutes: DynamicRoute[] = [];
@@ -44,8 +47,8 @@ class RouteHandlerRegistry {
4447
}
4548

4649
// Routes with more path segments are more specific
47-
const aSegments = a.path.split('/').length;
48-
const bSegments = b.path.split('/').length;
50+
const aSegments = getPathString(a.path).split('/').length;
51+
const bSegments = getPathString(b.path).split('/').length;
4952

5053
return bSegments - aSegments;
5154
}
@@ -103,6 +106,18 @@ class RouteHandlerRegistry {
103106

104107
const compiled = compilePath(route.path);
105108

109+
if (isRegExp(route.path)) {
110+
if (this.#regexRoutes.has(route.id)) {
111+
this.#logger.warn(
112+
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
113+
);
114+
}
115+
this.#regexRoutes.set(route.id, {
116+
...route,
117+
...compiled,
118+
});
119+
return;
120+
}
106121
if (compiled.isDynamic) {
107122
const dynamicRoute = {
108123
...route,
@@ -171,28 +186,10 @@ class RouteHandlerRegistry {
171186
};
172187
}
173188

174-
for (const route of this.#dynamicRoutes) {
175-
if (route.method !== method) continue;
176-
177-
const match = route.regex.exec(path);
178-
if (match?.groups) {
179-
const params = match.groups;
180-
181-
const processedParams = this.#processParams(params);
182-
183-
const validation = this.#validateParams(processedParams);
184-
185-
if (!validation.isValid) {
186-
throw new ParameterValidationError(validation.issues);
187-
}
188-
189-
return {
190-
handler: route.handler,
191-
params: processedParams,
192-
rawParams: params,
193-
middleware: route.middleware,
194-
};
195-
}
189+
const routes = [...this.#dynamicRoutes, ...this.#regexRoutes.values()];
190+
for (const route of routes) {
191+
const result = this.#processRoute(route, method, path);
192+
if (result) return result;
196193
}
197194

198195
return null;
@@ -215,6 +212,7 @@ class RouteHandlerRegistry {
215212
const routes = [
216213
...routeHandlerRegistry.#staticRoutes.values(),
217214
...routeHandlerRegistry.#dynamicRoutes,
215+
...routeHandlerRegistry.#regexRoutes.values(),
218216
];
219217
for (const route of routes) {
220218
this.register(
@@ -227,6 +225,28 @@ class RouteHandlerRegistry {
227225
);
228226
}
229227
}
228+
229+
#processRoute(route: DynamicRoute, method: HttpMethod, path: Path) {
230+
if (route.method !== method) return;
231+
232+
const match = route.regex.exec(getPathString(path));
233+
if (!match) return;
234+
235+
const params = match.groups || {};
236+
const processedParams = this.#processParams(params);
237+
const validation = this.#validateParams(processedParams);
238+
239+
if (!validation.isValid) {
240+
throw new ParameterValidationError(validation.issues);
241+
}
242+
243+
return {
244+
handler: route.handler,
245+
params: processedParams,
246+
rawParams: params,
247+
middleware: route.middleware,
248+
};
249+
}
230250
}
231251

232252
export { RouteHandlerRegistry };

packages/event-handler/src/rest/utils.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Readable, Writable } from 'node:stream';
2-
import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils';
2+
import {
3+
isRecord,
4+
isRegExp,
5+
isString,
6+
} from '@aws-lambda-powertools/commons/typeutils';
37
import type { APIGatewayProxyEvent } from 'aws-lambda';
48
import type {
59
CompiledRoute,
@@ -18,13 +22,21 @@ import {
1822
UNSAFE_CHARS,
1923
} from './constants.js';
2024

25+
export function getPathString(path: Path): string {
26+
return isString(path) ? path : path.source.replaceAll(/\\\//g, '/');
27+
}
28+
2129
export function compilePath(path: Path): CompiledRoute {
2230
const paramNames: string[] = [];
2331

24-
const regexPattern = path.replace(PARAM_PATTERN, (_match, paramName) => {
25-
paramNames.push(paramName);
26-
return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`;
27-
});
32+
const pathString = getPathString(path);
33+
const regexPattern = pathString.replace(
34+
PARAM_PATTERN,
35+
(_match, paramName) => {
36+
paramNames.push(paramName);
37+
return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`;
38+
}
39+
);
2840

2941
const finalPattern = `^${regexPattern}$`;
3042

@@ -39,9 +51,10 @@ export function compilePath(path: Path): CompiledRoute {
3951
export function validatePathPattern(path: Path): ValidationResult {
4052
const issues: string[] = [];
4153

42-
const matches = [...path.matchAll(PARAM_PATTERN)];
43-
if (path.includes(':')) {
44-
const expectedParams = path.split(':').length;
54+
const pathString = getPathString(path);
55+
const matches = [...pathString.matchAll(PARAM_PATTERN)];
56+
if (pathString.includes(':')) {
57+
const expectedParams = pathString.split(':').length;
4558
if (matches.length !== expectedParams - 1) {
4659
issues.push('Malformed parameter syntax. Use :paramName format.');
4760
}
@@ -227,14 +240,24 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => {
227240
/**
228241
* Resolves a prefixed path by combining the provided path and prefix.
229242
*
243+
* The function returns a RegExp if any of the path or prefix is a RegExp.
244+
* Otherwise, it returns a `/${string}` type value.
245+
*
230246
* @param path - The path to resolve
231247
* @param prefix - The prefix to prepend to the path
232248
*/
233249
export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => {
234-
if (prefix) {
235-
return path === '/' ? prefix : `${prefix}${path}`;
250+
if (!prefix) return path;
251+
if (isRegExp(prefix)) {
252+
if (isRegExp(path)) {
253+
return new RegExp(`${getPathString(prefix)}/${getPathString(path)}`);
254+
}
255+
return new RegExp(`${getPathString(prefix)}${path}`);
256+
}
257+
if (isRegExp(path)) {
258+
return new RegExp(`${prefix}/${getPathString(path)}`);
236259
}
237-
return path;
260+
return `${prefix}${path}`.replace(/\/$/, '') as Path;
238261
};
239262

240263
export const HttpResponseStream =

packages/event-handler/src/types/rest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ type HttpMethod = keyof typeof HttpVerbs;
7575

7676
type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes];
7777

78-
type Path = `/${string}`;
78+
type Path = `/${string}` | RegExp;
7979

8080
type RestRouteHandlerOptions = {
8181
handler: RouteHandler;

packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,32 @@ describe('Class: Router - Basic Routing', () => {
190190
'Handler for method: GET and path: /todos already exists. The previous handler will be replaced.'
191191
);
192192
});
193+
194+
it.each([
195+
['/files/test', 'GET', 'serveFileOverride'],
196+
['/api/v1/test', 'GET', 'apiVersioning'],
197+
['/users/1/files/test', 'GET', 'dynamicRegex1'],
198+
['/any-route', 'GET', 'getAnyRoute'],
199+
['/no-matches', 'POST', 'catchAllUnmatched'],
200+
])('routes %s %s to %s handler', async (path, method, expectedApi) => {
201+
// Prepare
202+
const app = new Router();
203+
app.get(/\/files\/.+/, async () => ({ api: 'serveFile' }));
204+
app.get(/\/files\/.+/, async () => ({ api: 'serveFileOverride' }));
205+
app.get(/\/api\/v\d+\/.*/, async () => ({ api: 'apiVersioning' }));
206+
app.get(/\/users\/:userId\/files\/.+/, async (reqCtx) => ({
207+
api: `dynamicRegex${reqCtx.params.userId}`,
208+
}));
209+
app.get(/.+/, async () => ({ api: 'getAnyRoute' }));
210+
app.route(async () => ({ api: 'catchAllUnmatched' }), {
211+
path: /.*/,
212+
method: [HttpVerbs.GET, HttpVerbs.POST],
213+
});
214+
215+
// Act
216+
const result = await app.resolve(createTestEvent(path, method), context);
217+
218+
// Assess
219+
expect(JSON.parse(result.body).api).toEqual(expectedApi);
220+
});
193221
});

packages/event-handler/tests/unit/rest/utils.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,12 +581,15 @@ describe('Path Utilities', () => {
581581
{ path: '/test', prefix: '/prefix', expected: '/prefix/test' },
582582
{ path: '/', prefix: '/prefix', expected: '/prefix' },
583583
{ path: '/test', expected: '/test' },
584+
{ path: /.+/, prefix: '/prefix', expected: /\/prefix\/.+/ },
585+
{ path: '/test', prefix: /\/prefix/, expected: /\/prefix\/test/ },
586+
{ path: /.+/, prefix: /\/prefix/, expected: /\/prefix\/.+/ },
584587
])('resolves prefixed path', ({ path, prefix, expected }) => {
585588
// Prepare & Act
586589
const resolvedPath = resolvePrefixedPath(path as Path, prefix as Path);
587590

588591
// Assert
589-
expect(resolvedPath).toBe(expected);
592+
expect(resolvedPath).toEqual(expected);
590593
});
591594
});
592595

0 commit comments

Comments
 (0)