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
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
184 changes: 163 additions & 21 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,94 @@
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import type { RouteRegistryOptions } from '../types/rest.js';
import type {
DynamicRoute,
HttpMethod,
Path,
RouteHandlerOptions,
RouteRegistryOptions,
ValidationResult,
} from '../types/rest.js';
import { ParameterValidationError } from './errors.js';
import type { Route } from './Route.js';
import { validatePathPattern } from './utils.js';
import { compilePath, 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;
}
/**
* Processes route parameters by URL-decoding their values.
* @param params - Raw parameter values extracted from the route path
* @returns Processed parameters with URL-decoded values
*/
#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;
}
/**
* Validates route parameters to ensure they are not empty or whitespace-only.
* @param params - Parameters to validate
* @returns Validation result with success status and any issues found
*/
#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,
};
}
/**
* 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 +97,96 @@ 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 = this.#processParams(params);

const validation = this.#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';
}
}
2 changes: 1 addition & 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
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