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
4 changes: 4 additions & 0 deletions docs/features/event-handler/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ You can combine both request and response validation in a single route by provid

You can access request details such as headers, query parameters, and body using the `Request` object provided to your route handlers and middleware functions via `reqCtx.req`.

For API Gateway v1, API Gateway v2, and ALB events, the router automatically decodes base64 request bodies into bytes. Use `reqCtx.req.arrayBuffer()` to read binary uploads or `reqCtx.req.formData()` to read multipart uploads. You can continue using `reqCtx.req.text()` and `reqCtx.req.json()` for text and JSON bodies.

The router preserves the request's `Content-Type` header. If the event contains a base64 body without this header, the router leaves the content type unset. Plain string bodies without a content type retain the Web `Request` default of `text/plain;charset=UTF-8`. Bodies on GET and HEAD requests are ignored.

### Error handling

You can use the `errorHandler()` method as a higher-order function or class method decorator to define a custom error handler for errors thrown in your route handlers or middleware.
Expand Down
102 changes: 53 additions & 49 deletions packages/event-handler/src/http/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
import type { IStore } from '../store/Store.js';
import { Store } from '../store/Store.js';
import type {
ClassifiedEvent,
Env,
ErrorConstructor,
ErrorHandler,
Expand Down Expand Up @@ -53,14 +54,14 @@ import type {
import type { HandlerResponse, ResolveOptions } from '../types/index.js';
import { HttpStatusCodes, HttpVerbs } from './constants.js';
import {
proxyEventToWebRequest,
classifiedEventToWebRequest,
classifyEvent,
webHeadersToApiGatewayHeaders,
webResponseToProxyResult,
} from './converters.js';
import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js';
import {
HttpError,
InvalidEventError,
InvalidHttpMethodError,
MethodNotAllowedError,
NotFoundError,
Expand All @@ -73,15 +74,22 @@ import {
composeMiddleware,
getBase64EncodingFromHeaders,
HttpResponseStream,
isALBEvent,
isAPIGatewayProxyEventV1,
isAPIGatewayProxyEventV2,
isBinaryResult,
isExtendedAPIGatewayProxyResult,
resolvePrefixedPath,
stripTrailingSlashes,
} from './utils.js';

/**
* Carries the response and metadata needed for buffered or streaming output.
*
* @internal
*/
type ResolvedResponse = Pick<
RequestContext,
'res' | 'responseType' | 'isBase64Encoded'
>;

class Router<TEnv extends Env = Env> {
/**
* @deprecated This property is deprecated and will be removed in a future major version, please use `requestContext.shared` instead.
Expand Down Expand Up @@ -245,16 +253,24 @@ class Router<TEnv extends Env = Env> {
};
}

/**
* Builds the middleware context from a classified event and its Web Request.
*
* @param classified - The event and its integration
* @param context - The Lambda context
* @param options - The request, response, and store accessors
*/
#buildRequestContext(
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent,
classified: ClassifiedEvent,
context: Context,
options: {
req: Request;
res: Response;
isHttpStreaming?: boolean;
} & Pick<RequestContext<TEnv>, 'set' | 'get' | 'has' | 'delete' | 'shared'>
): RequestContext<TEnv> {
const common = {
return {
...classified,
context,
req: options.req,
res: options.res,
Expand All @@ -267,14 +283,6 @@ class Router<TEnv extends Env = Env> {
delete: options.delete,
shared: options.shared,
};

if (isAPIGatewayProxyEventV2(event)) {
return { ...common, event, responseType: 'ApiGatewayV2' };
}
if (isALBEvent(event)) {
return { ...common, event, responseType: 'ALB' };
}
return { ...common, event, responseType: 'ApiGatewayV1' };
}

/**
Expand All @@ -284,50 +292,44 @@ class Router<TEnv extends Env = Env> {
* @param event - The Lambda event to resolve
* @param context - The Lambda context
* @param options - Optional resolve options for scope binding
* @returns A handler response (Response, JSONObject, or ExtendedAPIGatewayProxyResult)
*/
async #resolve(
event: unknown,
context: Context,
options?: HttpResolveOptions
): Promise<RequestContext<TEnv>> {
if (
!isAPIGatewayProxyEventV1(event) &&
!isAPIGatewayProxyEventV2(event) &&
!isALBEvent(event)
) {
): Promise<ResolvedResponse> {
let classified: ClassifiedEvent;
try {
classified = classifyEvent(event);
} catch (error) {
this.logger.error(
'Received an event that is not compatible with this resolver'
);
throw new InvalidEventError();
throw error;
}

const requestStore = new Store<RequestStoreOf<TEnv>>();
const storeAccessors = this.#createStoreAccessors(requestStore);

let req: Request;
try {
req = proxyEventToWebRequest(event);
req = classifiedEventToWebRequest(classified);
} catch (err) {
if (err instanceof InvalidHttpMethodError) {
this.logger.error(err);
// We can't throw a MethodNotAllowedError outside the try block as it
// will be converted to an internal server error by the API Gateway runtime
return this.#buildRequestContext(event, context, {
req: new Request('https://invalid'),
return {
responseType: classified.responseType,
res: new Response(null, {
status: HttpStatusCodes.METHOD_NOT_ALLOWED,
...(options?.isHttpStreaming && {
headers: { 'transfer-encoding': 'chunked' },
}),
}),
...storeAccessors,
});
};
}
throw err;
}

const requestContext = this.#buildRequestContext(event, context, {
const requestStore = new Store<RequestStoreOf<TEnv>>();
const storeAccessors = this.#createStoreAccessors(requestStore);
const requestContext = this.#buildRequestContext(classified, context, {
req,
res: new Response('', {
status: HttpStatusCodes.INTERNAL_SERVER_ERROR,
Expand Down Expand Up @@ -441,13 +443,15 @@ class Router<TEnv extends Env = Env> {
context: Context,
options?: ResolveOptions
): Promise<RouterResponse> {
const reqCtx = await this.#resolve(event, context, options);
const resolvedResponse = await this.#resolve(event, context, options);
const isBase64Encoded =
reqCtx.isBase64Encoded ??
getBase64EncodingFromHeaders(reqCtx.res.headers);
return webResponseToProxyResult(reqCtx.res, reqCtx.responseType, {
isBase64Encoded,
});
resolvedResponse.isBase64Encoded ??
getBase64EncodingFromHeaders(resolvedResponse.res.headers);
return webResponseToProxyResult(
resolvedResponse.res,
resolvedResponse.responseType,
{ isBase64Encoded }
);
}

/**
Expand All @@ -464,36 +468,36 @@ class Router<TEnv extends Env = Env> {
context: Context,
options: ResolveStreamOptions
): Promise<void> {
const reqCtx = await this.#resolve(event, context, {
const resolvedResponse = await this.#resolve(event, context, {
...options,
isHttpStreaming: true,
});
await this.#streamHandlerResponse(reqCtx, options.responseStream);
await this.#streamHandlerResponse(resolvedResponse, options.responseStream);
}

/**
* Streams a handler response to the Lambda response stream.
* Converts the response to a web response and pipes it through the stream.
*
* @param reqCtx - The request context containing the response to stream
* @param resolvedResponse - The resolved response and its output metadata
* @param responseStream - The Lambda response stream to write to
*/
async #streamHandlerResponse(
reqCtx: RequestContext,
resolvedResponse: ResolvedResponse,
responseStream: ResponseStream
) {
const { headers } = webHeadersToApiGatewayHeaders(
reqCtx.res.headers,
reqCtx.responseType
resolvedResponse.res.headers,
resolvedResponse.responseType
);
const resStream = HttpResponseStream.from(responseStream, {
statusCode: reqCtx.res.status,
statusCode: resolvedResponse.res.status,
headers,
});

if (reqCtx.res.body) {
if (resolvedResponse.res.body) {
const nodeStream = Readable.fromWeb(
reqCtx.res.body as streamWeb.ReadableStream
resolvedResponse.res.body as streamWeb.ReadableStream
);
await pipeline(nodeStream, resStream);
} else {
Expand Down
Loading