Skip to content

Commit c2cbb64

Browse files
authored
feat(event-handler): add route management system for ApiGw event handler (#4211)
1 parent 0c12440 commit c2cbb64

File tree

9 files changed

+643
-106
lines changed

9 files changed

+643
-106
lines changed

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

Lines changed: 54 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2-
import { isRecord } from '@aws-lambda-powertools/commons/typeutils';
32
import {
43
getStringFromEnv,
54
isDevMode,
@@ -13,10 +12,15 @@ import type {
1312
RouteOptions,
1413
RouterOptions,
1514
} from '../types/rest.js';
16-
import { HttpVerbs } from './constatnts.js';
15+
import { HttpVerbs } from './constants.js';
16+
import { Route } from './Route.js';
17+
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
1718

1819
abstract class BaseRouter {
1920
protected context: Record<string, unknown>;
21+
22+
protected routeRegistry: RouteHandlerRegistry;
23+
2024
/**
2125
* A logger instance to be used for logging debug, warning, and error messages.
2226
*
@@ -39,6 +43,7 @@ abstract class BaseRouter {
3943
error: console.error,
4044
warn: console.warn,
4145
};
46+
this.routeRegistry = new RouteHandlerRegistry({ logger: this.logger });
4247
this.isDev = isDevMode();
4348
}
4449

@@ -48,126 +53,98 @@ abstract class BaseRouter {
4853
options?: ResolveOptions
4954
): Promise<unknown>;
5055

51-
public abstract route(handler: RouteHandler, options: RouteOptions): void;
56+
public route(handler: RouteHandler, options: RouteOptions): void {
57+
const { method, path } = options;
58+
const methods = Array.isArray(method) ? method : [method];
59+
60+
for (const method of methods) {
61+
this.routeRegistry.register(new Route(method, path, handler));
62+
}
63+
}
5264

5365
#handleHttpMethod(
5466
method: HttpMethod,
5567
path: Path,
56-
handler?: RouteHandler | RouteOptions,
57-
options?: RouteOptions
68+
handler?: RouteHandler
5869
): MethodDecorator | undefined {
5970
if (handler && typeof handler === 'function') {
60-
this.route(handler, { ...(options || {}), method, path });
71+
this.route(handler, { method, path });
6172
return;
6273
}
6374

6475
return (_target, _propertyKey, descriptor: PropertyDescriptor) => {
65-
const routeOptions = isRecord(handler) ? handler : options;
66-
this.route(descriptor.value, { ...(routeOptions || {}), method, path });
76+
this.route(descriptor.value, { method, path });
6777
return descriptor;
6878
};
6979
}
7080

71-
public get(path: string, handler: RouteHandler, options?: RouteOptions): void;
72-
public get(path: string, options?: RouteOptions): MethodDecorator;
73-
public get(
74-
path: Path,
75-
handler?: RouteHandler | RouteOptions,
76-
options?: RouteOptions
77-
): MethodDecorator | undefined {
78-
return this.#handleHttpMethod(HttpVerbs.GET, path, handler, options);
81+
public get(path: Path, handler: RouteHandler): void;
82+
public get(path: Path): MethodDecorator;
83+
public get(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
84+
return this.#handleHttpMethod(HttpVerbs.GET, path, handler);
7985
}
8086

81-
public post(path: Path, handler: RouteHandler, options?: RouteOptions): void;
82-
public post(path: Path, options?: RouteOptions): MethodDecorator;
83-
public post(
84-
path: Path,
85-
handler?: RouteHandler | RouteOptions,
86-
options?: RouteOptions
87-
): MethodDecorator | undefined {
88-
return this.#handleHttpMethod(HttpVerbs.POST, path, handler, options);
87+
public post(path: Path, handler: RouteHandler): void;
88+
public post(path: Path): MethodDecorator;
89+
public post(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
90+
return this.#handleHttpMethod(HttpVerbs.POST, path, handler);
8991
}
9092

91-
public put(path: Path, handler: RouteHandler, options?: RouteOptions): void;
92-
public put(path: Path, options?: RouteOptions): MethodDecorator;
93-
public put(
94-
path: Path,
95-
handler?: RouteHandler | RouteOptions,
96-
options?: RouteOptions
97-
): MethodDecorator | undefined {
98-
return this.#handleHttpMethod(HttpVerbs.PUT, path, handler, options);
93+
public put(path: Path, handler: RouteHandler): void;
94+
public put(path: Path): MethodDecorator;
95+
public put(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
96+
return this.#handleHttpMethod(HttpVerbs.PUT, path, handler);
9997
}
10098

101-
public patch(path: Path, handler: RouteHandler, options?: RouteOptions): void;
102-
public patch(path: Path, options?: RouteOptions): MethodDecorator;
99+
public patch(path: Path, handler: RouteHandler): void;
100+
public patch(path: Path): MethodDecorator;
103101
public patch(
104102
path: Path,
105-
handler?: RouteHandler | RouteOptions,
106-
options?: RouteOptions
103+
handler?: RouteHandler
107104
): MethodDecorator | undefined {
108-
return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler, options);
105+
return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler);
109106
}
110107

108+
public delete(path: Path, handler: RouteHandler): void;
109+
public delete(path: Path): MethodDecorator;
111110
public delete(
112111
path: Path,
113-
handler: RouteHandler,
114-
options?: RouteOptions
115-
): void;
116-
public delete(path: Path, options?: RouteOptions): MethodDecorator;
117-
public delete(
118-
path: Path,
119-
handler?: RouteHandler | RouteOptions,
120-
options?: RouteOptions
112+
handler?: RouteHandler
121113
): MethodDecorator | undefined {
122-
return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler, options);
114+
return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler);
123115
}
124116

125-
public head(path: Path, handler: RouteHandler, options?: RouteOptions): void;
126-
public head(path: Path, options?: RouteOptions): MethodDecorator;
127-
public head(
128-
path: Path,
129-
handler?: RouteHandler | RouteOptions,
130-
options?: RouteOptions
131-
): MethodDecorator | undefined {
132-
return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler, options);
117+
public head(path: Path, handler: RouteHandler): void;
118+
public head(path: Path): MethodDecorator;
119+
public head(path: Path, handler?: RouteHandler): MethodDecorator | undefined {
120+
return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler);
133121
}
134122

123+
public options(path: Path, handler: RouteHandler): void;
124+
public options(path: Path): MethodDecorator;
135125
public options(
136126
path: Path,
137-
handler: RouteHandler,
138-
options?: RouteOptions
139-
): void;
140-
public options(path: Path, options?: RouteOptions): MethodDecorator;
141-
public options(
142-
path: Path,
143-
handler?: RouteHandler | RouteOptions,
144-
options?: RouteOptions
127+
handler?: RouteHandler
145128
): MethodDecorator | undefined {
146-
return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler, options);
129+
return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler);
147130
}
148131

132+
public connect(path: Path, handler: RouteHandler): void;
133+
public connect(path: Path): MethodDecorator;
149134
public connect(
150135
path: Path,
151-
handler: RouteHandler,
152-
options?: RouteOptions
153-
): void;
154-
public connect(path: Path, options?: RouteOptions): MethodDecorator;
155-
public connect(
156-
path: Path,
157-
handler?: RouteHandler | RouteOptions,
158-
options?: RouteOptions
136+
handler?: RouteHandler
159137
): MethodDecorator | undefined {
160-
return this.#handleHttpMethod(HttpVerbs.CONNECT, path, handler, options);
138+
return this.#handleHttpMethod(HttpVerbs.CONNECT, path, handler);
161139
}
162140

163-
public trace(path: Path, handler: RouteHandler, options?: RouteOptions): void;
164-
public trace(path: Path, options?: RouteOptions): MethodDecorator;
141+
public trace(path: Path, handler: RouteHandler): void;
142+
public trace(path: Path): MethodDecorator;
165143
public trace(
166144
path: Path,
167-
handler?: RouteHandler | RouteOptions,
168-
options?: RouteOptions
145+
handler?: RouteHandler
169146
): MethodDecorator | undefined {
170-
return this.#handleHttpMethod(HttpVerbs.TRACE, path, handler, options);
147+
return this.#handleHttpMethod(HttpVerbs.TRACE, path, handler);
171148
}
172149
}
173150

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Path, RouteHandler } from '../types/rest.js';
2+
3+
class Route {
4+
readonly id: string;
5+
readonly method: string;
6+
readonly path: Path;
7+
readonly handler: RouteHandler;
8+
9+
constructor(method: string, path: Path, handler: RouteHandler) {
10+
this.id = `${method}:${path}`;
11+
this.method = method.toUpperCase();
12+
this.path = path;
13+
this.handler = handler;
14+
}
15+
}
16+
17+
export { Route };
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2+
import type { RouteRegistryOptions } from '../types/rest.js';
3+
import type { Route } from './Route.js';
4+
import { validatePathPattern } from './utils.js';
5+
6+
class RouteHandlerRegistry {
7+
readonly #routes: Map<string, Route> = new Map();
8+
readonly #routesByMethod: Map<string, Route[]> = new Map();
9+
10+
readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
11+
12+
constructor(options: RouteRegistryOptions) {
13+
this.#logger = options.logger;
14+
}
15+
16+
public register(route: Route): void {
17+
const { isValid, issues } = validatePathPattern(route.path);
18+
if (!isValid) {
19+
for (const issue of issues) {
20+
this.#logger.warn(issue);
21+
}
22+
return;
23+
}
24+
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+
);
29+
}
30+
31+
this.#routes.set(route.id, route);
32+
33+
const routesByMethod = this.#routesByMethod.get(route.method) ?? [];
34+
routesByMethod.push(route);
35+
this.#routesByMethod.set(route.method, routesByMethod);
36+
}
37+
38+
public getRouteCount(): number {
39+
return this.#routes.size;
40+
}
41+
42+
public getRoutesByMethod(method: string): Route[] {
43+
return this.#routesByMethod.get(method.toUpperCase()) || [];
44+
}
45+
46+
public getAllRoutes(): Route[] {
47+
return Array.from(this.#routes.values());
48+
}
49+
}
50+
51+
export { RouteHandlerRegistry };

packages/event-handler/src/rest/constatnts.ts renamed to packages/event-handler/src/rest/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ export const HttpVerbs = {
99
HEAD: 'HEAD',
1010
OPTIONS: 'OPTIONS',
1111
} as const;
12+
13+
export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g;
14+
15+
export const SAFE_CHARS = "-._~()'!*:@,;=+&$";
16+
17+
export const UNSAFE_CHARS = '%<> \\[\\]{}|^';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { CompiledRoute, Path, ValidationResult } from '../types/rest.js';
2+
import { PARAM_PATTERN, SAFE_CHARS, UNSAFE_CHARS } from './constants.js';
3+
4+
export function compilePath(path: Path): CompiledRoute {
5+
const paramNames: string[] = [];
6+
7+
const regexPattern = path.replace(PARAM_PATTERN, (_match, paramName) => {
8+
paramNames.push(paramName);
9+
return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`;
10+
});
11+
12+
const finalPattern = `^${regexPattern}$`;
13+
14+
return {
15+
originalPath: path,
16+
regex: new RegExp(finalPattern),
17+
paramNames,
18+
isDynamic: paramNames.length > 0,
19+
};
20+
}
21+
22+
export function validatePathPattern(path: Path): ValidationResult {
23+
const issues: string[] = [];
24+
25+
const matches = [...path.matchAll(PARAM_PATTERN)];
26+
if (path.includes(':')) {
27+
const expectedParams = path.split(':').length;
28+
if (matches.length !== expectedParams - 1) {
29+
issues.push('Malformed parameter syntax. Use :paramName format.');
30+
}
31+
32+
const paramNames = matches.map((match) => match[1]);
33+
const duplicates = paramNames.filter(
34+
(param, index) => paramNames.indexOf(param) !== index
35+
);
36+
if (duplicates.length > 0) {
37+
issues.push(`Duplicate parameter names: ${duplicates.join(', ')}`);
38+
}
39+
}
40+
41+
return {
42+
isValid: issues.length === 0,
43+
issues,
44+
};
45+
}

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
22
import type { BaseRouter } from '../rest/BaseRouter.js';
3-
import type { HttpVerbs } from '../rest/constatnts.js';
3+
import type { HttpVerbs } from '../rest/constants.js';
44

55
/**
66
* Options for the {@link BaseRouter} class
@@ -14,6 +14,13 @@ type RouterOptions = {
1414
logger?: GenericLogger;
1515
};
1616

17+
interface CompiledRoute {
18+
originalPath: string;
19+
regex: RegExp;
20+
paramNames: string[];
21+
isDynamic: boolean;
22+
}
23+
1724
// biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function
1825
type RouteHandler<T = any, R = any> = (...args: T[]) => R;
1926

@@ -22,8 +29,31 @@ type HttpMethod = keyof typeof HttpVerbs;
2229
type Path = `/${string}`;
2330

2431
type RouteOptions = {
25-
method?: HttpMethod;
26-
path?: Path;
32+
method: HttpMethod | HttpMethod[];
33+
path: Path;
2734
};
2835

29-
export type { HttpMethod, Path, RouterOptions, RouteHandler, RouteOptions };
36+
type RouteRegistryOptions = {
37+
/**
38+
* A logger instance to be used for logging debug, warning, and error messages.
39+
*
40+
* When no logger is provided, we'll only log warnings and errors using the global `console` object.
41+
*/
42+
logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
43+
};
44+
45+
type ValidationResult = {
46+
isValid: boolean;
47+
issues: string[];
48+
};
49+
50+
export type {
51+
CompiledRoute,
52+
HttpMethod,
53+
Path,
54+
RouterOptions,
55+
RouteHandler,
56+
RouteOptions,
57+
RouteRegistryOptions,
58+
ValidationResult,
59+
};

0 commit comments

Comments
 (0)