From 27d2dc820945368a67ce84ffbb54380bab60c0e6 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 5 Sep 2025 10:11:59 +0100 Subject: [PATCH 1/2] feat(event-handler): add middleare registration and composition to rest handler --- packages/event-handler/src/rest/BaseRouter.ts | 26 +- packages/event-handler/src/rest/utils.ts | 39 ++ packages/event-handler/src/types/rest.ts | 9 + .../tests/unit/rest/BaseRouter.test.ts | 334 ++++++++++++++++++ 4 files changed, 405 insertions(+), 3 deletions(-) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index c21019476e..0082fef82d 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -10,6 +10,7 @@ import type { ErrorHandler, ErrorResolveOptions, HttpMethod, + Middleware, Path, RouteHandler, RouteOptions, @@ -30,13 +31,18 @@ import { } from './errors.js'; import { Route } from './Route.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; -import { isAPIGatewayProxyEvent, isHttpMethod } from './utils.js'; +import { + composeMiddleware, + isAPIGatewayProxyEvent, + isHttpMethod, +} from './utils.js'; abstract class BaseRouter { protected context: Record; protected readonly routeRegistry: RouteHandlerRegistry; protected readonly errorHandlerRegistry: ErrorHandlerRegistry; + protected readonly middlwares: Middleware[] = []; /** * A logger instance to be used for logging debug, warning, and error messages. @@ -140,6 +146,10 @@ abstract class BaseRouter { }; } + public use(middleware: Middleware): void { + this.middlwares.push(middleware); + } + /** * Resolves an API Gateway event by routing it to the appropriate handler * and converting the result to an API Gateway proxy result. Handles errors @@ -185,14 +195,24 @@ abstract class BaseRouter { throw new NotFoundError(`Route ${path} for method ${method} not found`); } - const result = await route.handler.apply(options?.scope ?? this, [ + const handler = + options?.scope != null + ? route.handler.bind(options.scope) + : route.handler; + + const middleware = composeMiddleware([...this.middlwares]); + + const result = await middleware( route.params, { event, context, request, }, - ]); + () => handler(route.params, { event, context, request }) + ); + + if (result === undefined) throw new InternalServerError(); return await handlerResultToProxyResult(result); } catch (error) { diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 57bf54197f..871f61c2de 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -2,8 +2,11 @@ import { isRecord, isString } from '@aws-lambda-powertools/commons/typeutils'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import type { CompiledRoute, + HandlerResponse, HttpMethod, + Middleware, Path, + RequestOptions, ValidationResult, } from '../types/rest.js'; import { @@ -105,3 +108,39 @@ export const isAPIGatewayProxyResult = ( typeof result.isBase64Encoded === 'boolean') ); }; + +export const composeMiddleware = (middlewares: Middleware[]): Middleware => { + return async ( + params: Record, + options: RequestOptions, + next: () => Promise + ): Promise => { + let index = -1; + let result: HandlerResponse | undefined; + + const dispatch = async (i: number): Promise => { + if (i <= index) throw new Error('next() called multiple times'); + index = i; + + if (i === middlewares.length) { + const nextResult = await next(); + if (nextResult !== undefined) { + result = nextResult; + } + return; + } + + const middleware = middlewares[i]; + const middlewareResult = await middleware(params, options, () => + dispatch(i + 1) + ); + + if (middlewareResult !== undefined) { + result = middlewareResult; + } + }; + + await dispatch(0); + return result; + }; +}; diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 7733dd9aac..8dcfd08018 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -77,6 +77,14 @@ type RouteOptions = { path: Path; }; +type NextFunction = () => Promise; + +type Middleware = ( + params: Record, + options: RequestOptions, + next: NextFunction +) => Promise; + type RouteRegistryOptions = { /** * A logger instance to be used for logging debug, warning, and error messages. @@ -111,6 +119,7 @@ export type { HandlerResponse, HttpStatusCode, HttpMethod, + Middleware, Path, RequestOptions, RouterOptions, diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 114b76ce25..e0350dff08 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -179,6 +179,340 @@ describe('Class: BaseRouter', () => { expect(logger.debug).toHaveBeenCalledWith('test debug'); }); + describe('middleware', () => { + it('executes middleware in order before route handler', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1-start'); + await next(); + executionOrder.push('middleware1-end'); + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2-start'); + await next(); + executionOrder.push('middleware2-end'); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(executionOrder).toEqual([ + 'middleware1-start', + 'middleware2-start', + 'handler', + 'middleware2-end', + 'middleware1-end', + ]); + }); + + it('allows middleware to short-circuit by returning Response', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1'); + await next(); + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2'); + return new Response('Short-circuited', { status: 401 }); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['middleware1', 'middleware2']); + expect(result?.statusCode).toBe(401); + expect(result?.body).toBe('Short-circuited'); + }); + + it('passes params and options to middleware', async () => { + // Prepare + const app = new TestResolver(); + let middlewareParams: Record | undefined; + let middlewareOptions: any; + + app.use(async (params, options, next) => { + middlewareParams = params; + middlewareOptions = options; + await next(); + }); + + app.get('/test/:id', async () => ({ success: true })); + + // Act + const testEvent = createTestEvent('/test/123', 'GET'); + await app.resolve(testEvent, context); + + // Assess + expect(middlewareParams).toEqual({ id: '123' }); + expect(middlewareOptions.event).toBe(testEvent); + expect(middlewareOptions.context).toBe(context); + expect(middlewareOptions.request).toBeInstanceOf(Request); + }); + + it('returns error response when next() is called multiple times', async () => { + // Prepare + vi.stubEnv('POWERTOOLS_DEV', 'true'); + const app = new TestResolver(); + + app.use(async (params, options, next) => { + await next(); + await next(); + }); + + app.get('/test', async () => ({ success: true })); + + // 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.message).toContain('next() called multiple times'); + }); + + it('handles errors thrown in middleware before next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1'); + throw new Error('Middleware error'); + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2'); + await next(); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['middleware1']); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles errors thrown in middleware after next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1-start'); + await next(); + executionOrder.push('middleware1-end'); + throw new Error('Cleanup error'); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'middleware1-start', + 'handler', + 'middleware1-end', + ]); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('propagates handler errors through middleware chain', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1-start'); + await next(); + executionOrder.push('middleware1-end'); + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2-start'); + await next(); + executionOrder.push('middleware2-end'); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + throw new Error('Handler error'); + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'middleware1-start', + 'middleware2-start', + 'handler', + ]); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles middleware not calling next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1'); + // Middleware doesn't call next() - should result in undefined + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2'); + await next(); + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['middleware1']); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles middleware returning JSON objects', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('middleware1'); + await next(); + }); + + app.use(async (params, options, next) => { + executionOrder.push('middleware2'); + return { statusCode: 202, message: 'Accepted by middleware' }; + }); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['middleware1', 'middleware2']); + expect(result?.statusCode).toBe(200); + const body = JSON.parse(result?.body ?? '{}'); + expect(body).toEqual({ + statusCode: 202, + message: 'Accepted by middleware', + }); + }); + + it('works with class decorators and preserves scope access', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + let middlewareScope: any; + + app.use(async (params, options, next) => { + executionOrder.push('middleware-start'); + await next(); + executionOrder.push('middleware-end'); + }); + + class Lambda { + public scope = 'class-scope'; + + @app.get('/test') + public async getTest() { + executionOrder.push('handler'); + middlewareScope = this.scope; + return { message: `${this.scope}: success` }; + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context, { scope: this }); + } + } + + const lambda = new Lambda(); + + const handler = lambda.handler.bind(lambda); + + // Act + const result = await handler(createTestEvent('/test', 'GET'), context); + + // Assess + expect(executionOrder).toEqual([ + 'middleware-start', + 'handler', + 'middleware-end', + ]); + expect(middlewareScope).toBe('class-scope'); + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ message: 'class-scope: success' }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); + }); + }); + describe('decorators', () => { const app = new TestResolver(); From 7c61fb9d21b733790eb29cccb14451f0b2ded3d5 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 5 Sep 2025 14:49:45 +0100 Subject: [PATCH 2/2] address PR comments --- packages/event-handler/src/rest/BaseRouter.ts | 27 +++++++++++++++ packages/event-handler/src/rest/utils.ts | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 0082fef82d..9966a06688 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -146,6 +146,29 @@ abstract class BaseRouter { }; } + /** + * Registers a global middleware function that will be executed for all routes. + * + * Global middleware executes before route-specific middleware and follows the onion model + * where middleware executes in registration order before `next()` and in reverse order after `next()`. + * + * @param middleware - The middleware function to register globally + * + * @example + * ```typescript + * const authMiddleware: Middleware = async (params, options, next) => { + * // Authentication logic + * if (!isAuthenticated(options.request)) { + * return new Response('Unauthorized', { status: 401 }); + * } + * await next(); + * // Cleanup or logging after request completion + * console.log('Request completed'); + * }; + * + * router.use(authMiddleware); + * ``` + */ public use(middleware: Middleware): void { this.middlwares.push(middleware); } @@ -212,6 +235,10 @@ abstract class BaseRouter { () => handler(route.params, { event, context, request }) ); + // 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(); return await handlerResultToProxyResult(result); diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 871f61c2de..712e1e2406 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -109,6 +109,40 @@ export const isAPIGatewayProxyResult = ( ); }; +/** + * Composes multiple middleware functions into a single middleware function. + * + * Middleware functions are executed in order, with each middleware having the ability + * to call `next()` to proceed to the next middleware in the chain. The composed middleware + * follows the onion model where middleware executes in order before `next()` and in + * reverse order after `next()`. + * + * @param middlewares - Array of middleware functions to compose + * @returns A single middleware function that executes all provided middlewares in sequence + * + * @example + * ```typescript + * const middleware1: Middleware = async (params, options, next) => { + * console.log('middleware1 start'); + * await next(); + * console.log('middleware1 end'); + * }; + * + * const middleware2: Middleware = async (params, options, next) => { + * console.log('middleware2 start'); + * await next(); + * console.log('middleware2 end'); + * }; + * + * const composed: Middleware = composeMiddleware([middleware1, middleware2]); + * // Execution order: + * // middleware1 start + * // -> middleware2 start + * // -> handler + * // -> middleware2 end + * // -> middleware1 end + * ``` + */ export const composeMiddleware = (middlewares: Middleware[]): Middleware => { return async ( params: Record,