Skip to content

Commit b8ca368

Browse files
authored
feat(event-handler): implement route matching & resolution system for rest handler (aws-powertools#4297)
1 parent 1fc8604 commit b8ca368

File tree

8 files changed

+714
-123
lines changed

8 files changed

+714
-123
lines changed

packages/event-handler/src/rest/Route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import type { Path, RouteHandler } from '../types/rest.js';
1+
import type { HttpMethod, Path, RouteHandler } from '../types/rest.js';
22

33
class Route {
44
readonly id: string;
55
readonly method: string;
66
readonly path: Path;
77
readonly handler: RouteHandler;
88

9-
constructor(method: string, path: Path, handler: RouteHandler) {
9+
constructor(method: HttpMethod, path: Path, handler: RouteHandler) {
1010
this.id = `${method}:${path}`;
11-
this.method = method.toUpperCase();
11+
this.method = method;
1212
this.path = path;
1313
this.handler = handler;
1414
}

packages/event-handler/src/rest/RouteHandlerRegistry.ts

Lines changed: 163 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,94 @@
11
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2-
import type { RouteRegistryOptions } from '../types/rest.js';
2+
import type {
3+
DynamicRoute,
4+
HttpMethod,
5+
Path,
6+
RouteHandlerOptions,
7+
RouteRegistryOptions,
8+
ValidationResult,
9+
} from '../types/rest.js';
10+
import { ParameterValidationError } from './errors.js';
311
import type { Route } from './Route.js';
4-
import { validatePathPattern } from './utils.js';
12+
import { compilePath, validatePathPattern } from './utils.js';
513

614
class RouteHandlerRegistry {
7-
readonly #routes: Map<string, Route> = new Map();
8-
readonly #routesByMethod: Map<string, Route[]> = new Map();
15+
readonly #staticRoutes: Map<string, Route> = new Map();
16+
readonly #dynamicRoutesSet: Set<string> = new Set();
17+
readonly #dynamicRoutes: DynamicRoute[] = [];
18+
#shouldSort = true;
919

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

1222
constructor(options: RouteRegistryOptions) {
1323
this.#logger = options.logger;
1424
}
1525

26+
/**
27+
* Compares two dynamic routes to determine their specificity order.
28+
* Routes with fewer parameters and more path segments are considered more specific.
29+
* @param a - First dynamic route to compare
30+
* @param b - Second dynamic route to compare
31+
* @returns Negative if a is more specific, positive if b is more specific, 0 if equal
32+
*/
33+
#compareRouteSpecificity(a: DynamicRoute, b: DynamicRoute): number {
34+
// Routes with fewer parameters are more specific
35+
const aParams = a.paramNames.length;
36+
const bParams = b.paramNames.length;
37+
38+
if (aParams !== bParams) {
39+
return aParams - bParams;
40+
}
41+
42+
// Routes with more path segments are more specific
43+
const aSegments = a.path.split('/').length;
44+
const bSegments = b.path.split('/').length;
45+
46+
return bSegments - aSegments;
47+
}
48+
/**
49+
* Processes route parameters by URL-decoding their values.
50+
* @param params - Raw parameter values extracted from the route path
51+
* @returns Processed parameters with URL-decoded values
52+
*/
53+
#processParams(params: Record<string, string>): Record<string, string> {
54+
const processed: Record<string, string> = {};
55+
56+
for (const [key, value] of Object.entries(params)) {
57+
processed[key] = decodeURIComponent(value);
58+
}
59+
60+
return processed;
61+
}
62+
/**
63+
* Validates route parameters to ensure they are not empty or whitespace-only.
64+
* @param params - Parameters to validate
65+
* @returns Validation result with success status and any issues found
66+
*/
67+
#validateParams(params: Record<string, string>): ValidationResult {
68+
const issues: string[] = [];
69+
70+
for (const [key, value] of Object.entries(params)) {
71+
if (!value || value.trim() === '') {
72+
issues.push(`Parameter '${key}' cannot be empty`);
73+
}
74+
}
75+
76+
return {
77+
isValid: issues.length === 0,
78+
issues,
79+
};
80+
}
81+
/**
82+
* Registers a route in the registry after validating its path pattern.
83+
*
84+
* The function decides whether to store the route in the static registry
85+
* (for exact paths like `/users`) or dynamic registry (for parameterized
86+
* paths like `/users/:id`) based on the compiled path analysis.
87+
*
88+
* @param route - The route to register
89+
*/
1690
public register(route: Route): void {
91+
this.#shouldSort = true;
1792
const { isValid, issues } = validatePathPattern(route.path);
1893
if (!isValid) {
1994
for (const issue of issues) {
@@ -22,29 +97,96 @@ class RouteHandlerRegistry {
2297
return;
2398
}
2499

25-
if (this.#routes.has(route.id)) {
26-
this.#logger.warn(
27-
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
28-
);
100+
const compiled = compilePath(route.path);
101+
102+
if (compiled.isDynamic) {
103+
const dynamicRoute = {
104+
...route,
105+
...compiled,
106+
};
107+
if (this.#dynamicRoutesSet.has(route.id)) {
108+
this.#logger.warn(
109+
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
110+
);
111+
// as dynamic routes are stored in an array, we can't rely on
112+
// overwriting a key in a map like with static routes so have
113+
// to manually manage overwriting them
114+
const i = this.#dynamicRoutes.findIndex(
115+
(oldRoute) => oldRoute.id === route.id
116+
);
117+
this.#dynamicRoutes[i] = dynamicRoute;
118+
} else {
119+
this.#dynamicRoutes.push(dynamicRoute);
120+
this.#dynamicRoutesSet.add(route.id);
121+
}
122+
} else {
123+
if (this.#staticRoutes.has(route.id)) {
124+
this.#logger.warn(
125+
`Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.`
126+
);
127+
}
128+
this.#staticRoutes.set(route.id, route);
29129
}
130+
}
131+
/**
132+
* Resolves a route handler for the given HTTP method and path.
133+
*
134+
* Static routes are checked first for exact matches. Dynamic routes are then
135+
* checked in order of specificity (fewer parameters and more segments first).
136+
* If no handler is found, it returns `null`.
137+
*
138+
* Examples of specificity (given registered routes `/users/:id` and `/users/:id/posts/:postId`):
139+
* - For path `'/users/123/posts/456'`:
140+
* - `/users/:id` matches but has fewer segments (2 vs 4)
141+
* - `/users/:id/posts/:postId` matches and is more specific -> **selected**
142+
* - For path `'/users/123'`:
143+
* - `/users/:id` matches exactly -> **selected**
144+
* - `/users/:id/posts/:postId` doesn't match (too many segments)
145+
*
146+
* @param method - The HTTP method to match
147+
* @param path - The path to match
148+
* @returns Route handler options or null if no match found
149+
*/
150+
public resolve(method: HttpMethod, path: Path): RouteHandlerOptions | null {
151+
if (this.#shouldSort) {
152+
this.#dynamicRoutes.sort(this.#compareRouteSpecificity);
153+
this.#shouldSort = false;
154+
}
155+
const routeId = `${method}:${path}`;
30156

31-
this.#routes.set(route.id, route);
157+
const staticRoute = this.#staticRoutes.get(routeId);
158+
if (staticRoute != null) {
159+
return {
160+
handler: staticRoute.handler,
161+
rawParams: {},
162+
params: {},
163+
};
164+
}
32165

33-
const routesByMethod = this.#routesByMethod.get(route.method) ?? [];
34-
routesByMethod.push(route);
35-
this.#routesByMethod.set(route.method, routesByMethod);
36-
}
166+
for (const route of this.#dynamicRoutes) {
167+
if (route.method !== method) continue;
37168

38-
public getRouteCount(): number {
39-
return this.#routes.size;
40-
}
169+
const match = route.regex.exec(path);
170+
if (match?.groups) {
171+
const params = match.groups;
41172

42-
public getRoutesByMethod(method: string): Route[] {
43-
return this.#routesByMethod.get(method.toUpperCase()) || [];
44-
}
173+
const processedParams = this.#processParams(params);
174+
175+
const validation = this.#validateParams(processedParams);
176+
177+
if (!validation.isValid) {
178+
throw new ParameterValidationError(validation.issues);
179+
}
180+
181+
return {
182+
handler: route.handler,
183+
params: processedParams,
184+
rawParams: params,
185+
};
186+
}
187+
}
45188

