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
25 changes: 18 additions & 7 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
isDevMode,
} from '@aws-lambda-powertools/commons/utils/env';
import type {
ALBEvent,
ALBResult,
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
APIGatewayProxyResult,
Expand All @@ -28,10 +30,10 @@ import type {
RequestContext,
ResolveStreamOptions,
ResponseStream,
ResponseType,
RestRouteOptions,
RestRouterOptions,
RouteHandler,
RouterResponse,
} from '../types/rest.js';
import { HttpStatusCodes, HttpVerbs } from './constants.js';
import {
Expand All @@ -54,8 +56,10 @@ import {
composeMiddleware,
getBase64EncodingFromHeaders,
getBase64EncodingFromResult,
getResponseType,
getStatusCode,
HttpResponseStream,
isALBEvent,
isAPIGatewayProxyEventV1,
isAPIGatewayProxyEventV2,
isBinaryResult,
Expand Down Expand Up @@ -219,16 +223,18 @@ class Router {
context: Context,
options?: ResolveOptions
): Promise<RequestContext> {
if (!isAPIGatewayProxyEventV1(event) && !isAPIGatewayProxyEventV2(event)) {
if (
!isAPIGatewayProxyEventV1(event) &&
!isAPIGatewayProxyEventV2(event) &&
!isALBEvent(event)
) {
this.logger.error(
'Received an event that is not compatible with this resolver'
);
throw new InvalidEventError();
}

const responseType: ResponseType = isAPIGatewayProxyEventV2(event)
? 'ApiGatewayV2'
: 'ApiGatewayV1';
const responseType = getResponseType(event);

let req: Request;
try {
Expand Down Expand Up @@ -357,16 +363,21 @@ class Router {
context: Context,
options?: ResolveOptions
): Promise<APIGatewayProxyStructuredResultV2>;
public async resolve(
event: ALBEvent,
context: Context,
options?: ResolveOptions
): Promise<ALBResult>;
public async resolve(
event: unknown,
context: Context,
options?: ResolveOptions
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2>;
): Promise<RouterResponse>;
public async resolve(
event: unknown,
context: Context,
options?: ResolveOptions
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2> {
): Promise<RouterResponse> {
const reqCtx = await this.#resolve(event, context, options);
const isBase64Encoded =
reqCtx.isBase64Encoded ??
Expand Down
69 changes: 69 additions & 0 deletions packages/event-handler/src/rest/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,78 @@ const COMPRESSION_ENCODING_TYPES = {
ANY: '*',
} as const;

const HttpStatusText: Record<number, string> = {
// 2xx Success
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status',
208: 'Already Reported',
226: 'IM Used',

// 3xx Redirection
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
307: 'Temporary Redirect',
308: 'Permanent Redirect',

// 4xx Client Error
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Request Entity Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested Range Not Satisfiable',
417: 'Expectation Failed',
418: "I'm a Teapot",
421: 'Misdirected Request',
422: 'Unprocessable Entity',
423: 'Locked',
424: 'Failed Dependency',
425: 'Too Early',
426: 'Upgrade Required',
428: 'Precondition Required',
429: 'Too Many Requests',
431: 'Request Header Fields Too Large',
451: 'Unavailable For Legal Reasons',

// 5xx Server Error
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
506: 'Variant Also Negotiates',
507: 'Insufficient Storage',
508: 'Loop Detected',
510: 'Not Extended',
511: 'Network Authentication Required',
};

export {
HttpVerbs,
HttpStatusCodes,
HttpStatusText,
PARAM_PATTERN,
SAFE_CHARS,
UNSAFE_CHARS,
Expand Down
99 changes: 90 additions & 9 deletions packages/event-handler/src/rest/converters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Readable } from 'node:stream';
import type streamWeb from 'node:stream/web';
import type {
ALBEvent,
ALBResult,
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
APIGatewayProxyResult,
Expand All @@ -16,9 +18,10 @@ import type {
V1Headers,
WebResponseToProxyResultOptions,
} from '../types/rest.js';
import { HttpStatusCodes } from './constants.js';
import { HttpStatusCodes, HttpStatusText, HttpVerbs } from './constants.js';
import { InvalidHttpMethodError } from './errors.js';
import {
isALBEvent,
isAPIGatewayProxyEventV2,
isBinaryResult,
isExtendedAPIGatewayProxyResult,
Expand Down Expand Up @@ -47,11 +50,11 @@ const createBody = (body: string | null, isBase64Encoded: boolean) => {
* Populates headers from single and multi-value header entries.
*
* @param headers - The Headers object to populate
* @param event - The API Gateway proxy event
* @param event - The API Gateway proxy event or ALB event
*/
const populateV1Headers = (
headers: Headers,
event: APIGatewayProxyEvent
event: APIGatewayProxyEvent | ALBEvent
): void => {
for (const [name, value] of Object.entries(event.headers ?? {})) {
if (value !== undefined) headers.set(name, value);
Expand All @@ -71,9 +74,12 @@ const populateV1Headers = (
* Populates URL search parameters from single and multi-value query string parameters.
*
* @param url - The URL object to populate
* @param event - The API Gateway proxy event
* @param event - The API Gateway proxy event or ALB event
*/
const populateV1QueryParams = (url: URL, event: APIGatewayProxyEvent): void => {
const populateV1QueryParams = (
url: URL,
event: APIGatewayProxyEvent | ALBEvent
): void => {
for (const [name, value] of Object.entries(
event.queryStringParameters ?? {}
)) {
Expand Down Expand Up @@ -154,14 +160,45 @@ const proxyEventV2ToWebRequest = (event: APIGatewayProxyEventV2): Request => {
};

/**
* Converts an API Gateway proxy event (V1 or V2) to a Web API Request object.
* Converts an ALB event to a Web API Request object.
*
* @param event - The ALB event
* @returns A Web API Request object
*/
const albEventToWebRequest = (event: ALBEvent): Request => {
const { httpMethod, path } = event;

const headers = new Headers();
populateV1Headers(headers, event);

const hostname = headers.get('Host') ?? 'localhost';
const protocol = headers.get('X-Forwarded-Proto') ?? 'https';

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

// ALB events represent GET and PATCH request bodies as empty strings
const body =
httpMethod === HttpVerbs.GET || httpMethod === HttpVerbs.PATCH
? null
: createBody(event.body ?? null, event.isBase64Encoded);

return new Request(url.toString(), {
method: httpMethod,
headers,
body: body,
});
};

/**
* Converts an API Gateway proxy event (V1 or V2) or ALB event to a Web API Request object.
* Automatically detects the event version and calls the appropriate converter.
*
* @param event - The API Gateway proxy event (V1 or V2)
* @param event - The API Gateway proxy event (V1 or V2) or ALB event
* @returns A Web API Request object
*/
const proxyEventToWebRequest = (
event: APIGatewayProxyEvent | APIGatewayProxyEventV2
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent
): Request => {
if (isAPIGatewayProxyEventV2(event)) {
const method = event.requestContext.http.method.toUpperCase();
Expand All @@ -170,10 +207,13 @@ const proxyEventToWebRequest = (
}
return proxyEventV2ToWebRequest(event);
}
const method = event.requestContext.httpMethod.toUpperCase();
const method = event.httpMethod.toUpperCase();
if (!isHttpMethod(method)) {
throw new InvalidHttpMethodError(method);
}
if (isALBEvent(event)) {
return albEventToWebRequest(event);
}
return proxyEventV1ToWebRequest(event);
};

Expand Down Expand Up @@ -319,6 +359,42 @@ const webResponseToProxyResultV2 = async (
return result;
};

/**
* Converts a Web API Response object to an ALB result.
*
* @param response - The Web API Response object
* @param isBase64Encoded - Whether the response body should be base64 encoded (e.g., for binary or compressed content)
* @returns An ALB result
*/
const webResponseToALBResult = async (
response: Response,
isBase64Encoded?: boolean
): Promise<ALBResult> => {
const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers(
response.headers
);

const body = isBase64Encoded
? await responseBodyToBase64(response)
: await response.text();

const statusText = response.statusText || HttpStatusText[response.status];

const result: ALBResult = {
statusCode: response.status,
statusDescription: `${response.status} ${statusText}`,
headers,
body,
isBase64Encoded,
};

if (Object.keys(multiValueHeaders).length > 0) {
result.multiValueHeaders = multiValueHeaders;
}

return result;
};

const webResponseToProxyResult = <T extends ResponseType>(
response: Response,
responseType: T,
Expand All @@ -330,6 +406,11 @@ const webResponseToProxyResult = <T extends ResponseType>(
ResponseTypeMap[T]
>;
}
if (responseType === 'ALB') {
return webResponseToALBResult(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
}
return webResponseToProxyResultV2(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
Expand Down
1 change: 1 addition & 0 deletions packages/event-handler/src/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
export { Router } from './Router.js';
export {
composeMiddleware,
isALBEvent,
isAPIGatewayProxyEventV1,
isAPIGatewayProxyEventV2,
isExtendedAPIGatewayProxyResult,
Expand Down
23 changes: 22 additions & 1 deletion packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isString,
} from '@aws-lambda-powertools/commons/typeutils';
import type {
ALBEvent,
APIGatewayProxyEvent,
APIGatewayProxyEventV2,
StreamifyHandler,
Expand All @@ -21,6 +22,7 @@ import type {
Middleware,
Path,
ResponseStream,
ResponseType,
ValidationResult,
} from '../types/rest.js';
import {
Expand All @@ -44,7 +46,7 @@ export function compilePath(path: Path): CompiledRoute {
PARAM_PATTERN,
(_match, paramName) => {
paramNames.push(paramName);
return `(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\\w]+)`;
return String.raw`(?<${paramName}>[${SAFE_CHARS}${UNSAFE_CHARS}\w]+)`;
}
);

Expand Down Expand Up @@ -140,6 +142,25 @@ export const isAPIGatewayProxyEventV2 = (
);
};

/**
* Type guard to check if the provided event is an ALB event.
*
* @param event - The incoming event to check
*/
export const isALBEvent = (event: unknown): event is ALBEvent => {
if (!isRecord(event)) return false;
if (!isRecord(event.requestContext)) return false;
return isRecord(event.requestContext.elb);
};

export const getResponseType = (
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent
): ResponseType => {
if (isAPIGatewayProxyEventV2(event)) return 'ApiGatewayV2';
if (isALBEvent(event)) return 'ALB';
return 'ApiGatewayV1';
};

export const isHttpMethod = (method: string): method is HttpMethod => {
return Object.keys(HttpVerbs).includes(method);
};
Expand Down
Loading
Loading