Skip to content

Commit f1ecc6d

Browse files
svozzadreamorosi
andauthored
feat(event-handler): add resolution logic to base router (#4349)
Co-authored-by: Andrea Amorosi <dreamorosi@gmail.com>
1 parent 172f450 commit f1ecc6d

File tree

9 files changed

+1029
-495
lines changed

9 files changed

+1029
-495
lines changed

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
getStringFromEnv,
44
isDevMode,
55
} from '@aws-lambda-powertools/commons/utils/env';
6-
import type { Context } from 'aws-lambda';
6+
import type { APIGatewayProxyResult, Context } from 'aws-lambda';
77
import type { ResolveOptions } from '../types/index.js';
88
import type {
99
ErrorConstructor,
@@ -15,15 +15,22 @@ import type {
1515
RouteOptions,
1616
RouterOptions,
1717
} from '../types/rest.js';
18-
import { HttpVerbs } from './constants.js';
18+
import { HttpErrorCodes, HttpVerbs } from './constants.js';
19+
import {
20+
handlerResultToProxyResult,
21+
proxyEventToWebRequest,
22+
responseToProxyResult,
23+
} from './converters.js';
1924
import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js';
2025
import {
26+
InternalServerError,
2127
MethodNotAllowedError,
2228
NotFoundError,
2329
ServiceError,
2430
} from './errors.js';
2531
import { Route } from './Route.js';
2632
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
33+
import { isAPIGatewayProxyEvent, isHttpMethod } from './utils.js';
2734

2835
abstract class BaseRouter {
2936
protected context: Record<string, unknown>;
@@ -133,11 +140,72 @@ abstract class BaseRouter {
133140
};
134141
}
135142

136-
public abstract resolve(
143+
/**
144+
* Resolves an API Gateway event by routing it to the appropriate handler
145+
* and converting the result to an API Gateway proxy result. Handles errors
146+
* using registered error handlers or falls back to default error handling
147+
* (500 Internal Server Error).
148+
*
149+
* @param event - The Lambda event to resolve
150+
* @param context - The Lambda context
151+
* @param options - Optional resolve options for scope binding
152+
* @returns An API Gateway proxy result or undefined for incompatible events
153+
*/
154+
public async resolve(
137155
event: unknown,
138156
context: Context,
139157
options?: ResolveOptions
140-
): Promise<unknown>;
158+
): Promise<APIGatewayProxyResult | undefined> {
159+
if (!isAPIGatewayProxyEvent(event)) {
160+
this.logger.error(
161+
'Received an event that is not compatible with this resolver'
162+
);
163+
throw new InternalServerError();
164+
}
165+
166+
const method = event.requestContext.httpMethod.toUpperCase();
167+
if (!isHttpMethod(method)) {
168+
this.logger.error(`HTTP method ${method} is not supported.`);
169+
// We can't throw a MethodNotAllowedError outside the try block as it
170+
// will be converted to an internal server error by the API Gateway runtime
171+
return {
172+
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
173+
body: '',
174+
};
175+
}
176+
177+
const request = proxyEventToWebRequest(event);
178+
179+
try {
180+
const path = new URL(request.url).pathname as Path;
181+
182+
const route = this.routeRegistry.resolve(method, path);
183+
184+
if (route === null) {
185+
throw new NotFoundError(`Route ${path} for method ${method} not found`);
186+
}
187+
188+
const result = await route.handler.apply(options?.scope ?? this, [
189+
route.params,
190+
{
191+
event,
192+
context,
193+
request,
194+
},
195+
]);
196+
197+
return await handlerResultToProxyResult(result);
198+
} catch (error) {
199+
this.logger.debug(`There was an error processing the request: ${error}`);
200+
const result = await this.handleError(error as Error, {
201+
request,
202+
event,
203+
context,
204+
scope: options?.scope,
205+
});
206+
return await responseToProxyResult(result);
207+
}
208+
}
141209

142210
public route(handler: RouteHandler, options: RouteOptions): void {
143211
const { method, path } = options;

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

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import type { APIGatewayProxyEvent } from 'aws-lambda';
1+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
2+
import type { HandlerResponse } from '../types/rest.js';
3+
import { isAPIGatewayProxyResult } from './utils.js';
24

5+
/**
6+
* Creates a request body from API Gateway event body, handling base64 decoding if needed.
7+
*
8+
* @param body - The raw body from the API Gateway event
9+
* @param isBase64Encoded - Whether the body is base64 encoded
10+
* @returns The decoded body string or null
11+
*/
312
const createBody = (body: string | null, isBase64Encoded: boolean) => {
413
if (body === null) return null;
514

@@ -9,21 +18,33 @@ const createBody = (body: string | null, isBase64Encoded: boolean) => {
918
return Buffer.from(body, 'base64').toString('utf8');
1019
};
1120

12-
export const proxyEventToWebRequest = (event: APIGatewayProxyEvent) => {
13-
const { httpMethod, path, domainName } = event.requestContext;
21+
/**
22+
* Converts an API Gateway proxy event to a Web API Request object.
23+
*
24+
* @param event - The API Gateway proxy event
25+
* @returns A Web API Request object
26+
*/
27+
export const proxyEventToWebRequest = (
28+
event: APIGatewayProxyEvent
29+
): Request => {
30+
const { httpMethod, path } = event;
31+
const { domainName } = event.requestContext;
1432

1533
const headers = new Headers();
1634
for (const [name, value] of Object.entries(event.headers ?? {})) {
17-
if (value != null) headers.append(name, value);
35+
if (value != null) headers.set(name, value);
1836
}
1937

2038
for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) {
2139
for (const value of values ?? []) {
22-
headers.append(name, value);
40+
const headerValue = headers.get(name);
41+
if (!headerValue?.includes(value)) {
42+
headers.append(name, value);
43+
}
2344
}
2445
}
2546
const hostname = headers.get('Host') ?? domainName;
26-
const protocol = headers.get('X-Forwarded-Proto') ?? 'http';
47+
const protocol = headers.get('X-Forwarded-Proto') ?? 'https';
2748

2849
const url = new URL(path, `${protocol}://${hostname}/`);
2950

@@ -45,4 +66,58 @@ export const proxyEventToWebRequest = (event: APIGatewayProxyEvent) => {
4566
headers,
4667
body: createBody(event.body, event.isBase64Encoded),
4768
});
48-
}
69+
};
70+
71+
/**
72+
* Converts a Web API Response object to an API Gateway proxy result.
73+
*
74+
* @param response - The Web API Response object
75+
* @returns An API Gateway proxy result
76+
*/
77+
export const responseToProxyResult = async (
78+
response: Response
79+
): Promise<APIGatewayProxyResult> => {
80+
const headers: Record<string, string> = {};
81+
const multiValueHeaders: Record<string, Array<string>> = {};
82+
83+
for (const [key, value] of response.headers.entries()) {
84+
const values = value.split(',').map((v) => v.trimStart());
85+
if (values.length > 1) {
86+
multiValueHeaders[key] = values;
87+
} else {
88+
headers[key] = value;
89+
}
90+
}
91+
92+
return {
93+
statusCode: response.status,
94+
headers,
95+
multiValueHeaders,
96+
body: await response.text(),
97+
isBase64Encoded: false,
98+
};
99+
};
100+
101+
/**
102+
* Converts a handler response to an API Gateway proxy result.
103+
* Handles APIGatewayProxyResult, Response objects, and plain objects.
104+
*
105+
* @param response - The handler response (APIGatewayProxyResult, Response, or plain object)
106+
* @returns An API Gateway proxy result
107+
*/
108+
export const handlerResultToProxyResult = async (
109+
response: HandlerResponse
110+
): Promise<APIGatewayProxyResult> => {
111+
if (isAPIGatewayProxyResult(response)) {
112+
return response;
113+
}
114+
if (response instanceof Response) {
115+
return await responseToProxyResult(response);
116+
}
117+
return {
118+
statusCode: 200,
119+
body: JSON.stringify(response),
120+
headers: { 'Content-Type': 'application/json' },
121+
isBase64Encoded: false,
122+
};
123+
};

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils';
2-
import type { APIGatewayProxyEvent } from 'aws-lambda';
3-
import type { CompiledRoute, Path, ValidationResult } from '../types/rest.js';
4-
import { PARAM_PATTERN, SAFE_CHARS, UNSAFE_CHARS } from './constants.js';
2+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
3+
import type {
4+
CompiledRoute,
5+
HttpMethod,
6+
Path,
7+
ValidationResult,
8+
} from '../types/rest.js';
9+
import {
10+
HttpVerbs,
11+
PARAM_PATTERN,
12+
SAFE_CHARS,
13+
UNSAFE_CHARS,
14+
} from './constants.js';
515

616
export function compilePath(path: Path): CompiledRoute {
717
const paramNames: string[] = [];
@@ -68,3 +78,30 @@ export const isAPIGatewayProxyEvent = (
6878
(event.body === null || isString(event.body))
6979
);
7080
};
81+
82+
export const isHttpMethod = (method: string): method is HttpMethod => {
83+
return Object.keys(HttpVerbs).includes(method);
84+
};
85+
86+
/**
87+
* Type guard to check if the provided result is an API Gateway Proxy result.
88+
*
89+
* We use this function to ensure that the result is an object and has the
90+
* required properties without adding a dependency.
91+
*
92+
* @param result - The result to check
93+
*/
94+
export const isAPIGatewayProxyResult = (
95+
result: unknown
96+
): result is APIGatewayProxyResult => {
97+
if (!isRecord(result)) return false;
98+
return (
99+
typeof result.statusCode === 'number' &&
100+
isString(result.body) &&
101+
(result.headers === undefined || isRecord(result.headers)) &&
102+
(result.multiValueHeaders === undefined ||
103+
isRecord(result.multiValueHeaders)) &&
104+
(result.isBase64Encoded === undefined ||
105+
typeof result.isBase64Encoded === 'boolean')
106+
);
107+
};

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type ErrorResolveOptions = RequestOptions & ResolveOptions;
2424

2525
type ErrorHandler<T extends Error = Error> = (
2626
error: T,
27-
options?: RequestOptions
27+
options: RequestOptions
2828
) => Promise<ErrorResponse>;
2929

3030
interface ErrorConstructor<T extends Error = Error> {
@@ -53,10 +53,12 @@ interface CompiledRoute {
5353

5454
type DynamicRoute = Route & CompiledRoute;
5555

56+
type HandlerResponse = Response | JSONObject;
57+
5658
type RouteHandler<
5759
TParams = Record<string, unknown>,
58-
TReturn = Response | JSONObject,
59-
> = (args: TParams, options?: RequestOptions) => Promise<TReturn>;
60+
TReturn = HandlerResponse,
61+
> = (args: TParams, options: RequestOptions) => Promise<TReturn>;
6062

6163
type HttpMethod = keyof typeof HttpVerbs;
6264

@@ -106,6 +108,7 @@ export type {
106108
ErrorHandlerRegistryOptions,
107109
ErrorHandler,
108110
ErrorResolveOptions,
111+
HandlerResponse,
109112
HttpStatusCode,
110113
HttpMethod,
111114
Path,

0 commit comments

Comments
 (0)