Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
67 changes: 43 additions & 24 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,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 +46,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 +105,18 @@ class RouteHandlerRegistry {

const compiled = compilePath(route.path);

if (route.path instanceof RegExp) {
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 +185,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 +211,7 @@ class RouteHandlerRegistry {
const routes = [
...routeHandlerRegistry.#staticRoutes.values(),
...routeHandlerRegistry.#dynamicRoutes,
...routeHandlerRegistry.#regexRoutes.values(),
];
for (const route of routes) {
this.register(
Expand All @@ -227,6 +224,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,4 +1,8 @@
import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils';
import {
isRecord,
isRegExp,
isString,
} from '@aws-lambda-powertools/commons/typeutils';
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type {
CompiledRoute,
Expand All @@ -15,13 +19,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 @@ -36,9 +48,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 @@ -200,12 +213,22 @@ 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;
};
10 changes: 6 additions & 4 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,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 All @@ -79,14 +79,16 @@ type RestRouteOptions = {
middleware?: Middleware[];
};

// biome-ignore lint/suspicious/noConfusingVoidType: To ensure next function is awaited
type NextFunction = () => Promise<HandlerResponse | void>;
type NextFunction = () => // biome-ignore lint/suspicious/noConfusingVoidType: To ensure next function is awaited
| Promise<HandlerResponse | void>
| HandlerResponse
| void;

type Middleware = (args: {
reqCtx: RequestContext;
next: NextFunction;
// biome-ignore lint/suspicious/noConfusingVoidType: To ensure next function is awaited
}) => Promise<HandlerResponse | void>;
}) => Promise<HandlerResponse | void> | HandlerResponse | void;

type RouteRegistryOptions = {
/**
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 @@ -577,12 +577,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);
});
});
});