From 3761ba0ad08bcd6962d55e465b99cd7b04b7482c Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 8 Sep 2025 15:14:21 +0100 Subject: [PATCH 1/2] feat(event-handler): implement mechanism to manipulate response in middleware --- packages/event-handler/src/rest/BaseRouter.ts | 54 +++--- packages/event-handler/src/rest/converters.ts | 53 +++++- packages/event-handler/src/rest/utils.ts | 4 +- packages/event-handler/src/types/rest.ts | 13 +- .../tests/unit/rest/BaseRouter.test.ts | 157 +++++++++++++++--- .../unit/rest/ErrorHandlerRegistry.test.ts | 4 +- .../tests/unit/rest/converters.test.ts | 119 ++++++++++++- .../tests/unit/rest/utils.test.ts | 117 ++++++++++++- 8 files changed, 456 insertions(+), 65 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 9f99f3557d..6cd88a8076 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -9,6 +9,7 @@ import type { ErrorConstructor, ErrorHandler, ErrorResolveOptions, + HandlerOptions, HttpMethod, Middleware, Path, @@ -19,6 +20,7 @@ import type { import { HttpErrorCodes, HttpVerbs } from './constants.js'; import { handlerResultToProxyResult, + handlerResultToResponse, proxyEventToWebRequest, responseToProxyResult, } from './converters.js'; @@ -209,6 +211,15 @@ abstract class BaseRouter { const request = proxyEventToWebRequest(event); + const handlerOptions: HandlerOptions = { + event, + context, + request, + // this response should be overwritten by the handler, if it isn't + // it means somthing went wrong with the middleware chain + res: new Response('', { status: 500 }), + }; + try { const path = new URL(request.url).pathname as Path; @@ -223,34 +234,36 @@ abstract class BaseRouter { ? route.handler.bind(options.scope) : route.handler; + const handlerMiddleware: Middleware = async (params, options, next) => { + const handlerResult = await handler(params, options); + options.res = handlerResultToResponse( + handlerResult, + options.res.headers + ); + + await next(); + }; + const middleware = composeMiddleware([ ...this.middleware, ...route.middleware, + handlerMiddleware, ]); - const result = await middleware( + const middlewareResult = await middleware( route.params, - { - event, - context, - request, - }, - () => handler(route.params, { event, context, request }) + handlerOptions, + () => Promise.resolve() ); - // In practice this we never happen because the final 'middleware' is - // the handler function that allways returns HandlerResponse. However, the - // type signature of of NextFunction includes undefined so we need this for - // the TS compiler - if (result === undefined) throw new InternalServerError(); + // middleware result takes precedence to allow short-circuiting + const result = middlewareResult ?? handlerOptions.res; - return await handlerResultToProxyResult(result); + return handlerResultToProxyResult(result); } catch (error) { this.logger.debug(`There was an error processing the request: ${error}`); const result = await this.handleError(error as Error, { - request, - event, - context, + ...handlerOptions, scope: options?.scope, }); return await responseToProxyResult(result); @@ -281,13 +294,10 @@ abstract class BaseRouter { const handler = this.errorHandlerRegistry.resolve(error); if (handler !== null) { try { - const body = await handler.apply(options.scope ?? this, [ + const { scope, ...handlerOptions } = options; + const body = await handler.apply(scope ?? this, [ error, - { - request: options.request, - event: options.event, - context: options.context, - }, + handlerOptions, ]); return new Response(JSON.stringify(body), { status: body.statusCode, diff --git a/packages/event-handler/src/rest/converters.ts b/packages/event-handler/src/rest/converters.ts index f5484e5f7d..2b6ba5527f 100644 --- a/packages/event-handler/src/rest/converters.ts +++ b/packages/event-handler/src/rest/converters.ts @@ -89,13 +89,60 @@ export const responseToProxyResult = async ( } } - return { + const result: APIGatewayProxyResult = { statusCode: response.status, headers, - multiValueHeaders, body: await response.text(), isBase64Encoded: false, }; + + if (Object.keys(multiValueHeaders).length > 0) { + result.multiValueHeaders = multiValueHeaders; + } + + return result; +}; + +/** + * Converts a handler response to a Web API Response object. + * Handles APIGatewayProxyResult, Response objects, and plain objects. + * + * @param response - The handler response (APIGatewayProxyResult, Response, or plain object) + * @param headers - Optional headers to be included in the response + * @returns A Web API Response object + */ +export const handlerResultToResponse = ( + response: HandlerResponse, + resHeaders?: Headers +): Response => { + if (response instanceof Response) { + return response; + } + + const headers = new Headers(resHeaders); + headers.set('Content-Type', 'application/json'); + + if (isAPIGatewayProxyResult(response)) { + for (const [key, value] of Object.entries(response.headers ?? {})) { + if (value != null) { + headers.set(key, String(value)); + } + } + + for (const [key, values] of Object.entries( + response.multiValueHeaders ?? {} + )) { + for (const value of values ?? []) { + headers.append(key, String(value)); + } + } + + return new Response(response.body, { + status: response.statusCode, + headers, + }); + } + return Response.json(response, { headers }); }; /** @@ -117,7 +164,7 @@ export const handlerResultToProxyResult = async ( return { statusCode: 200, body: JSON.stringify(response), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }; }; diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 5f04c7223c..211d131509 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -2,11 +2,11 @@ import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import type { CompiledRoute, + HandlerOptions, HandlerResponse, HttpMethod, Middleware, Path, - RequestOptions, ValidationResult, } from '../types/rest.js'; import { @@ -146,7 +146,7 @@ export const isAPIGatewayProxyResult = ( export const composeMiddleware = (middleware: Middleware[]): Middleware => { return async ( params: Record, - options: RequestOptions, + options: HandlerOptions, next: () => Promise ): Promise => { let index = -1; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 365a4cde8d..3d473c005d 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -14,17 +14,18 @@ type ErrorResponse = { message: string; }; -type RequestOptions = { +type HandlerOptions = { request: Request; event: APIGatewayProxyEvent; context: Context; + res: Response; }; -type ErrorResolveOptions = RequestOptions & ResolveOptions; +type ErrorResolveOptions = HandlerOptions & ResolveOptions; type ErrorHandler = ( error: T, - options: RequestOptions + options: HandlerOptions ) => Promise; interface ErrorConstructor { @@ -58,7 +59,7 @@ type HandlerResponse = Response | JSONObject; type RouteHandler< TParams = Record, TReturn = HandlerResponse, -> = (args: TParams, options: RequestOptions) => Promise; +> = (args: TParams, options: HandlerOptions) => Promise; type HttpMethod = keyof typeof HttpVerbs; @@ -83,7 +84,7 @@ type NextFunction = () => Promise; type Middleware = ( params: Record, - options: RequestOptions, + options: HandlerOptions, next: NextFunction ) => Promise; @@ -123,7 +124,7 @@ export type { HttpMethod, Middleware, Path, - RequestOptions, + HandlerOptions, RouterOptions, RouteHandler, RouteOptions, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index f14c2acbe3..4538d01cc2 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -10,10 +10,10 @@ import { type NotFoundError, } from '../../../src/rest/errors.js'; import type { + HandlerOptions, HttpMethod, Middleware, Path, - RequestOptions, RouteHandler, RouterOptions, } from '../../../src/types/rest.js'; @@ -62,7 +62,7 @@ describe('Class: BaseRouter', () => { expect(actual).toEqual({ statusCode: 200, body: JSON.stringify({ result: `${verb}-test` }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); @@ -106,7 +106,7 @@ describe('Class: BaseRouter', () => { const expectedResult = { statusCode: 200, body: JSON.stringify({ result: 'route-test' }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }; expect(getResult).toEqual(expectedResult); @@ -233,7 +233,7 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); let middlewareParams: Record | undefined; - let middlewareOptions: RequestOptions | undefined; + let middlewareOptions: HandlerOptions | undefined; app.use(async (params, options, next) => { middlewareParams = params; @@ -430,6 +430,129 @@ describe('Class: BaseRouter', () => { }); }); + it('allows middleware to manipulate response headers', async () => { + // Prepare + const app = new TestResolver(); + + app.use(async (params, options, next) => { + await next(); + options.res.headers.set('x-custom-header', 'middleware-value'); + options.res.headers.set('x-request-id', '12345'); + }); + + app.get('/test', async () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { + 'content-type': 'application/json', + 'x-custom-header': 'middleware-value', + 'x-request-id': '12345', + }, + isBase64Encoded: false, + }); + }); + + it('allows middleware to completely overwrite response', async () => { + // Prepare + const app = new TestResolver(); + + app.use(async (params, options, next) => { + await next(); + const originalBody = await options.res.text(); + options.res = new Response(`Modified: ${originalBody}`, { + headers: { 'content-type': 'text/plain' }, + }); + }); + + app.get('/test', async () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: 'Modified: {"success":true}', + headers: { 'content-type': 'text/plain' }, + isBase64Encoded: false, + }); + }); + + it('preserves headers set before calling next()', async () => { + // Prepare + const app = new TestResolver(); + + app.use(async (params, options, next) => { + options.res.headers.set('x-before-handler', 'middleware-value'); + await next(); + }); + + app.get('/test', async () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { + 'content-type': 'application/json', + 'x-before-handler': 'middleware-value', + }, + isBase64Encoded: false, + }); + }); + + it('overwrites headers when set later in the middleware stack', async () => { + // Prepare + const app = new TestResolver(); + + app.use(async (params, options, next) => { + options.res.headers.set('x-test-header', 'before-next'); + await next(); + }); + + app.use(async (params, options, next) => { + await next(); + options.res.headers.set('x-test-header', 'after-next'); + }); + + app.get('/test', async () => ({ success: true })); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { + 'content-type': 'application/json', + 'x-test-header': 'after-next', + }, + isBase64Encoded: false, + }); + }); + it('works with class decorators and preserves scope access', async () => { // Prepare const app = new TestResolver(); @@ -471,7 +594,7 @@ describe('Class: BaseRouter', () => { expect(result).toEqual({ statusCode: 200, body: JSON.stringify({ message: 'class-scope: success' }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); @@ -772,7 +895,7 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); let middlewareParams: Record | undefined; - let middlewareOptions: RequestOptions | undefined; + let middlewareOptions: HandlerOptions | undefined; const routeMiddleware: Middleware = async (params, options, next) => { middlewareParams = params; @@ -953,7 +1076,7 @@ describe('Class: BaseRouter', () => { expect(actual).toEqual({ statusCode: 200, body: JSON.stringify(expected), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); @@ -1000,7 +1123,7 @@ describe('Class: BaseRouter', () => { body: JSON.stringify({ result: 'class-scope: decorator-with-middleware', }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); @@ -1081,7 +1204,7 @@ describe('Class: BaseRouter', () => { expect(result).toEqual({ statusCode: 200, body: JSON.stringify(expected), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); } @@ -1119,7 +1242,6 @@ describe('Class: BaseRouter', () => { message: 'Handled: test error', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1149,7 +1271,6 @@ describe('Class: BaseRouter', () => { message: 'Custom: Route /nonexistent for method GET not found', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1183,7 +1304,6 @@ describe('Class: BaseRouter', () => { message: 'Custom: POST not allowed', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1271,7 +1391,6 @@ describe('Class: BaseRouter', () => { message: 'Specific handler', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1299,7 +1418,6 @@ describe('Class: BaseRouter', () => { message: 'service error', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1394,7 +1512,6 @@ describe('Class: BaseRouter', () => { message: 'Array handler: bad request', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }; const expectedMethodResult = { @@ -1405,7 +1522,6 @@ describe('Class: BaseRouter', () => { message: 'Array handler: method not allowed', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }; @@ -1448,7 +1564,6 @@ describe('Class: BaseRouter', () => { message: 'second: test error', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1520,7 +1635,6 @@ describe('Class: BaseRouter', () => { message: 'Decorated: test error', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1561,7 +1675,6 @@ describe('Class: BaseRouter', () => { message: 'Decorated: Route /nonexistent for method GET not found', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1607,7 +1720,6 @@ describe('Class: BaseRouter', () => { message: 'Decorated: POST not allowed', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1653,7 +1765,6 @@ describe('Class: BaseRouter', () => { message: 'scoped: test error', }), headers: { 'content-type': 'application/json' }, - multiValueHeaders: {}, isBase64Encoded: false, }); }); @@ -1814,7 +1925,7 @@ describe('Class: BaseRouter', () => { body: JSON.stringify({ message: 'scoped: success', }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); @@ -1847,7 +1958,7 @@ describe('Class: BaseRouter', () => { expect(result).toEqual({ statusCode: 200, body: JSON.stringify({ success: true }), - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json' }, isBase64Encoded: false, }); }); diff --git a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts index 6ac852b176..6bf35c663a 100644 --- a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import { HttpErrorCodes } from '../../../src/rest/constants.js'; import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js'; import type { + HandlerOptions, HttpStatusCode, - RequestOptions, } from '../../../src/types/rest.js'; const createErrorHandler = (statusCode: HttpStatusCode, message?: string) => - async (error: Error, _options: RequestOptions) => ({ + async (error: Error, _options: HandlerOptions) => ({ statusCode, error: error.name, message: message ?? error.message, diff --git a/packages/event-handler/tests/unit/rest/converters.test.ts b/packages/event-handler/tests/unit/rest/converters.test.ts index 130a3dd1a6..e864a9d9e2 100644 --- a/packages/event-handler/tests/unit/rest/converters.test.ts +++ b/packages/event-handler/tests/unit/rest/converters.test.ts @@ -2,6 +2,7 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { handlerResultToProxyResult, + handlerResultToResponse, proxyEventToWebRequest, responseToProxyResult, } from '../../../src/rest/converters.js'; @@ -109,7 +110,7 @@ describe('Converters', () => { const request = proxyEventToWebRequest(event); expect(request).toBeInstanceOf(Request); expect(request.method).toBe('POST'); - expect(await request.text()).toBe('{"key":"value"}'); + expect(request.text()).resolves.toBe('{"key":"value"}'); expect(request.headers.get('Content-Type')).toBe('application/json'); }); @@ -126,7 +127,7 @@ describe('Converters', () => { const request = proxyEventToWebRequest(event); expect(request).toBeInstanceOf(Request); - expect(await request.text()).toBe(originalText); + expect(request.text()).resolves.toBe(originalText); }); it('handles single-value headers', () => { @@ -315,7 +316,7 @@ describe('Converters', () => { const response = new Response('Hello World', { status: 200, headers: { - 'Content-type': 'application/json', + 'content-type': 'application/json', }, }); @@ -325,7 +326,6 @@ describe('Converters', () => { expect(result.body).toBe('Hello World'); expect(result.isBase64Encoded).toBe(false); expect(result.headers).toEqual({ 'content-type': 'application/json' }); - expect(result.multiValueHeaders).toEqual({}); }); it('handles single-value headers', async () => { @@ -341,7 +341,6 @@ describe('Converters', () => { 'content-type': 'text/plain', 'x-custom': 'value', }); - expect(result.multiValueHeaders).toEqual({}); }); it('handles multi-value headers', async () => { @@ -430,8 +429,116 @@ describe('Converters', () => { expect(result.statusCode).toBe(200); expect(result.body).toBe(JSON.stringify(obj)); - expect(result.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(result.headers).toEqual({ 'content-type': 'application/json' }); expect(result.isBase64Encoded).toBe(false); }); }); + + describe('handlerResultToResponse', () => { + it('returns Response object as-is', () => { + const response = new Response('Hello', { status: 201 }); + + const result = handlerResultToResponse(response); + + expect(result).toBe(response); + }); + + it('converts APIGatewayProxyResult to Response', async () => { + const proxyResult = { + statusCode: 201, + body: 'Hello World', + headers: { 'content-type': 'text/plain' }, + isBase64Encoded: false, + }; + + const result = handlerResultToResponse(proxyResult); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(201); + expect(await result.text()).toBe('Hello World'); + expect(result.headers.get('content-type')).toBe('text/plain'); + }); + + it('converts APIGatewayProxyResult with multiValueHeaders', async () => { + const proxyResult = { + statusCode: 200, + body: 'test', + headers: { 'content-type': 'application/json' }, + multiValueHeaders: { + 'Set-Cookie': ['cookie1=value1', 'cookie2=value2'], + }, + isBase64Encoded: false, + }; + + const result = handlerResultToResponse(proxyResult); + + expect(result.headers.get('content-type')).toBe('application/json'); + expect(result.headers.get('Set-Cookie')).toBe( + 'cookie1=value1, cookie2=value2' + ); + }); + + it('converts plain object to JSON Response with default headers', async () => { + const obj = { message: 'success' }; + + const result = handlerResultToResponse(obj); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + expect(result.text()).resolves.toBe(JSON.stringify(obj)); + expect(result.headers.get('Content-Type')).toBe('application/json'); + }); + + it('uses provided headers for plain object', async () => { + const obj = { message: 'success' }; + const headers = new Headers({ 'x-custom': 'value' }); + + const result = handlerResultToResponse(obj, headers); + + expect(result.headers.get('Content-Type')).toBe('application/json'); + expect(result.headers.get('x-custom')).toBe('value'); + }); + + it('handles APIGatewayProxyResult with undefined headers', async () => { + const proxyResult = { + statusCode: 200, + body: 'test', + headers: undefined, + isBase64Encoded: false, + }; + + const result = handlerResultToResponse(proxyResult); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + }); + + it('handles APIGatewayProxyResult with undefined multiValueHeaders', async () => { + const proxyResult = { + statusCode: 200, + body: 'test', + headers: { 'content-type': 'text/plain' }, + multiValueHeaders: undefined, + isBase64Encoded: false, + }; + + const result = handlerResultToResponse(proxyResult); + + expect(result.headers.get('content-type')).toBe('text/plain'); + }); + + it('handles APIGatewayProxyResult with undefined values in multiValueHeaders', async () => { + const proxyResult = { + statusCode: 200, + body: 'test', + headers: { 'content-type': 'text/plain' }, + multiValueHeaders: { 'Set-Cookie': undefined }, + isBase64Encoded: false, + }; + + const result = handlerResultToResponse(proxyResult); + + expect(result.headers.get('content-type')).toBe('text/plain'); + }); + }); }); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 2a2f981f39..85ad40ecf4 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -2,11 +2,16 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { compilePath, + composeMiddleware, isAPIGatewayProxyEvent, isAPIGatewayProxyResult, validatePathPattern, } from '../../../src/rest/utils.js'; -import type { Path } from '../../../src/types/rest.js'; +import type { + HandlerOptions, + Middleware, + Path, +} from '../../../src/types/rest.js'; describe('Path Utilities', () => { describe('validatePathPattern', () => { @@ -363,4 +368,114 @@ describe('Path Utilities', () => { expect(isAPIGatewayProxyResult(incompleteResult)).toBe(false); }); }); + + describe('composeMiddleware', () => { + const mockOptions: HandlerOptions = { + event: {} as APIGatewayProxyEvent, + context: {} as any, + request: new Request('https://example.com'), + res: new Response(), + }; + + it('executes middleware in order', async () => { + const executionOrder: string[] = []; + const middleware: Middleware[] = [ + async (_params, _options, next) => { + executionOrder.push('middleware1-start'); + await next(); + executionOrder.push('middleware1-end'); + }, + async (_params, _options, next) => { + executionOrder.push('middleware2-start'); + await next(); + executionOrder.push('middleware2-end'); + }, + ]; + + const composed = composeMiddleware(middleware); + await composed({}, mockOptions, async () => { + executionOrder.push('handler'); + }); + + expect(executionOrder).toEqual([ + 'middleware1-start', + 'middleware2-start', + 'handler', + 'middleware2-end', + 'middleware1-end', + ]); + }); + + it('returns result from middleware that short-circuits', async () => { + const middleware: Middleware[] = [ + async (_params, _options, next) => { + await next(); + }, + async (_params, _options, _next) => { + return { shortCircuit: true }; + }, + ]; + + const composed = composeMiddleware(middleware); + const result = await composed({}, mockOptions, async () => { + return { handler: true }; + }); + + expect(result).toEqual({ shortCircuit: true }); + }); + + it('returns result from next function when middleware does not return', async () => { + const middleware: Middleware[] = [ + async (_params, _options, next) => { + await next(); + }, + ]; + + const composed = composeMiddleware(middleware); + const result = await composed({}, mockOptions, async () => { + return { handler: true }; + }); + + expect(result).toEqual({ handler: true }); + }); + + it('throws error when next() is called multiple times', async () => { + const middleware: Middleware[] = [ + async (_params, _options, next) => { + await next(); + await next(); + }, + ]; + + const composed = composeMiddleware(middleware); + + await expect(composed({}, mockOptions, async () => {})).rejects.toThrow( + 'next() called multiple times' + ); + }); + + it('handles empty middleware array', async () => { + const composed = composeMiddleware([]); + const result = await composed({}, mockOptions, async () => { + return { handler: true }; + }); + + expect(result).toEqual({ handler: true }); + }); + + it('returns undefined when next function returns undefined', async () => { + const middleware: Middleware[] = [ + async (_params, _options, next) => { + await next(); + }, + ]; + + const composed = composeMiddleware(middleware); + const result = await composed({}, mockOptions, async () => { + return undefined; + }); + + expect(result).toBeUndefined(); + }); + }); }); From e9c09e1926ca49eb33a468e6e3457764171edb6a Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 8 Sep 2025 18:16:22 +0100 Subject: [PATCH 2/2] rename HandlerOptions to RequestContext --- packages/event-handler/src/rest/BaseRouter.ts | 4 ++-- packages/event-handler/src/rest/utils.ts | 4 ++-- packages/event-handler/src/types/rest.ts | 12 ++++++------ .../event-handler/tests/unit/rest/BaseRouter.test.ts | 6 +++--- .../tests/unit/rest/ErrorHandlerRegistry.test.ts | 4 ++-- packages/event-handler/tests/unit/rest/utils.test.ts | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 6cd88a8076..a9e8236d39 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -9,10 +9,10 @@ import type { ErrorConstructor, ErrorHandler, ErrorResolveOptions, - HandlerOptions, HttpMethod, Middleware, Path, + RequestContext, RouteHandler, RouteOptions, RouterOptions, @@ -211,7 +211,7 @@ abstract class BaseRouter { const request = proxyEventToWebRequest(event); - const handlerOptions: HandlerOptions = { + const handlerOptions: RequestContext = { event, context, request, diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 211d131509..8293043877 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -2,11 +2,11 @@ import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import type { CompiledRoute, - HandlerOptions, HandlerResponse, HttpMethod, Middleware, Path, + RequestContext, ValidationResult, } from '../types/rest.js'; import { @@ -146,7 +146,7 @@ export const isAPIGatewayProxyResult = ( export const composeMiddleware = (middleware: Middleware[]): Middleware => { return async ( params: Record, - options: HandlerOptions, + options: RequestContext, next: () => Promise ): Promise => { let index = -1; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 3d473c005d..0f96bff889 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -14,18 +14,18 @@ type ErrorResponse = { message: string; }; -type HandlerOptions = { +type RequestContext = { request: Request; event: APIGatewayProxyEvent; context: Context; res: Response; }; -type ErrorResolveOptions = HandlerOptions & ResolveOptions; +type ErrorResolveOptions = RequestContext & ResolveOptions; type ErrorHandler = ( error: T, - options: HandlerOptions + options: RequestContext ) => Promise; interface ErrorConstructor { @@ -59,7 +59,7 @@ type HandlerResponse = Response | JSONObject; type RouteHandler< TParams = Record, TReturn = HandlerResponse, -> = (args: TParams, options: HandlerOptions) => Promise; +> = (args: TParams, options: RequestContext) => Promise; type HttpMethod = keyof typeof HttpVerbs; @@ -84,7 +84,7 @@ type NextFunction = () => Promise; type Middleware = ( params: Record, - options: HandlerOptions, + options: RequestContext, next: NextFunction ) => Promise; @@ -124,7 +124,7 @@ export type { HttpMethod, Middleware, Path, - HandlerOptions, + RequestContext, RouterOptions, RouteHandler, RouteOptions, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 4538d01cc2..a68ac045b9 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -10,10 +10,10 @@ import { type NotFoundError, } from '../../../src/rest/errors.js'; import type { - HandlerOptions, HttpMethod, Middleware, Path, + RequestContext, RouteHandler, RouterOptions, } from '../../../src/types/rest.js'; @@ -233,7 +233,7 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); let middlewareParams: Record | undefined; - let middlewareOptions: HandlerOptions | undefined; + let middlewareOptions: RequestContext | undefined; app.use(async (params, options, next) => { middlewareParams = params; @@ -895,7 +895,7 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); let middlewareParams: Record | undefined; - let middlewareOptions: HandlerOptions | undefined; + let middlewareOptions: RequestContext | undefined; const routeMiddleware: Middleware = async (params, options, next) => { middlewareParams = params; diff --git a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts index 6bf35c663a..64aea416fe 100644 --- a/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/rest/ErrorHandlerRegistry.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import { HttpErrorCodes } from '../../../src/rest/constants.js'; import { ErrorHandlerRegistry } from '../../../src/rest/ErrorHandlerRegistry.js'; import type { - HandlerOptions, HttpStatusCode, + RequestContext, } from '../../../src/types/rest.js'; const createErrorHandler = (statusCode: HttpStatusCode, message?: string) => - async (error: Error, _options: HandlerOptions) => ({ + async (error: Error, _options: RequestContext) => ({ statusCode, error: error.name, message: message ?? error.message, diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 85ad40ecf4..dd9fefa047 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -8,9 +8,9 @@ import { validatePathPattern, } from '../../../src/rest/utils.js'; import type { - HandlerOptions, Middleware, Path, + RequestContext, } from '../../../src/types/rest.js'; describe('Path Utilities', () => { @@ -370,7 +370,7 @@ describe('Path Utilities', () => { }); describe('composeMiddleware', () => { - const mockOptions: HandlerOptions = { + const mockOptions: RequestContext = { event: {} as APIGatewayProxyEvent, context: {} as any, request: new Request('https://example.com'),