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
60 changes: 40 additions & 20 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ import { Route } from './Route.js';
import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
import {
composeMiddleware,
getBase64EncodingFromHeaders,
getBase64EncodingFromResult,
getStatusCode,
HttpResponseStream,
isAPIGatewayProxyEventV1,
isAPIGatewayProxyEventV2,
isBinaryResult,
isExtendedAPIGatewayProxyResult,
resolvePrefixedPath,
} from './utils.js';
Expand Down Expand Up @@ -260,29 +264,30 @@ class Router {
const route = this.routeRegistry.resolve(method, path);

const handlerMiddleware: Middleware = async ({ reqCtx, next }) => {
let handlerRes: HandlerResponse;
if (route === null) {
const notFoundRes = await this.handleError(
handlerRes = await this.handleError(
new NotFoundError(`Route ${path} for method ${method} not found`),
{ ...reqCtx, scope: options?.scope }
);
reqCtx.res = handlerResultToWebResponse(
notFoundRes,
reqCtx.res.headers
);
} else {
const handler =
options?.scope == null
? route.handler
: route.handler.bind(options.scope);

const handlerResult = await handler(reqCtx);
handlerRes = await handler(reqCtx);
}

reqCtx.res = handlerResultToWebResponse(
handlerResult,
reqCtx.res.headers
);
if (getBase64EncodingFromResult(handlerRes)) {
reqCtx.isBase64Encoded = true;
}

reqCtx.res = handlerResultToWebResponse(handlerRes, {
statusCode: getStatusCode(handlerRes),
resHeaders: reqCtx.res.headers,
});

await next();
};

Expand All @@ -300,10 +305,10 @@ class Router {

// middleware result takes precedence to allow short-circuiting
if (middlewareResult !== undefined) {
requestContext.res = handlerResultToWebResponse(
middlewareResult,
requestContext.res.headers
);
requestContext.res = handlerResultToWebResponse(middlewareResult, {
statusCode: getStatusCode(middlewareResult),
resHeaders: requestContext.res.headers,
});
}

return requestContext;
Expand All @@ -313,10 +318,16 @@ class Router {
...requestContext,
scope: options?.scope,
});
requestContext.res = handlerResultToWebResponse(
res,
requestContext.res.headers
);

if (getBase64EncodingFromResult(res)) {
requestContext.isBase64Encoded = true;
}

requestContext.res = handlerResultToWebResponse(res, {
statusCode: getStatusCode(res, HttpStatusCodes.INTERNAL_SERVER_ERROR),
resHeaders: requestContext.res.headers,
});

return requestContext;
}
}
Expand Down Expand Up @@ -353,7 +364,12 @@ class Router {
options?: ResolveOptions
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2> {
const reqCtx = await this.#resolve(event, context, options);
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType);
const isBase64Encoded =
reqCtx.isBase64Encoded ??
getBase64EncodingFromHeaders(reqCtx.res.headers);
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, {
isBase64Encoded,
});
}

