Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/commons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
isNullOrUndefined,
isNumber,
isRecord,
isRegExp,
isStrictEqual,
isString,
isStringUndefinedNullEmpty,
Expand Down
20 changes: 20 additions & 0 deletions packages/commons/src/typeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -337,6 +356,7 @@ export {
isNull,
isNullOrUndefined,
isStringUndefinedNullEmpty,
isRegExp,
getType,
isStrictEqual,
};
25 changes: 25 additions & 0 deletions packages/commons/tests/unit/typeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isNullOrUndefined,
isNumber,
isRecord,
isRegExp,
isStrictEqual,
isString,
isStringUndefinedNullEmpty,
Expand Down Expand Up @@ -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([
{
Expand Down
68 changes: 44 additions & 24 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, DynamicRoute> = new Map();
readonly #staticRoutes: Map<string, Route> = new Map();
readonly #dynamicRoutesSet: Set<string> = new Set();
readonly #dynamicRoutes: DynamicRoute[] = [];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -215,6 +212,7 @@ class RouteHandlerRegistry {
const routes = [
...routeHandlerRegistry.#staticRoutes.values(),
...routeHandlerRegistry.#dynamicRoutes,
...routeHandlerRegistry.#regexRoutes.values(),
];
for (const route of routes) {
this.register(
Expand All @@ -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 };
45 changes: 34 additions & 11 deletions packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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}$`;

Expand All @@ -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.');
}
Expand Down Expand Up @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion packages/event-handler/tests/unit/rest/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down