Skip to content
Merged
6 changes: 3 additions & 3 deletions packages/event-handler/src/rest/Route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { Path, RouteHandler } from '../types/rest.js';
import type { HttpMethod, Path, RouteHandler } from '../types/rest.js';

class Route {
readonly id: string;
readonly method: string;
readonly path: Path;
readonly handler: RouteHandler;

constructor(method: string, path: Path, handler: RouteHandler) {
constructor(method: HttpMethod, path: Path, handler: RouteHandler) {
this.id = `${method}:${path}`;
this.method = method.toUpperCase();
this.method = method;
this.path = path;
this.handler = handler;
}
Expand Down
156 changes: 135 additions & 21 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,65 @@
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import type { RouteRegistryOptions } from '../types/rest.js';
import type {
DynamicRoute,
HttpMethod,
Path,
RouteHandlerOptions,
RouteRegistryOptions,
} from '../types/rest.js';
import { ParameterValidationError } from './errors.js';
import type { Route } from './Route.js';
import { validatePathPattern } from './utils.js';
import {
compilePath,
processParams,
validateParams,
validatePathPattern,
} from './utils.js';

class RouteHandlerRegistry {
readonly #routes: Map<string, Route> = new Map();
readonly #routesByMethod: Map<string, Route[]> = new Map();
readonly #staticRoutes: Map<string, Route> = new Map();
readonly #dynamicRoutesSet: Set<string> = new Set();
readonly #dynamicRoutes: DynamicRoute[] = [];
#shouldSort = true;

readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;

constructor(options: RouteRegistryOptions) {
this.#logger = options.logger;
}

/**
* Compares two dynamic routes to determine their specificity order.
* Routes with fewer parameters and more path segments are considered more specific.
* @param a - First dynamic route to compare
* @param b - Second dynamic route to compare
* @returns Negative if a is more specific, positive if b is more specific, 0 if equal
*/
#compareRouteSpecificity(a: DynamicRoute, b: DynamicRoute): number {
// Routes with fewer parameters are more specific
const aParams = a.paramNames.length;
const bParams = b.paramNames.length;

if (aParams !== bParams) {
return aParams - bParams;
}

// Routes with more path segments are more specific
const aSegments = a.path.split('/').length;
const bSegments = b.path.split('/').length;

return bSegments - aSegments;
}
/**
* Registers a route in the registry after validating its path pattern.
*
* The function decides whether to store the route in the static registry
* (for exact paths like `/users`) or dynamic registry (for parameterized
* paths like `/users/:id`) based on the compiled path analysis.
*
* @param route - The route to register
*/
public register(route: Route): void {
this.#shouldSort = true;
const { isValid, issues } = validatePathPattern(route.path);
if (!isValid) {
for (const issue of issues) {
Expand All @@ -22,29 +68,97 @@ class RouteHandlerRegistry {
return;
}

if (this.#routes.has(route.id)) {
this.#logger.warn(
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
);
const compiled = compilePath(route.path);

if (compiled.isDynamic) {
const dynamicRoute = {
...route,
...compiled,
};
if (this.#dynamicRoutesSet.has(route.id)) {
this.#logger.warn(
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
);
// as dynamic routes are stored in an array, we can't rely on
// overwriting a key in a map like with static routes so have
// to manually manage overwriting them
const i = this.#dynamicRoutes.findIndex(
(oldRoute) => oldRoute.id === route.id
);
this.#dynamicRoutes[i] = dynamicRoute;
} else {
this.#dynamicRoutes.push(dynamicRoute);
}
this.#dynamicRoutesSet.add(route.id);
} else {
if (this.#staticRoutes.has(route.id)) {
this.#logger.warn(
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
);
}
this.#staticRoutes.set(route.id, route);
}
}

/**
* Resolves a route handler for the given HTTP method and path.
*
* Static routes are checked first for exact matches. Dynamic routes are then
* checked in order of specificity (fewer parameters and more segments first).
* If no handler is found, it returns `null`.
*
* Examples of specificity (given registered routes `/users/:id` and `/users/:id/posts/:postId`):
* - For path `'/users/123/posts/456'`:
* - `/users/:id` matches but has fewer segments (2 vs 4)
* - `/users/:id/posts/:postId` matches and is more specific -> **selected**
* - For path `'/users/123'`:
* - `/users/:id` matches exactly -> **selected**
* - `/users/:id/posts/:postId` doesn't match (too many segments)
*
* @param method - The HTTP method to match
* @param path - The path to match
* @returns Route handler options or null if no match found
*/
public resolve(method: HttpMethod, path: Path): RouteHandlerOptions | null {
if (this.#shouldSort) {
this.#dynamicRoutes.sort(this.#compareRouteSpecificity);
this.#shouldSort = false;
}
const routeId = `${method}:${path}`;

this.#routes.set(route.id, route);
const staticRoute = this.#staticRoutes.get(routeId);
if (staticRoute != null) {
return {
handler: staticRoute.handler,
rawParams: {},
params: {},
};
}

const routesByMethod = this.#routesByMethod.get(route.method) ?? [];
routesByMethod.push(route);
this.#routesByMethod.set(route.method, routesByMethod);
}
for (const route of this.#dynamicRoutes) {
if (route.method !== method) continue;

public getRouteCount(): number {
return this.#routes.size;
}
const match = route.regex.exec(path);
if (match?.groups) {
const params = match.groups;

public getRoutesByMethod(method: string): Route[] {
return this.#routesByMethod.get(method.toUpperCase()) || [];
}
const processedParams = processParams(params);

const validation = validateParams(processedParams);

if (!validation.isValid) {
throw new ParameterValidationError(validation.issues);
}

return {
handler: route.handler,
params: processedParams,
rawParams: params,
};
}
}

public getAllRoutes(): Route[] {
return Array.from(this.#routes.values());
return null;
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/event-handler/src/rest/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export class RouteMatchingError extends Error {
constructor(
message: string,
public readonly path: string,
public readonly method: string
) {
super(message);
this.name = 'RouteMatchingError';
}
}

export class ParameterValidationError extends RouteMatchingError {
constructor(public readonly issues: string[]) {
super(`Parameter validation failed: ${issues.join(', ')}`, '', '');
this.name = 'ParameterValidationError';
}
}
31 changes: 30 additions & 1 deletion packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function compilePath(path: Path): CompiledRoute {
const finalPattern = `^${regexPattern}$`;

return {
originalPath: path,
path,
regex: new RegExp(finalPattern),
paramNames,
isDynamic: paramNames.length > 0,
Expand Down Expand Up @@ -43,3 +43,32 @@ export function validatePathPattern(path: Path): ValidationResult {
issues,
};
}

export function processParams(
params: Record<string, string>
): Record<string, string> {
const processed: Record<string, string> = {};

for (const [key, value] of Object.entries(params)) {
processed[key] = decodeURIComponent(value);
}

return processed;
}

export function validateParams(
params: Record<string, string>
): ValidationResult {
const issues: string[] = [];

for (const [key, value] of Object.entries(params)) {
if (!value || value.trim() === '') {
issues.push(`Parameter '${key}' cannot be empty`);
}
}

return {
isValid: issues.length === 0,
issues,
};
}
13 changes: 12 additions & 1 deletion packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import type { BaseRouter } from '../rest/BaseRouter.js';
import type { HttpVerbs } from '../rest/constants.js';
import type { Route } from '../rest/Route.js';

/**
* Options for the {@link BaseRouter} class
Expand All @@ -15,19 +16,27 @@ type RouterOptions = {
};

interface CompiledRoute {
originalPath: string;
path: Path;
regex: RegExp;
paramNames: string[];
isDynamic: boolean;
}

type DynamicRoute = Route & CompiledRoute;

// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function
type RouteHandler<T = any, R = any> = (...args: T[]) => R;

type HttpMethod = keyof typeof HttpVerbs;

type Path = `/${string}`;

type RouteHandlerOptions = {
handler: RouteHandler;
params: Record<string, string>;
rawParams: Record<string, string>;
};

type RouteOptions = {
method: HttpMethod | HttpMethod[];
path: Path;
Expand All @@ -49,11 +58,13 @@ type ValidationResult = {

export type {
CompiledRoute,
DynamicRoute,
HttpMethod,
Path,
RouterOptions,
RouteHandler,
RouteOptions,
RouteHandlerOptions,
RouteRegistryOptions,
ValidationResult,
};
12 changes: 8 additions & 4 deletions packages/event-handler/tests/unit/rest/BaseRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BaseRouter } from '../../../src/rest/BaseRouter.js';
import { HttpVerbs } from '../../../src/rest/constants.js';
import type {
HttpMethod,
Path,
RouteHandler,
RouterOptions,
} from '../../../src/types/rest.js';
Expand All @@ -18,12 +19,16 @@ describe('Class: BaseRouter', () => {
this.logger.error('test error');
}

#isEvent(obj: unknown): asserts obj is { path: string; method: string } {
#isEvent(obj: unknown): asserts obj is { path: Path; method: HttpMethod } {
if (
typeof obj !== 'object' ||
obj === null ||
!('path' in obj) ||
!('method' in obj)
!('method' in obj) ||
typeof (obj as any).path !== 'string' ||
!(obj as any).path.startsWith('/') ||
typeof (obj as any).method !== 'string' ||
!Object.values(HttpVerbs).includes((obj as any).method as HttpMethod)
) {
throw new Error('Invalid event object');
}
Expand All @@ -32,8 +37,7 @@ describe('Class: BaseRouter', () => {
public resolve(event: unknown, context: Context): Promise<unknown> {
this.#isEvent(event);
const { method, path } = event;
const routes = this.routeRegistry.getRoutesByMethod(method);
const route = routes.find((x) => x.path === path);
const route = this.routeRegistry.resolve(method, path);
if (route == null) throw new Error('404');
return route.handler(event, context);
}
Expand Down
Loading