Skip to content

Commit eef92ca

Browse files
arnabrahmansvozza
andauthored
fix(event-handler): allow event handler response to return array (#4725)
Co-authored-by: Stefano Vozza <svozza@amazon.com>
1 parent 52561a2 commit eef92ca

File tree

6 files changed

+282
-141
lines changed

6 files changed

+282
-141
lines changed

packages/event-handler/src/rest/Router.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Readable } from 'node:stream';
22
import { pipeline } from 'node:stream/promises';
33
import type streamWeb from 'node:stream/web';
4-
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
4+
import type {
5+
GenericLogger,
6+
JSONValue,
7+
} from '@aws-lambda-powertools/commons/types';
8+
import { isRecord } from '@aws-lambda-powertools/commons/typeutils';
59
import {
610
getStringFromEnv,
711
isDevMode,
@@ -457,19 +461,7 @@ class Router {
457461
) {
458462
return body;
459463
}
460-
if (!body.statusCode) {
461-
if (error instanceof NotFoundError) {
462-
body.statusCode = HttpStatusCodes.NOT_FOUND;
463-
} else if (error instanceof MethodNotAllowedError) {
464-
body.statusCode = HttpStatusCodes.METHOD_NOT_ALLOWED;
465-
}
466-
}
467-
return new Response(JSON.stringify(body), {
468-
status:
469-
(body.statusCode as number) ??
470-
HttpStatusCodes.INTERNAL_SERVER_ERROR,
471-
headers: { 'Content-Type': 'application/json' },
472-
});
464+
return this.#errorBodyToWebResponse(body, error);
473465
} catch (handlerError) {
474466
if (handlerError instanceof HttpError) {
475467
return await this.handleError(handlerError, options);
@@ -488,6 +480,48 @@ class Router {
488480
return this.#defaultErrorHandler(error);
489481
}
490482

483+
/**
484+
* Converts an error handler's response body to an HTTP Response object.
485+
*
486+
* If the body is a record object without a status code, sets the status code for
487+
* NotFoundError (404) or MethodNotAllowedError (405). Uses the status code from
488+
* the body if present, otherwise defaults to 500 Internal Server Error.
489+
*
490+
* @param body - The response body returned by the error handler, of type JSONValue
491+
* @param error - The Error object associated with the response
492+
*/
493+
#errorBodyToWebResponse(body: JSONValue, error: Error): Response {
494+
let status: number = HttpStatusCodes.INTERNAL_SERVER_ERROR;
495+
496+
if (isRecord(body)) {
497+
body.statusCode = body.statusCode ?? this.#getStatusCodeFromError(error);
498+
status = (body.statusCode as number) ?? status;
499+
}
500+
501+
return new Response(JSON.stringify(body), {
502+
status,
503+
headers: { 'Content-Type': 'application/json' },
504+
});
505+
}
506+
507+
/**
508+
* Extracts the HTTP status code from an error instance.
509+
*
510+
* Maps specific error types to their corresponding HTTP status codes:
511+
* - `NotFoundError` maps to 404 (NOT_FOUND)
512+
* - `MethodNotAllowedError` maps to 405 (METHOD_NOT_ALLOWED)
513+
*
514+
* @param error - The error instance to extract the status code from
515+
*/
516+
#getStatusCodeFromError(error: Error): number | undefined {
517+
if (error instanceof NotFoundError) {
518+
return HttpStatusCodes.NOT_FOUND;
519+
}
520+
if (error instanceof MethodNotAllowedError) {
521+
return HttpStatusCodes.METHOD_NOT_ALLOWED;
522+
}
523+
}
524+
491525
/**
492526
* Default error handler that returns a 500 Internal Server Error response.
493527
* In development mode, includes stack trace and error details.

packages/event-handler/src/types/rest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Readable } from 'node:stream';
22
import type {
33
GenericLogger,
4-
JSONObject,
4+
JSONValue,
55
} from '@aws-lambda-powertools/commons/types';
66
import type {
77
APIGatewayProxyEvent,
@@ -81,7 +81,7 @@ type ExtendedAPIGatewayProxyResult = Omit<APIGatewayProxyResult, 'body'> & {
8181

8282
type HandlerResponse =
8383
| Response
84-
| JSONObject
84+
| JSONValue
8585
| ExtendedAPIGatewayProxyResult
8686
| BinaryResult;
8787

packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,68 @@ describe.each([
1414
{ version: 'V1', createEvent: createTestEvent },
1515
{ version: 'V2', createEvent: createTestEventV2 },
1616
])('Class: Router - Basic Routing ($version)', ({ createEvent }) => {
17-
it.each([
17+
const httpMethods = [
1818
['GET', 'get'],
1919
['POST', 'post'],
2020
['PUT', 'put'],
2121
['PATCH', 'patch'],
2222
['DELETE', 'delete'],
2323
['HEAD', 'head'],
2424
['OPTIONS', 'options'],
25-
])('routes %s requests', async (method, verb) => {
26-
// Prepare
27-
const app = new Router();
28-
(
29-
app[verb as Lowercase<HttpMethod>] as (
30-
path: string,
31-
handler: RouteHandler
32-
) => void
33-
)('/test', async () => ({ result: `${verb}-test` }));
34-
// Act
35-
const actual = await app.resolve(createEvent('/test', method), context);
36-
// Assess
37-
expect(actual.statusCode).toBe(200);
38-
expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` }));
39-
expect(actual.headers?.['content-type']).toBe('application/json');
40-
expect(actual.isBase64Encoded).toBe(false);
41-
});
25+
];
26+
it.each(httpMethods)(
27+
'routes %s requests with object response',
28+
async (method, verb) => {
29+
// Prepare
30+
const app = new Router();
31+
(
32+
app[verb as Lowercase<HttpMethod>] as (
33+
path: string,
34+
handler: RouteHandler
35+
) => void
36+
)('/test', async () => ({ result: `${verb}-test` }));
37+
38+
// Act
39+
const actual = await app.resolve(createEvent('/test', method), context);
40+
41+
// Assess
42+
expect(actual.statusCode).toBe(200);
43+
expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` }));
44+
expect(actual.headers?.['content-type']).toBe('application/json');
45+
expect(actual.isBase64Encoded).toBe(false);
46+
}
47+
);
48+
49+
it.each(httpMethods)(
50+
'routes %s requests with array response',
51+
async (method, verb) => {
52+
// Prepare
53+
const app = new Router();
54+
(
55+
app[verb as Lowercase<HttpMethod>] as (
56+
path: string,
57+
handler: RouteHandler
58+
) => void
59+
)('/test', async () => [
60+
{ id: 1, result: `${verb}-test-1` },
61+
{ id: 2, result: `${verb}-test-2` },
62+
]);
63+
64+
// Act
65+
const actual = await app.resolve(createEvent('/test', method), context);
66+
67+
// Assess
68+
expect(actual.statusCode).toBe(200);
69+
expect(actual.body).toBe(
70+
JSON.stringify([
71+
{ id: 1, result: `${verb}-test-1` },
72+
{ id: 2, result: `${verb}-test-2` },
73+
])
74+
);
75+
expect(actual.headers?.['content-type']).toBe('application/json');
76+
expect(actual.isBase64Encoded).toBe(false);
77+
}
78+
);
4279

4380
it.each([['CONNECT'], ['TRACE']])(
4481
'throws MethodNotAllowedError for %s requests',

packages/event-handler/tests/unit/rest/Router/decorators.test.ts

Lines changed: 54 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
11
import context from '@aws-lambda-powertools/testing-utils/context';
2-
import type {
3-
APIGatewayProxyEvent,
4-
APIGatewayProxyEventV2,
5-
APIGatewayProxyResult,
6-
APIGatewayProxyStructuredResultV2,
7-
Context,
8-
} from 'aws-lambda';
92
import { describe, expect, it } from 'vitest';
103
import {
114
BadRequestError,
@@ -17,124 +10,79 @@ import {
1710
} from '../../../../src/rest/index.js';
1811
import type { RequestContext } from '../../../../src/types/rest.js';
1912
import {
13+
createHandler,
14+
createHandlerWithScope,
15+
createStreamHandler,
2016
createTestEvent,
2117
createTestEventV2,
18+
createTestLambdaClass,
2219
createTrackingMiddleware,
2320
MockResponseStream,
2421
parseStreamOutput,
2522
} from '../helpers.js';
2623

27-
const createHandler = (app: Router) => {
28-
function handler(
29-
event: APIGatewayProxyEvent,
30-
context: Context
31-
): Promise<APIGatewayProxyResult>;
32-
function handler(
33-
event: APIGatewayProxyEventV2,
34-
context: Context
35-
): Promise<APIGatewayProxyStructuredResultV2>;
36-
function handler(
37-
event: unknown,
38-
context: Context
39-
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2>;
40-
function handler(event: unknown, context: Context) {
41-
return app.resolve(event, context);
42-
}
43-
return handler;
44-
};
45-
46-
const createHandlerWithScope = (app: Router, scope: unknown) => {
47-
function handler(
48-
event: APIGatewayProxyEvent,
49-
context: Context
50-
): Promise<APIGatewayProxyResult>;
51-
function handler(
52-
event: APIGatewayProxyEventV2,
53-
context: Context
54-
): Promise<APIGatewayProxyStructuredResultV2>;
55-
function handler(
56-
event: unknown,
57-
context: Context
58-
): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2>;
59-
function handler(event: unknown, context: Context) {
60-
return app.resolve(event, context, { scope });
61-
}
62-
return handler;
63-
};
64-
65-
const createStreamHandler =
66-
(app: Router, scope: unknown) =>
67-
(event: unknown, _context: Context, responseStream: MockResponseStream) =>
68-
app.resolveStream(event, _context, { scope, responseStream });
69-
7024
describe.each([
7125
{ version: 'V1', createEvent: createTestEvent },
7226
{ version: 'V2', createEvent: createTestEventV2 },
7327
])('Class: Router - Decorators ($version)', ({ createEvent }) => {
7428
describe('decorators', () => {
75-
const app = new Router();
76-
77-
class Lambda {
78-
@app.get('/test')
79-
public getTest() {
80-
return { result: 'get-test' };
81-
}
82-
83-
@app.post('/test')
84-
public postTest() {
85-
return { result: 'post-test' };
86-
}
29+
const httpMethods = [
30+
['GET', 'get'],
31+
['POST', 'post'],
32+
['PUT', 'put'],
33+
['PATCH', 'patch'],
34+
['DELETE', 'delete'],
35+
['HEAD', 'head'],
36+
['OPTIONS', 'options'],
37+
];
38+
it.each(httpMethods)(
39+
'routes %s requests with object response',
40+
async (method, verb) => {
41+
// Prepare
42+
const app = new Router();
43+
const expected = { result: `${verb}-test` };
44+
const Lambda = createTestLambdaClass(app, expected);
45+
const lambda = new Lambda();
8746

88-
@app.put('/test')
89-
public putTest() {
90-
return { result: 'put-test' };
91-
}
47+
// Act
48+
const actual = await lambda.handler(
49+
createTestEvent('/test', method),
50+
context
51+
);
9252

93-
@app.patch('/test')
94-
public patchTest() {
95-
return { result: 'patch-test' };
53+
// Assess
54+
expect(actual.statusCode).toBe(200);
55+
expect(actual.body).toBe(JSON.stringify(expected));
56+
expect(actual.headers?.['content-type']).toBe('application/json');
57+
expect(actual.isBase64Encoded).toBe(false);
9658
}
59+
);
9760

98-
@app.delete('/test')
99-
public deleteTest() {
100-
return { result: 'delete-test' };
101-
}
61+
it.each(httpMethods)(
62+
'routes %s requests with array response',
63+
async (method, verb) => {
64+
// Prepare
65+
const app = new Router();
66+
const expected = [
67+
{ id: 1, result: `${verb}-test-1` },
68+
{ id: 2, result: `${verb}-test-2` },
69+
];
70+
const Lambda = createTestLambdaClass(app, expected);
71+
const lambda = new Lambda();
10272

103-
@app.head('/test')
104-
public headTest() {
105-
return { result: 'head-test' };
106-
}
73+
// Act
74+
const actual = await lambda.handler(
75+
createTestEvent('/test', method),
76+
context
77+
);
10778

108-
@app.options('/test')
109-
public optionsTest() {
110-
return { result: 'options-test' };
79+
// Assess
80+
expect(actual.statusCode).toBe(200);
81+
expect(actual.body).toBe(JSON.stringify(expected));
82+
expect(actual.headers?.['content-type']).toBe('application/json');
83+
expect(actual.isBase64Encoded).toBe(false);
11184
}
112-
113-
public handler = createHandler(app);
114-
}
115-
116-
it.each([
117-
['GET', { result: 'get-test' }],
118-
['POST', { result: 'post-test' }],
119-
['PUT', { result: 'put-test' }],
120-
['PATCH', { result: 'patch-test' }],
121-
['DELETE', { result: 'delete-test' }],
122-
['HEAD', { result: 'head-test' }],
123-
['OPTIONS', { result: 'options-test' }],
124-
])('routes %s requests with decorators', async (method, expected) => {
125-
// Prepare
126-
const lambda = new Lambda();
127-
// Act
128-
const actual = await lambda.handler(
129-
createEvent('/test', method),
130-
context
131-
);
132-
// Assess
133-
expect(actual.statusCode).toBe(200);
134-
expect(actual.body).toBe(JSON.stringify(expected));
135-
expect(actual.headers?.['content-type']).toBe('application/json');
136-
expect(actual.isBase64Encoded).toBe(false);
137-
});
85+
);
13886
});
13987

14088
describe('decorators with middleware', () => {

0 commit comments

Comments
 (0)