46-
public getAllRoutes(): Route[] {
47-
return Array.from(this.#routes.values());
189+
return null;
48190
}
49191
}
50192

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class RouteMatchingError extends Error {
2+
constructor(
3+
message: string,
4+
public readonly path: string,
5+
public readonly method: string
6+
) {
7+
super(message);
8+
this.name = 'RouteMatchingError';
9+
}
10+
}
11+
12+
export class ParameterValidationError extends RouteMatchingError {
13+
constructor(public readonly issues: string[]) {
14+
super(`Parameter validation failed: ${issues.join(', ')}`, '', '');
15+
this.name = 'ParameterValidationError';
16+
}
17+
}

packages/event-handler/src/rest/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function compilePath(path: Path): CompiledRoute {
1212
const finalPattern = `^${regexPattern}$`;
1313

1414
return {
15-
originalPath: path,
15+
path,
1616
regex: new RegExp(finalPattern),
1717
paramNames,
1818
isDynamic: paramNames.length > 0,

packages/event-handler/src/types/rest.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
22
import type { BaseRouter } from '../rest/BaseRouter.js';
33
import type { HttpVerbs } from '../rest/constants.js';
4+
import type { Route } from '../rest/Route.js';
45

56
/**
67
* Options for the {@link BaseRouter} class
@@ -15,19 +16,27 @@ type RouterOptions = {
1516
};
1617

1718
interface CompiledRoute {
18-
originalPath: string;
19+
path: Path;
1920
regex: RegExp;
2021
paramNames: string[];
2122
isDynamic: boolean;
2223
}
2324

25+
type DynamicRoute = Route & CompiledRoute;
26+
2427
// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function
2528
type RouteHandler<T = any, R = any> = (...args: T[]) => R;
2629

2730
type HttpMethod = keyof typeof HttpVerbs;
2831

2932
type Path = `/${string}`;
3033

34+
type RouteHandlerOptions = {
35+
handler: RouteHandler;
36+
params: Record<string, string>;
37+
rawParams: Record<string, string>;
38+
};
39+
3140
type RouteOptions = {
3241
method: HttpMethod | HttpMethod[];
3342
path: Path;
@@ -49,11 +58,13 @@ type ValidationResult = {
4958

5059
export type {
5160
CompiledRoute,
61+
DynamicRoute,
5262
HttpMethod,
5363
Path,
5464
RouterOptions,
5565
RouteHandler,
5666
RouteOptions,
67+
RouteHandlerOptions,
5768
RouteRegistryOptions,
5869
ValidationResult,
5970
};

packages/event-handler/tests/unit/rest/BaseRouter.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BaseRouter } from '../../../src/rest/BaseRouter.js';
55
import { HttpVerbs } from '../../../src/rest/constants.js';
66
import type {
77
HttpMethod,
8+
Path,
89
RouteHandler,
910
RouterOptions,
1011
} from '../../../src/types/rest.js';
@@ -18,12 +19,16 @@ describe('Class: BaseRouter', () => {
1819
this.logger.error('test error');
1920
}
2021

21-
#isEvent(obj: unknown): asserts obj is { path: string; method: string } {
22+
#isEvent(obj: unknown): asserts obj is { path: Path; method: HttpMethod } {
2223
if (
2324
typeof obj !== 'object' ||
2425
obj === null ||
2526
!('path' in obj) ||
26-
!('method' in obj)
27+
!('method' in obj) ||
28+
typeof (obj as any).path !== 'string' ||
29+
!(obj as any).path.startsWith('/') ||
30+
typeof (obj as any).method !== 'string' ||
31+
!Object.values(HttpVerbs).includes((obj as any).method as HttpMethod)
2732
) {
2833
throw new Error('Invalid event object');
2934
}
@@ -32,8 +37,7 @@ describe('Class: BaseRouter', () => {
3237
public resolve(event: unknown, context: Context): Promise<unknown> {
3338
this.#isEvent(event);
3439
const { method, path } = event;
35-
const routes = this.routeRegistry.getRoutesByMethod(method);
36-
const route = routes.find((x) => x.path === path);
40+
const route = this.routeRegistry.resolve(method, path);
3741
if (route == null) throw new Error('404');
3842
return route.handler(event, context);
3943
}

0 commit comments

Comments
 (0)