diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index aed1788618..17906e6e26 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -22,6 +22,7 @@ export { isNullOrUndefined, isNumber, isRecord, + isRegExp, isStrictEqual, isString, isStringUndefinedNullEmpty, diff --git a/packages/commons/src/typeUtils.ts b/packages/commons/src/typeUtils.ts index 2dca860b84..10cc93214f 100644 --- a/packages/commons/src/typeUtils.ts +++ b/packages/commons/src/typeUtils.ts @@ -176,6 +176,25 @@ const isStringUndefinedNullEmpty = (value: unknown) => { return false; }; +/** + * Check if a Regular Expression + * + * @example + * ```typescript + * import { isRegExp } from '@aws-lambda-powertools/commons/typeUtils'; + * + * const value = /^foo.+$/; + * if (isRegExp(value)) { + * // value is a Regular Expression + * } + * ``` + * + * @param value - The value to check + */ +const isRegExp = (value: unknown): value is RegExp => { + return value instanceof RegExp; +}; + /** * Get the type of a value as a string. * @@ -337,6 +356,7 @@ export { isNull, isNullOrUndefined, isStringUndefinedNullEmpty, + isRegExp, getType, isStrictEqual, }; diff --git a/packages/commons/tests/unit/typeUtils.test.ts b/packages/commons/tests/unit/typeUtils.test.ts index 99b281be87..72a719adf6 100644 --- a/packages/commons/tests/unit/typeUtils.test.ts +++ b/packages/commons/tests/unit/typeUtils.test.ts @@ -6,6 +6,7 @@ import { isNullOrUndefined, isNumber, isRecord, + isRegExp, isStrictEqual, isString, isStringUndefinedNullEmpty, @@ -224,6 +225,30 @@ describe('Functions: typeUtils', () => { }); }); + describe('Function: isRegExp', () => { + it('returns true when the passed value is a Regular Expression', () => { + // Prepare + const value = /^hello.+$/; + + // Act + const result = isRegExp(value); + + // Assess + expect(result).toBe(true); + }); + + it('returns false when the passed value is not a Regular Expression', () => { + // Prepare + const value = 123; + + // Act + const result = isRegExp(value); + + // Assess + expect(result).toBe(false); + }); + }); + describe('Function: getType', () => { it.each([ { diff --git a/packages/event-handler/src/rest/RouteHandlerRegistry.ts b/packages/event-handler/src/rest/RouteHandlerRegistry.ts index 116b95b91e..c191d08398 100644 --- a/packages/event-handler/src/rest/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/rest/RouteHandlerRegistry.ts @@ -1,4 +1,5 @@ import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; +import { isRegExp } from '@aws-lambda-powertools/commons/typeutils'; import type { DynamicRoute, HttpMethod, @@ -11,11 +12,13 @@ import { ParameterValidationError } from './errors.js'; import { Route } from './Route.js'; import { compilePath, + getPathString, resolvePrefixedPath, validatePathPattern, } from './utils.js'; class RouteHandlerRegistry { + readonly #regexRoutes: Map = new Map(); readonly #staticRoutes: Map = new Map(); readonly #dynamicRoutesSet: Set = new Set(); readonly #dynamicRoutes: DynamicRoute[] = []; @@ -44,8 +47,8 @@ class RouteHandlerRegistry { } // Routes with more path segments are more specific - const aSegments = a.path.split('/').length; - const bSegments = b.path.split('/').length; + const aSegments = getPathString(a.path).split('/').length; + const bSegments = getPathString(b.path).split('/').length; return bSegments - aSegments; } @@ -103,6 +106,18 @@ class RouteHandlerRegistry { const compiled = compilePath(route.path); + if (isRegExp(route.path)) { + if (this.#regexRoutes.has(route.id)) { + this.#logger.warn( + `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` + ); + } + this.#regexRoutes.set(route.id, { + ...route, + ...compiled, + }); + return; + } if (compiled.isDynamic) { const dynamicRoute = { ...route, @@ -171,28 +186,10 @@ class RouteHandlerRegistry { }; } - for (const route of this.#dynamicRoutes) { - if (route.method !== method) continue; - - const match = route.regex.exec(path); - if (match?.groups) { - const params = match.groups; - - const processedParams = this.#processParams(params); - - const validation = this.#validateParams(processedParams); - - if (!validation.isValid) { - throw new ParameterValidationError(validation.issues); - } - - return { - handler: route.handler, - params: processedParams, - rawParams: params, - middleware: route.middleware, - }; - } + const routes = [...this.#dynamicRoutes, ...this.#regexRoutes.values()]; + for (const route of routes) { + const result = this.#processRoute(route, method, path); + if (result) return result; } return null; @@ -215,6 +212,7 @@ class RouteHandlerRegistry { const routes = [ ...routeHandlerRegistry.#staticRoutes.values(), ...routeHandlerRegistry.#dynamicRoutes, + ...routeHandlerRegistry.#regexRoutes.values(), ]; for (const route of routes) { this.register( @@ -227,6 +225,28 @@ class RouteHandlerRegistry { ); } } + + #processRoute(route: DynamicRoute, method: HttpMethod, path: Path) { + if (route.method !== method) return; + + const match = route.regex.exec(getPathString(path)); + if (!match) return; + + const params = match.groups || {}; + const processedParams = this.#processParams(params); + const validation = this.#validateParams(processedParams); + + if (!validation.isValid) { + throw new ParameterValidationError(validation.issues); + } + + return { + handler: route.handler, + params: processedParams, + rawParams: params, + middleware: route.middleware, + }; + } } export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index b0943d3e4f..caf4ad82f8 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -1,5 +1,9 @@ import { Readable, Writable } from 'node:stream'; -import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; +import { + isRecord, + isRegExp, + isString, +} from '@aws-lambda-powertools/commons/typeutils'; import type { APIGatewayProxyEvent } from 'aws-lambda'; import type { CompiledRoute, @@ -18,13 +22,21 @@ import { UNSAFE_CHARS, } from './constants.js'; +export function getPathString(path: Path): string { + return isString(path) ? path : path.source.replaceAll(/\\\//g, '/'); +} + export function compilePath(path: Path): CompiledRoute { const paramNames: string[] = []; - const regexPattern = path.replace(PARAM_PATTERN, (_match, paramName) => { - paramNames.push(paramName); - return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`; - }); + const pathString = getPathString(path); + const regexPattern = pathString.replace( + PARAM_PATTERN, + (_match, paramName) => { + paramNames.push(paramName); + return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`; + } + ); const finalPattern = `^${regexPattern}$`; @@ -39,9 +51,10 @@ export function compilePath(path: Path): CompiledRoute { export function validatePathPattern(path: Path): ValidationResult { const issues: string[] = []; - const matches = [...path.matchAll(PARAM_PATTERN)]; - if (path.includes(':')) { - const expectedParams = path.split(':').length; + const pathString = getPathString(path); + const matches = [...pathString.matchAll(PARAM_PATTERN)]; + if (pathString.includes(':')) { + const expectedParams = pathString.split(':').length; if (matches.length !== expectedParams - 1) { issues.push('Malformed parameter syntax. Use :paramName format.'); } @@ -227,14 +240,24 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { /** * Resolves a prefixed path by combining the provided path and prefix. * + * The function returns a RegExp if any of the path or prefix is a RegExp. + * Otherwise, it returns a `/${string}` type value. + * * @param path - The path to resolve * @param prefix - The prefix to prepend to the path */ export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => { - if (prefix) { - return path === '/' ? prefix : `${prefix}${path}`; + if (!prefix) return path; + if (isRegExp(prefix)) { + if (isRegExp(path)) { + return new RegExp(`${getPathString(prefix)}/${getPathString(path)}`); + } + return new RegExp(`${getPathString(prefix)}${path}`); + } + if (isRegExp(path)) { + return new RegExp(`${prefix}/${getPathString(path)}`); } - return path; + return `${prefix}${path}`.replace(/\/$/, '') as Path; }; export const HttpResponseStream = diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index ae7f9f8d5f..4fa17c36a2 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -75,7 +75,7 @@ type HttpMethod = keyof typeof HttpVerbs; type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes]; -type Path = `/${string}`; +type Path = `/${string}` | RegExp; type RestRouteHandlerOptions = { handler: RouteHandler; diff --git a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts index a2de67e709..3fdd5b0954 100644 --- a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts @@ -190,4 +190,32 @@ describe('Class: Router - Basic Routing', () => { 'Handler for method: GET and path: /todos already exists. The previous handler will be replaced.' ); }); + + it.each([ + ['/files/test', 'GET', 'serveFileOverride'], + ['/api/v1/test', 'GET', 'apiVersioning'], + ['/users/1/files/test', 'GET', 'dynamicRegex1'], + ['/any-route', 'GET', 'getAnyRoute'], + ['/no-matches', 'POST', 'catchAllUnmatched'], + ])('routes %s %s to %s handler', async (path, method, expectedApi) => { + // Prepare + const app = new Router(); + app.get(/\/files\/.+/, async () => ({ api: 'serveFile' })); + app.get(/\/files\/.+/, async () => ({ api: 'serveFileOverride' })); + app.get(/\/api\/v\d+\/.*/, async () => ({ api: 'apiVersioning' })); + app.get(/\/users\/:userId\/files\/.+/, async (reqCtx) => ({ + api: `dynamicRegex${reqCtx.params.userId}`, + })); + app.get(/.+/, async () => ({ api: 'getAnyRoute' })); + app.route(async () => ({ api: 'catchAllUnmatched' }), { + path: /.*/, + method: [HttpVerbs.GET, HttpVerbs.POST], + }); + + // Act + const result = await app.resolve(createTestEvent(path, method), context); + + // Assess + expect(JSON.parse(result.body).api).toEqual(expectedApi); + }); }); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 478b7225bb..8b0fbeadad 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -581,12 +581,15 @@ describe('Path Utilities', () => { { path: '/test', prefix: '/prefix', expected: '/prefix/test' }, { path: '/', prefix: '/prefix', expected: '/prefix' }, { path: '/test', expected: '/test' }, + { path: /.+/, prefix: '/prefix', expected: /\/prefix\/.+/ }, + { path: '/test', prefix: /\/prefix/, expected: /\/prefix\/test/ }, + { path: /.+/, prefix: /\/prefix/, expected: /\/prefix\/.+/ }, ])('resolves prefixed path', ({ path, prefix, expected }) => { // Prepare & Act const resolvedPath = resolvePrefixedPath(path as Path, prefix as Path); // Assert - expect(resolvedPath).toBe(expected); + expect(resolvedPath).toEqual(expected); }); });