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
30 changes: 25 additions & 5 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
isDevMode,
} from '@aws-lambda-powertools/commons/utils/env';
import type { APIGatewayProxyResult, Context } from 'aws-lambda';
import type { ResolveOptions } from '../types/index.js';
import type {
HandlerResponse,
HttpStatusCode,
ResolveOptions,
} from '../types/index.js';
import type {
ErrorConstructor,
ErrorHandler,
Expand All @@ -22,7 +26,6 @@ import {
handlerResultToProxyResult,
handlerResultToWebResponse,
proxyEventToWebRequest,
webResponseToProxyResult,
} from './converters.js';
import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js';
import {
Expand All @@ -36,6 +39,7 @@ import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
import {
composeMiddleware,
isAPIGatewayProxyEvent,
isAPIGatewayProxyResult,
isHttpMethod,
} from './utils.js';

Expand Down Expand Up @@ -280,7 +284,9 @@ class Router {
...requestContext,
scope: options?.scope,
});
return await webResponseToProxyResult(result);
const statusCode =
result instanceof Response ? result.status : result.statusCode;
return handlerResultToProxyResult(result, statusCode as HttpStatusCode);
}
}

Expand Down Expand Up @@ -310,17 +316,31 @@ class Router {
protected async handleError(
error: Error,
options: ErrorResolveOptions
): Promise<Response> {
): Promise<HandlerResponse> {
const handler = this.errorHandlerRegistry.resolve(error);
if (handler !== null) {
try {
const { scope, ...reqCtx } = options;
const body = await handler.apply(scope ?? this, [error, reqCtx]);
if (body instanceof Response || isAPIGatewayProxyResult(body)) {
return body;
}
if (!body.statusCode) {
if (error instanceof NotFoundError) {
body.statusCode = HttpErrorCodes.NOT_FOUND;
} else if (error instanceof MethodNotAllowedError) {
body.statusCode = HttpErrorCodes.METHOD_NOT_ALLOWED;
}
}
return new Response(JSON.stringify(body), {
status: body.statusCode,
status:
(body.statusCode as number) ?? HttpErrorCodes.INTERNAL_SERVER_ERROR,
headers: { 'Content-Type': 'application/json' },
});
} catch (handlerError) {
if (handlerError instanceof ServiceError) {
return await this.handleError(handlerError, options);
}
return this.#defaultErrorHandler(handlerError as Error);
}
}
Expand Down
14 changes: 10 additions & 4 deletions packages/event-handler/src/rest/converters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { CompressionOptions, HandlerResponse } from '../types/rest.js';
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
import type {
CompressionOptions,
HandlerResponse,
HttpStatusCode,
} from '../types/rest.js';
import { COMPRESSION_ENCODING_TYPES, HttpErrorCodes } from './constants.js';
import { isAPIGatewayProxyResult } from './utils.js';

/**
Expand Down Expand Up @@ -181,10 +185,12 @@ export const handlerResultToWebResponse = (
* Handles APIGatewayProxyResult, Response objects, and plain objects.
*
* @param response - The handler response (APIGatewayProxyResult, Response, or plain object)
* @param statusCode - The response status code to return
* @returns An API Gateway proxy result
*/
export const handlerResultToProxyResult = async (
response: HandlerResponse
response: HandlerResponse,
statusCode: HttpStatusCode = HttpErrorCodes.OK
): Promise<APIGatewayProxyResult> => {
if (isAPIGatewayProxyResult(response)) {
return response;
Expand All @@ -193,7 +199,7 @@ export const handlerResultToProxyResult = async (
return await webResponseToProxyResult(response);
}
return {
statusCode: 200,
statusCode,
body: JSON.stringify(response),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
Expand Down
9 changes: 6 additions & 3 deletions packages/event-handler/src/rest/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ErrorResponse, HttpStatusCode } from '../types/rest.js';
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
import type { HandlerResponse, HttpStatusCode } from '../types/rest.js';
import { HttpErrorCodes } from './constants.js';

export class RouteMatchingError extends Error {
Expand Down Expand Up @@ -34,12 +35,14 @@ export abstract class ServiceError extends Error {
this.details = details;
}

toJSON(): ErrorResponse {
toJSON(): HandlerResponse {
return {
statusCode: this.statusCode,
error: this.errorType,
message: this.message,
...(this.details && { details: this.details }),
...(this.details && {
details: this.details as Record<string, JSONValue>,
}),
};
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/event-handler/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export type {
CorsOptions,
ErrorHandler,
ErrorResolveOptions,
ErrorResponse,
HandlerResponse,
HttpMethod,
HttpStatusCode,
Expand Down
9 changes: 1 addition & 8 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ import type { Route } from '../rest/Route.js';
import type { Router } from '../rest/Router.js';
import type { ResolveOptions } from './common.js';

type ErrorResponse = {
statusCode: HttpStatusCode;
error: string;
message: string;
};

type RequestContext = {
req: Request;
event: APIGatewayProxyEvent;
Expand All @@ -27,7 +21,7 @@ type ErrorResolveOptions = RequestContext & ResolveOptions;
type ErrorHandler<T extends Error = Error> = (
error: T,
reqCtx: RequestContext
) => Promise<ErrorResponse>;
) => Promise<HandlerResponse>;

interface ErrorConstructor<T extends Error = Error> {
new (...args: any[]): T;
Expand Down Expand Up @@ -165,7 +159,6 @@ export type {
CompiledRoute,
CorsOptions,
DynamicRoute,
ErrorResponse,
ErrorConstructor,
ErrorHandlerRegistryOptions,
ErrorHandler,
Expand Down
181 changes: 177 additions & 4 deletions packages/event-handler/tests/unit/rest/Router/error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
HttpErrorCodes,
InternalServerError,
MethodNotAllowedError,
NotFoundError,
Router,
} from '../../../../src/rest/index.js';
import { createTestEvent } from '../helpers.js';
Expand Down Expand Up @@ -49,7 +50,6 @@ describe('Class: Router - Error Handling', () => {
const app = new Router();

app.notFound(async (error) => ({
statusCode: HttpErrorCodes.NOT_FOUND,
error: 'Not Found',
message: `Custom: ${error.message}`,
}));
Expand All @@ -64,9 +64,9 @@ describe('Class: Router - Error Handling', () => {
expect(result).toEqual({
statusCode: HttpErrorCodes.NOT_FOUND,
body: JSON.stringify({
statusCode: HttpErrorCodes.NOT_FOUND,
error: 'Not Found',
message: 'Custom: Route /nonexistent for method GET not found',
statusCode: HttpErrorCodes.NOT_FOUND,
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
Expand All @@ -78,7 +78,6 @@ describe('Class: Router - Error Handling', () => {
const app = new Router();

app.methodNotAllowed(async (error) => ({
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
error: 'Method Not Allowed',
message: `Custom: ${error.message}`,
}));
Expand All @@ -94,9 +93,9 @@ describe('Class: Router - Error Handling', () => {
expect(result).toEqual({
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
body: JSON.stringify({
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
error: 'Method Not Allowed',
message: 'Custom: POST not allowed',
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
Expand Down Expand Up @@ -393,4 +392,178 @@ describe('Class: Router - Error Handling', () => {
expect(body.hasEvent).toBe(true);
expect(body.hasContext).toBe(true);
});

it('handles returning a Response from the error handler', async () => {
// Prepare
const app = new Router();

app.errorHandler(
BadRequestError,
async () =>
new Response(
JSON.stringify({
foo: 'bar',
}),
{
status: HttpErrorCodes.BAD_REQUEST,
headers: {
'content-type': 'application/json',
},
}
)
);

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result).toEqual({
statusCode: HttpErrorCodes.BAD_REQUEST,
body: JSON.stringify({
foo: 'bar',
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
});
});

it('handles returning an API Gateway Proxy result from the error handler', async () => {
// Prepare
const app = new Router();

app.errorHandler(BadRequestError, async () => ({
statusCode: HttpErrorCodes.BAD_REQUEST,
body: JSON.stringify({
foo: 'bar',
}),
}));

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result).toEqual({
statusCode: HttpErrorCodes.BAD_REQUEST,
body: JSON.stringify({
foo: 'bar',
}),
});
});

it('handles returning a JSONObject from the error handler', async () => {
// Prepare
const app = new Router();

app.errorHandler(BadRequestError, async () => ({ foo: 'bar' }));

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result).toEqual({
statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR,
body: JSON.stringify({
foo: 'bar',
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
});
});

it('handles throwing a built in NotFound error from the error handler', async () => {
// Prepare
const app = new Router();

app.errorHandler(BadRequestError, async () => {
throw new NotFoundError('This error is thrown from the error handler');
});

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result).toEqual({
statusCode: HttpErrorCodes.NOT_FOUND,
body: JSON.stringify({
statusCode: HttpErrorCodes.NOT_FOUND,
error: 'NotFoundError',
message: 'This error is thrown from the error handler',
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
});
});

it('handles throwing a built in MethodNotAllowedError error from the error handler', async () => {
// Prepare
const app = new Router();

app.errorHandler(BadRequestError, async () => {
throw new MethodNotAllowedError(
'This error is thrown from the error handler'
);
});

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result).toEqual({
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
body: JSON.stringify({
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
error: 'MethodNotAllowedError',
message: 'This error is thrown from the error handler',
}),
headers: { 'content-type': 'application/json' },
isBase64Encoded: false,
});
});

it('handles throwing a generic error from the error handler', async () => {
// Prepare
vi.stubEnv('POWERTOOLS_DEV', 'true');
const app = new Router();

app.errorHandler(BadRequestError, async () => {
throw new Error('This error is thrown from the error handler');
});

app.get('/test', () => {
throw new BadRequestError('test error');
});

// Act
const result = await app.resolve(createTestEvent('/test', 'GET'), context);

// Assess
expect(result.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR);
const body = JSON.parse(result.body);
expect(body.error).toBe('Internal Server Error');
expect(body.message).toBe('This error is thrown from the error handler');
expect(body.stack).toBeDefined();
expect(body.details).toEqual({
errorName: 'Error',
});
});
});