/**
Expand Down Expand Up @@ -434,7 +450,11 @@ class Router {
try {
const { scope, ...reqCtx } = options;
const body = await handler.apply(scope ?? this, [error, reqCtx]);
if (body instanceof Response || isExtendedAPIGatewayProxyResult(body)) {
if (
body instanceof Response ||
isExtendedAPIGatewayProxyResult(body) ||
isBinaryResult(body)
) {
return body;
}
if (!body.statusCode) {
Expand Down
99 changes: 48 additions & 51 deletions packages/event-handler/src/rest/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ import type {
APIGatewayProxyStructuredResultV2,
} from 'aws-lambda';
import type {
CompressionOptions,
ExtendedAPIGatewayProxyResult,
ExtendedAPIGatewayProxyResultBody,
HandlerResponse,
HttpStatusCode,
ResponseType,
ResponseTypeMap,
V1Headers,
WebResponseToProxyResultOptions,
} from '../types/rest.js';
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
import { HttpStatusCodes } from './constants.js';
import { InvalidHttpMethodError } from './errors.js';
import {
isAPIGatewayProxyEventV2,
isBinaryResult,
isExtendedAPIGatewayProxyResult,
isHttpMethod,
isNodeReadableStream,
Expand Down Expand Up @@ -213,41 +215,29 @@ const webHeadersToApiGatewayHeaders = <T extends ResponseType>(
: { headers: Record<string, string> };
};

const responseBodyToBase64 = async (response: Response) => {
const buffer = await response.arrayBuffer();
return Buffer.from(buffer).toString('base64');
};

/**
* Converts a Web API Response object to an API Gateway V1 proxy 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 API Gateway V1 proxy result
*/
const webResponseToProxyResultV1 = async (
response: Response
response: Response,
isBase64Encoded?: boolean
): Promise<APIGatewayProxyResult> => {
const { headers, multiValueHeaders } = webHeadersToApiGatewayV1Headers(
response.headers
);

// Check if response contains compressed/binary content
const contentEncoding = response.headers.get(
'content-encoding'
) as CompressionOptions['encoding'];
let body: string;
let isBase64Encoded = false;

if (
contentEncoding &&
[
COMPRESSION_ENCODING_TYPES.GZIP,
COMPRESSION_ENCODING_TYPES.DEFLATE,
].includes(contentEncoding)
) {
// For compressed content, get as buffer and encode to base64
const buffer = await response.arrayBuffer();
body = Buffer.from(buffer).toString('base64');
isBase64Encoded = true;
} else {
// For text content, use text()
body = await response.text();
}
const body = isBase64Encoded
? await responseBodyToBase64(response)
: await response.text();

const result: APIGatewayProxyResult = {
statusCode: response.status,
Expand All @@ -267,10 +257,12 @@ const webResponseToProxyResultV1 = async (
* Converts a Web API Response object to an API Gateway V2 proxy 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 API Gateway V2 proxy result
*/
const webResponseToProxyResultV2 = async (
response: Response
response: Response,
isBase64Encoded?: boolean
): Promise<APIGatewayProxyStructuredResultV2> => {
const headers: Record<string, string> = {};
const cookies: string[] = [];
Expand All @@ -283,25 +275,9 @@ const webResponseToProxyResultV2 = async (
}
}

const contentEncoding = response.headers.get(
'content-encoding'
) as CompressionOptions['encoding'];
let body: string;
let isBase64Encoded = false;

if (
contentEncoding &&
[
COMPRESSION_ENCODING_TYPES.GZIP,
COMPRESSION_ENCODING_TYPES.DEFLATE,
].includes(contentEncoding)
) {
const buffer = await response.arrayBuffer();
body = Buffer.from(buffer).toString('base64');
isBase64Encoded = true;
} else {
body = await response.text();
}
const body = isBase64Encoded
? await responseBodyToBase64(response)
: await response.text();

const result: APIGatewayProxyStructuredResultV2 = {
statusCode: response.status,
Expand All @@ -319,12 +295,18 @@ const webResponseToProxyResultV2 = async (

const webResponseToProxyResult = <T extends ResponseType>(
response: Response,
responseType: T
responseType: T,
options?: WebResponseToProxyResultOptions
): Promise<ResponseTypeMap[T]> => {
const isBase64Encoded = options?.isBase64Encoded ?? false;
if (responseType === 'ApiGatewayV1') {
return webResponseToProxyResultV1(response) as Promise<ResponseTypeMap[T]>;
return webResponseToProxyResultV1(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
}
return webResponseToProxyResultV2(response) as Promise<ResponseTypeMap[T]>;
return webResponseToProxyResultV2(response, isBase64Encoded) as Promise<
ResponseTypeMap[T]
>;
};

/**
Expand Down Expand Up @@ -365,13 +347,15 @@ function addProxyEventHeaders(
* Handles APIGatewayProxyResult, Response objects, and plain objects.
*
* @param response - The handler response (APIGatewayProxyResult, Response, or plain object)
* @param resHeaders - Optional headers to be included in the response
* @param options - Optional configuration with statusCode and resHeaders
* @returns A Web API Response object
*/
const handlerResultToWebResponse = (
response: HandlerResponse,
resHeaders?: Headers
options?: { statusCode?: HttpStatusCode; resHeaders?: Headers }
): Response => {
const statusCode = options?.statusCode ?? HttpStatusCodes.OK;
const resHeaders = options?.resHeaders;
if (response instanceof Response) {
if (resHeaders === undefined) return response;
const headers = new Headers(resHeaders);
Expand All @@ -385,6 +369,19 @@ const handlerResultToWebResponse = (
}

const headers = new Headers(resHeaders);

if (isBinaryResult(response)) {
const body =
response instanceof Readable
? (Readable.toWeb(response) as ReadableStream)
: response;

return new Response(body, {
status: statusCode,
headers,
});
}

headers.set('Content-Type', 'application/json');

if (isExtendedAPIGatewayProxyResult(response)) {
Expand All @@ -400,7 +397,7 @@ const handlerResultToWebResponse = (
headers,
});
}
return Response.json(response, { headers });
return Response.json(response, { headers, status: statusCode });
};

/**
Expand Down
67 changes: 67 additions & 0 deletions packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import {
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
import type {
CompiledRoute,
CompressionOptions,
ExtendedAPIGatewayProxyResult,
HandlerResponse,
HttpMethod,
HttpStatusCode,
Middleware,
Path,
ResponseStream,
ValidationResult,
} from '../types/rest.js';
import {
COMPRESSION_ENCODING_TYPES,
HttpStatusCodes,
HttpVerbs,
PARAM_PATTERN,
SAFE_CHARS,
Expand Down Expand Up @@ -156,6 +160,16 @@ export const isWebReadableStream = (
);
};

export const isBinaryResult = (
value: unknown
): value is ArrayBuffer | Readable | ReadableStream => {
return (
value instanceof ArrayBuffer ||
isNodeReadableStream(value) ||
isWebReadableStream(value)
);
};

/**
* Type guard to check if the provided result is an API Gateway Proxy result.
*
Expand Down Expand Up @@ -318,3 +332,56 @@ export const HttpResponseStream =
return underlyingStream;
}
};

export const getBase64EncodingFromResult = (result: HandlerResponse) => {
if (isBinaryResult(result)) {
return true;
}
if (isExtendedAPIGatewayProxyResult(result)) {
return isBinaryResult(result);
}
return false;
};

export const getBase64EncodingFromHeaders = (headers: Headers): boolean => {
const contentEncoding = headers.get(
'content-encoding'
) as CompressionOptions['encoding'];

if (
contentEncoding != null &&
[
COMPRESSION_ENCODING_TYPES.GZIP,
COMPRESSION_ENCODING_TYPES.DEFLATE,
].includes(contentEncoding)
) {
return true;
}

const contentType = headers.get('content-type');
if (contentType != null) {
const type = contentType.split(';')[0].trim();
if (
type.startsWith('image/') ||
type.startsWith('audio/') ||
type.startsWith('video/')
) {
return true;
}
}

return false;
};

export const getStatusCode = (
result: HandlerResponse,
fallback: HttpStatusCode = HttpStatusCodes.OK
): HttpStatusCode => {
if (result instanceof Response) {
return result.status as HttpStatusCode;
}
if (isExtendedAPIGatewayProxyResult(result)) {
return result.statusCode as HttpStatusCode;
}
return fallback;
};
Loading
Loading