Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1c1b1f1
refactor: Update HandlerResponse type to use JSONValue instead of JSO…
arnabrahman Nov 9, 2025
e1d5afc
refactor: Enhance error handling by adding #errorBodyToResponse and #…
arnabrahman Nov 9, 2025
4517ec3
refactor: Rename #tryAddErrorCodeToBody to #tryAddingErrorCodeToBody …
arnabrahman Nov 9, 2025
b50a14d
refactor: HTTP method tests for array response
arnabrahman Nov 9, 2025
5d2d7b3
refactor: Update decorators tests to return expected responses for bo…
arnabrahman Nov 9, 2025
829e20a
refactor: Simplify HTTP method tests by using describe.each for reque…
arnabrahman Nov 9, 2025
ef33697
test: Add test for handling array response from error handler
arnabrahman Nov 9, 2025
6c2ad1f
refactor: Simplify error handling by extracting status code logic fro…
arnabrahman Nov 10, 2025
cbd789d
refactor: Update response body in decorators tests to reflect expecte…
arnabrahman Nov 10, 2025
a1ca85b
Merge branch 'main' into 4593-handler-array-response
arnabrahman Nov 10, 2025
4605774
test: Enhance routing tests to include array response handling
arnabrahman Nov 10, 2025
1f262af
refactor: Consolidate HTTP method tests for object and array responses
arnabrahman Nov 10, 2025
d2d98f6
test: Add array response handling for all HTTP methods in decorators …
arnabrahman Nov 11, 2025
849dab1
test: Update request routing test description to specify object response
arnabrahman Nov 11, 2025
f94addd
refactor: Extract Lambda class creation into a separate function for …
arnabrahman Nov 11, 2025
fb1ea55
Merge branch 'main' into 4593-handler-array-response
arnabrahman Nov 11, 2025
d20ac1f
refactor: Rename errorBodyToResponse method to errorBodyToWebResponse…
arnabrahman Nov 11, 2025
bdfc053
refactor: Move handler creation functions to helpers for better organ…
arnabrahman Nov 11, 2025
8e8bbe5
Merge branch 'main' into 4593-handler-array-response
svozza Nov 11, 2025
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
62 changes: 48 additions & 14 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type streamWeb from 'node:stream/web';
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
import type {
GenericLogger,
JSONValue,
} from '@aws-lambda-powertools/commons/types';
import { isRecord } from '@aws-lambda-powertools/commons/typeutils';
import {
getStringFromEnv,
isDevMode,
Expand Down Expand Up @@ -457,19 +461,7 @@ class Router {
) {
return body;
}
if (!body.statusCode) {
if (error instanceof NotFoundError) {
body.statusCode = HttpStatusCodes.NOT_FOUND;
} else if (error instanceof MethodNotAllowedError) {
body.statusCode = HttpStatusCodes.METHOD_NOT_ALLOWED;
}
}
return new Response(JSON.stringify(body), {
status:
(body.statusCode as number) ??
HttpStatusCodes.INTERNAL_SERVER_ERROR,
headers: { 'Content-Type': 'application/json' },
});
return this.#errorBodyToResponse(body, error);
} catch (handlerError) {
if (handlerError instanceof HttpError) {
return await this.handleError(handlerError, options);
Expand All @@ -488,6 +480,48 @@ class Router {
return this.#defaultErrorHandler(error);
}

/**
* Converts an error handler's response body to an HTTP Response object.
*
* If the body is a record object without a status code, sets the status code for
* NotFoundError (404) or MethodNotAllowedError (405). Uses the status code from
* the body if present, otherwise defaults to 500 Internal Server Error.
*
* @param body - The response body returned by the error handler, of type JSONValue
* @param error - The Error object associated with the response
*/
#errorBodyToResponse(body: JSONValue, error: Error): Response {
let status: number = HttpStatusCodes.INTERNAL_SERVER_ERROR;

if (isRecord(body)) {
body.statusCode = body.statusCode ?? this.#getStatusCodeFromError(error);
status = (body.statusCode as number) ?? status;
}

return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

/**
* Extracts the HTTP status code from an error instance.
*
* Maps specific error types to their corresponding HTTP status codes:
* - `NotFoundError` maps to 404 (NOT_FOUND)
* - `MethodNotAllowedError` maps to 405 (METHOD_NOT_ALLOWED)
*
* @param error - The error instance to extract the status code from
*/
#getStatusCodeFromError(error: Error): number | undefined {
if (error instanceof NotFoundError) {
return HttpStatusCodes.NOT_FOUND;
}
if (error instanceof MethodNotAllowedError) {
return HttpStatusCodes.METHOD_NOT_ALLOWED;
}
}

/**
* Default error handler that returns a 500 Internal Server Error response.
* In development mode, includes stack trace and error details.
Expand Down
4 changes: 2 additions & 2 deletions packages/event-handler/src/types/rest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Readable } from 'node:stream';
import type {
GenericLogger,
JSONObject,
JSONValue,
} from '@aws-lambda-powertools/commons/types';
import type {
APIGatewayProxyEvent,
Expand Down Expand Up @@ -81,7 +81,7 @@ type ExtendedAPIGatewayProxyResult = Omit<APIGatewayProxyResult, 'body'> & {

type HandlerResponse =
| Response
| JSONObject
| JSONValue
| ExtendedAPIGatewayProxyResult
| BinaryResult;

Expand Down
73 changes: 55 additions & 18 deletions packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,68 @@ describe.each([
{ version: 'V1', createEvent: createTestEvent },
{ version: 'V2', createEvent: createTestEventV2 },
])('Class: Router - Basic Routing ($version)', ({ createEvent }) => {
it.each([
const httpMethods = [
['GET', 'get'],
['POST', 'post'],
['PUT', 'put'],
['PATCH', 'patch'],
['DELETE', 'delete'],
['HEAD', 'head'],
['OPTIONS', 'options'],
])('routes %s requests', async (method, verb) => {
// Prepare
const app = new Router();
(
app[verb as Lowercase<HttpMethod>] as (
path: string,
handler: RouteHandler
) => void
)('/test', async () => ({ result: `${verb}-test` }));
// Act
const actual = await app.resolve(createEvent('/test', method), context);
// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` }));
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
});
];
it.each(httpMethods)(
'routes %s requests with object response',
async (method, verb) => {
// Prepare
const app = new Router();
(
app[verb as Lowercase<HttpMethod>] as (
path: string,
handler: RouteHandler
) => void
)('/test', async () => ({ result: `${verb}-test` }));

// Act
const actual = await app.resolve(createEvent('/test', method), context);

// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` }));
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
}
);

it.each(httpMethods)(
'routes %s requests with array response',
async (method, verb) => {
// Prepare
const app = new Router();
(
app[verb as Lowercase<HttpMethod>] as (
path: string,
handler: RouteHandler
) => void
)('/test', async () => [
{ id: 1, result: `${verb}-test-1` },
{ id: 2, result: `${verb}-test-2` },
]);

// Act
const actual = await app.resolve(createEvent('/test', method), context);

// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(
JSON.stringify([
{ id: 1, result: `${verb}-test-1` },
{ id: 2, result: `${verb}-test-2` },
])
);
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
}
);

it.each([['CONNECT'], ['TRACE']])(
'throws MethodNotAllowedError for %s requests',
Expand Down
149 changes: 93 additions & 56 deletions packages/event-handler/tests/unit/rest/Router/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,74 +67,111 @@ const createStreamHandler =
(event: unknown, _context: Context, responseStream: MockResponseStream) =>
app.resolveStream(event, _context, { scope, responseStream });

const createTestLambdaClass = (app: Router, expectedResponse: unknown) => {
class Lambda {
@app.get('/test')
public getTest() {
return expectedResponse;
}

@app.post('/test')
public postTest() {
return expectedResponse;
}

@app.put('/test')
public putTest() {
return expectedResponse;
}

@app.patch('/test')
public patchTest() {
return expectedResponse;
}

@app.delete('/test')
public deleteTest() {
return expectedResponse;
}

@app.head('/test')
public headTest() {
return expectedResponse;
}

@app.options('/test')
public optionsTest() {
return expectedResponse;
}

public handler = createHandler(app);
}

return Lambda;
};

describe.each([
{ version: 'V1', createEvent: createTestEvent },
{ version: 'V2', createEvent: createTestEventV2 },
])('Class: Router - Decorators ($version)', ({ createEvent }) => {
describe('decorators', () => {
const app = new Router();

class Lambda {
@app.get('/test')
public getTest() {
return { result: 'get-test' };
}

@app.post('/test')
public postTest() {
return { result: 'post-test' };
}
const httpMethods = [
['GET', 'get'],
['POST', 'post'],
['PUT', 'put'],
['PATCH', 'patch'],
['DELETE', 'delete'],
['HEAD', 'head'],
['OPTIONS', 'options'],
];
it.each(httpMethods)(
'routes %s requests with object response',
async (method, verb) => {
// Prepare
const app = new Router();
const expected = { result: `${verb}-test` };
const Lambda = createTestLambdaClass(app, expected);
const lambda = new Lambda();

@app.put('/test')
public putTest() {
return { result: 'put-test' };
}
// Act
const actual = await lambda.handler(
createTestEvent('/test', method),
context
);

@app.patch('/test')
public patchTest() {
return { result: 'patch-test' };
// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(JSON.stringify(expected));
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
}
);

@app.delete('/test')
public deleteTest() {
return { result: 'delete-test' };
}
it.each(httpMethods)(
'routes %s requests with array response',
async (method, verb) => {
// Prepare
const app = new Router();
const expected = [
{ id: 1, result: `${verb}-test-1` },
{ id: 2, result: `${verb}-test-2` },
];
const Lambda = createTestLambdaClass(app, expected);
const lambda = new Lambda();

@app.head('/test')
public headTest() {
return { result: 'head-test' };
}
// Act
const actual = await lambda.handler(
createTestEvent('/test', method),
context
);

@app.options('/test')
public optionsTest() {
return { result: 'options-test' };
// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(JSON.stringify(expected));
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
}

public handler = createHandler(app);
}

it.each([
['GET', { result: 'get-test' }],
['POST', { result: 'post-test' }],
['PUT', { result: 'put-test' }],
['PATCH', { result: 'patch-test' }],
['DELETE', { result: 'delete-test' }],
['HEAD', { result: 'head-test' }],
['OPTIONS', { result: 'options-test' }],
])('routes %s requests with decorators', async (method, expected) => {
// Prepare
const lambda = new Lambda();
// Act
const actual = await lambda.handler(
createEvent('/test', method),
context
);
// Assess
expect(actual.statusCode).toBe(200);
expect(actual.body).toBe(JSON.stringify(expected));
expect(actual.headers?.['content-type']).toBe('application/json');
expect(actual.isBase64Encoded).toBe(false);
});
);
});

describe('decorators with middleware', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,28 @@ describe.each([
});
});

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

app.errorHandler(BadRequestError, async () => ['error1', 'error2']);

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

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

// Assess
expect(result).toEqual({
statusCode: HttpStatusCodes.INTERNAL_SERVER_ERROR,
body: JSON.stringify(['error1', 'error2']),
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();
Expand Down
Loading