diff --git a/packages/event-handler/src/rest/BaseRouter.ts b/packages/event-handler/src/rest/BaseRouter.ts index 9966a06688..9f99f3557d 100644 --- a/packages/event-handler/src/rest/BaseRouter.ts +++ b/packages/event-handler/src/rest/BaseRouter.ts @@ -42,7 +42,7 @@ abstract class BaseRouter { protected readonly routeRegistry: RouteHandlerRegistry; protected readonly errorHandlerRegistry: ErrorHandlerRegistry; - protected readonly middlwares: Middleware[] = []; + protected readonly middleware: Middleware[] = []; /** * A logger instance to be used for logging debug, warning, and error messages. @@ -170,7 +170,7 @@ abstract class BaseRouter { * ``` */ public use(middleware: Middleware): void { - this.middlwares.push(middleware); + this.middleware.push(middleware); } /** @@ -223,7 +223,10 @@ abstract class BaseRouter { ? route.handler.bind(options.scope) : route.handler; - const middleware = composeMiddleware([...this.middlwares]); + const middleware = composeMiddleware([ + ...this.middleware, + ...route.middleware, + ]); const result = await middleware( route.params, @@ -255,11 +258,11 @@ abstract class BaseRouter { } public route(handler: RouteHandler, options: RouteOptions): void { - const { method, path } = options; + const { method, path, middleware = [] } = options; const methods = Array.isArray(method) ? method : [method]; for (const method of methods) { - this.routeRegistry.register(new Route(method, path, handler)); + this.routeRegistry.register(new Route(method, path, handler, middleware)); } } @@ -333,10 +336,26 @@ abstract class BaseRouter { #handleHttpMethod( method: HttpMethod, path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, handler?: RouteHandler ): MethodDecorator | undefined { - if (handler && typeof handler === 'function') { - this.route(handler, { method, path }); + if (Array.isArray(middlewareOrHandler)) { + if (handler && typeof handler === 'function') { + this.route(handler, { method, path, middleware: middlewareOrHandler }); + return; + } + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + this.route(descriptor.value, { + method, + path, + middleware: middlewareOrHandler, + }); + return descriptor; + }; + } + + if (middlewareOrHandler && typeof middlewareOrHandler === 'function') { + this.route(middlewareOrHandler, { method, path }); return; } @@ -347,54 +366,142 @@ abstract class BaseRouter { } public get(path: Path, handler: RouteHandler): void; + public get(path: Path, middleware: Middleware[], handler: RouteHandler): void; public get(path: Path): MethodDecorator; - public get(path: Path, handler?: RouteHandler): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.GET, path, handler); + public get(path: Path, middleware: Middleware[]): MethodDecorator; + public get( + path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, + handler?: RouteHandler + ): MethodDecorator | undefined { + return this.#handleHttpMethod( + HttpVerbs.GET, + path, + middlewareOrHandler, + handler + ); } public post(path: Path, handler: RouteHandler): void; + public post( + path: Path, + middleware: Middleware[], + handler: RouteHandler + ): void; public post(path: Path): MethodDecorator; - public post(path: Path, handler?: RouteHandler): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.POST, path, handler); + public post(path: Path, middleware: Middleware[]): MethodDecorator; + public post( + path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, + handler?: RouteHandler + ): MethodDecorator | undefined { + return this.#handleHttpMethod( + HttpVerbs.POST, + path, + middlewareOrHandler, + handler + ); } public put(path: Path, handler: RouteHandler): void; + public put(path: Path, middleware: Middleware[], handler: RouteHandler): void; public put(path: Path): MethodDecorator; - public put(path: Path, handler?: RouteHandler): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.PUT, path, handler); + public put(path: Path, middleware: Middleware[]): MethodDecorator; + public put( + path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, + handler?: RouteHandler + ): MethodDecorator | undefined { + return this.#handleHttpMethod( + HttpVerbs.PUT, + path, + middlewareOrHandler, + handler + ); } public patch(path: Path, handler: RouteHandler): void; + public patch( + path: Path, + middleware: Middleware[], + handler: RouteHandler + ): void; public patch(path: Path): MethodDecorator; + public patch(path: Path, middleware: Middleware[]): MethodDecorator; public patch( path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, handler?: RouteHandler ): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.PATCH, path, handler); + return this.#handleHttpMethod( + HttpVerbs.PATCH, + path, + middlewareOrHandler, + handler + ); } public delete(path: Path, handler: RouteHandler): void; + public delete( + path: Path, + middleware: Middleware[], + handler: RouteHandler + ): void; public delete(path: Path): MethodDecorator; + public delete(path: Path, middleware: Middleware[]): MethodDecorator; public delete( path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, handler?: RouteHandler ): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.DELETE, path, handler); + return this.#handleHttpMethod( + HttpVerbs.DELETE, + path, + middlewareOrHandler, + handler + ); } public head(path: Path, handler: RouteHandler): void; + public head( + path: Path, + middleware: Middleware[], + handler: RouteHandler + ): void; public head(path: Path): MethodDecorator; - public head(path: Path, handler?: RouteHandler): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.HEAD, path, handler); + public head(path: Path, middleware: Middleware[]): MethodDecorator; + public head( + path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, + handler?: RouteHandler + ): MethodDecorator | undefined { + return this.#handleHttpMethod( + HttpVerbs.HEAD, + path, + middlewareOrHandler, + handler + ); } public options(path: Path, handler: RouteHandler): void; + public options( + path: Path, + middleware: Middleware[], + handler: RouteHandler + ): void; public options(path: Path): MethodDecorator; + public options(path: Path, middleware: Middleware[]): MethodDecorator; public options( path: Path, + middlewareOrHandler?: Middleware[] | RouteHandler, handler?: RouteHandler ): MethodDecorator | undefined { - return this.#handleHttpMethod(HttpVerbs.OPTIONS, path, handler); + return this.#handleHttpMethod( + HttpVerbs.OPTIONS, + path, + middlewareOrHandler, + handler + ); } } diff --git a/packages/event-handler/src/rest/Route.ts b/packages/event-handler/src/rest/Route.ts index d5b2653198..a65c4ab50f 100644 --- a/packages/event-handler/src/rest/Route.ts +++ b/packages/event-handler/src/rest/Route.ts @@ -1,16 +1,28 @@ -import type { HttpMethod, Path, RouteHandler } from '../types/rest.js'; +import type { + HttpMethod, + Middleware, + Path, + RouteHandler, +} from '../types/rest.js'; class Route { readonly id: string; readonly method: string; readonly path: Path; readonly handler: RouteHandler; + readonly middleware: Middleware[]; - constructor(method: HttpMethod, path: Path, handler: RouteHandler) { + constructor( + method: HttpMethod, + path: Path, + handler: RouteHandler, + middleware: Middleware[] = [] + ) { this.id = `${method}:${path}`; this.method = method; this.path = path; this.handler = handler; + this.middleware = middleware; } } diff --git a/packages/event-handler/src/rest/RouteHandlerRegistry.ts b/packages/event-handler/src/rest/RouteHandlerRegistry.ts index 47e2eda1ac..971fe51e33 100644 --- a/packages/event-handler/src/rest/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/rest/RouteHandlerRegistry.ts @@ -160,6 +160,7 @@ class RouteHandlerRegistry { handler: staticRoute.handler, rawParams: {}, params: {}, + middleware: staticRoute.middleware, }; } @@ -182,6 +183,7 @@ class RouteHandlerRegistry { handler: route.handler, params: processedParams, rawParams: params, + middleware: route.middleware, }; } } diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 712e1e2406..5f04c7223c 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -117,8 +117,8 @@ export const isAPIGatewayProxyResult = ( * 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 + * @param middleware - Array of middleware functions to compose + * @returns A single middleware function that executes all provided middleware in sequence * * @example * ```typescript @@ -143,7 +143,7 @@ export const isAPIGatewayProxyResult = ( * // -> middleware1 end * ``` */ -export const composeMiddleware = (middlewares: Middleware[]): Middleware => { +export const composeMiddleware = (middleware: Middleware[]): Middleware => { return async ( params: Record, options: RequestOptions, @@ -156,7 +156,7 @@ export const composeMiddleware = (middlewares: Middleware[]): Middleware => { if (i <= index) throw new Error('next() called multiple times'); index = i; - if (i === middlewares.length) { + if (i === middleware.length) { const nextResult = await next(); if (nextResult !== undefined) { result = nextResult; @@ -164,8 +164,8 @@ export const composeMiddleware = (middlewares: Middleware[]): Middleware => { return; } - const middleware = middlewares[i]; - const middlewareResult = await middleware(params, options, () => + const middlewareFn = middleware[i]; + const middlewareResult = await middlewareFn(params, options, () => dispatch(i + 1) ); diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 8dcfd08018..365a4cde8d 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -70,11 +70,13 @@ type RouteHandlerOptions = { handler: RouteHandler; params: Record; rawParams: Record; + middleware: Middleware[]; }; type RouteOptions = { method: HttpMethod | HttpMethod[]; path: Path; + middleware?: Middleware[]; }; type NextFunction = () => Promise; diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index e0350dff08..f14c2acbe3 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -1,5 +1,5 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import type { Context } from 'aws-lambda'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseRouter } from '../../../src/rest/BaseRouter.js'; import { HttpErrorCodes, HttpVerbs } from '../../../src/rest/constants.js'; @@ -11,31 +11,19 @@ import { } from '../../../src/rest/errors.js'; import type { HttpMethod, + Middleware, + Path, + RequestOptions, RouteHandler, RouterOptions, } from '../../../src/types/rest.js'; - -const createTestEvent = ( - path: string, - httpMethod: string -): APIGatewayProxyEvent => ({ - path, - httpMethod, - headers: {}, - body: null, - multiValueHeaders: {}, - isBase64Encoded: false, - pathParameters: null, - queryStringParameters: null, - multiValueQueryStringParameters: null, - stageVariables: null, - requestContext: { - httpMethod, - path, - domainName: 'localhost', - } as any, - resource: '', -}); +import { + createNoNextMiddleware, + createReturningMiddleware, + createTestEvent, + createThrowingMiddleware, + createTrackingMiddleware, +} from './helpers.js'; describe('Class: BaseRouter', () => { class TestResolver extends BaseRouter { @@ -179,23 +167,14 @@ describe('Class: BaseRouter', () => { expect(logger.debug).toHaveBeenCalledWith('test debug'); }); - describe('middleware', () => { + describe('middleware - global', () => { 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.use(createTrackingMiddleware('middleware1', executionOrder)); + app.use(createTrackingMiddleware('middleware2', executionOrder)); app.get('/test', async () => { executionOrder.push('handler'); @@ -220,15 +199,14 @@ describe('Class: BaseRouter', () => { 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.use(createTrackingMiddleware('middleware1', executionOrder)); + app.use( + createReturningMiddleware( + 'middleware2', + executionOrder, + new Response('Short-circuited', { status: 401 }) + ) + ); app.get('/test', async () => { executionOrder.push('handler'); @@ -242,7 +220,11 @@ describe('Class: BaseRouter', () => { ); // Assess - expect(executionOrder).toEqual(['middleware1', 'middleware2']); + expect(executionOrder).toEqual([ + 'middleware1-start', + 'middleware2', + 'middleware1-end', + ]); expect(result?.statusCode).toBe(401); expect(result?.body).toBe('Short-circuited'); }); @@ -251,7 +233,7 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); let middlewareParams: Record | undefined; - let middlewareOptions: any; + let middlewareOptions: RequestOptions | undefined; app.use(async (params, options, next) => { middlewareParams = params; @@ -267,9 +249,9 @@ describe('Class: BaseRouter', () => { // Assess expect(middlewareParams).toEqual({ id: '123' }); - expect(middlewareOptions.event).toBe(testEvent); - expect(middlewareOptions.context).toBe(context); - expect(middlewareOptions.request).toBeInstanceOf(Request); + 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 () => { @@ -301,15 +283,14 @@ describe('Class: BaseRouter', () => { 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.use( + createThrowingMiddleware( + 'middleware1', + executionOrder, + 'Middleware error' + ) + ); + app.use(createTrackingMiddleware('middleware2', executionOrder)); app.get('/test', async () => { executionOrder.push('handler'); @@ -322,7 +303,7 @@ describe('Class: BaseRouter', () => { context ); - // Assess + // Assesss expect(executionOrder).toEqual(['middleware1']); expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); }); @@ -364,17 +345,8 @@ describe('Class: BaseRouter', () => { 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.use(createTrackingMiddleware('middleware1', executionOrder)); + app.use(createTrackingMiddleware('middleware2', executionOrder)); app.get('/test', async () => { executionOrder.push('handler'); @@ -401,15 +373,8 @@ describe('Class: BaseRouter', () => { 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.use(createNoNextMiddleware('middleware1', executionOrder)); + app.use(createTrackingMiddleware('middleware2', executionOrder)); app.get('/test', async () => { executionOrder.push('handler'); @@ -432,15 +397,13 @@ describe('Class: BaseRouter', () => { 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.use(createTrackingMiddleware('middleware1', executionOrder)); + app.use( + createReturningMiddleware('middleware2', executionOrder, { + statusCode: 202, + message: 'Accepted by middleware', + }) + ); app.get('/test', async () => { executionOrder.push('handler'); @@ -454,7 +417,11 @@ describe('Class: BaseRouter', () => { ); // Assess - expect(executionOrder).toEqual(['middleware1', 'middleware2']); + expect(executionOrder).toEqual([ + 'middleware1-start', + 'middleware2', + 'middleware1-end', + ]); expect(result?.statusCode).toBe(200); const body = JSON.parse(result?.body ?? '{}'); expect(body).toEqual({ @@ -467,7 +434,6 @@ describe('Class: BaseRouter', () => { // Prepare const app = new TestResolver(); const executionOrder: string[] = []; - let middlewareScope: any; app.use(async (params, options, next) => { executionOrder.push('middleware-start'); @@ -481,7 +447,6 @@ describe('Class: BaseRouter', () => { @app.get('/test') public async getTest() { executionOrder.push('handler'); - middlewareScope = this.scope; return { message: `${this.scope}: success` }; } @@ -503,7 +468,6 @@ describe('Class: BaseRouter', () => { 'handler', 'middleware-end', ]); - expect(middlewareScope).toBe('class-scope'); expect(result).toEqual({ statusCode: 200, body: JSON.stringify({ message: 'class-scope: success' }), @@ -513,6 +477,418 @@ describe('Class: BaseRouter', () => { }); }); + describe('middleware - route specific', () => { + it('executes route-specific middleware after global middleware', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + const routeMiddleware = createTrackingMiddleware( + 'route-middleware', + executionOrder + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'route-middleware-start', + 'handler', + 'route-middleware-end', + 'global-middleware-end', + ]); + }); + + it('executes multiple route-specific middleware in order', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + const routeMiddleware1 = createTrackingMiddleware( + 'route-middleware-1', + executionOrder + ); + const routeMiddleware2 = createTrackingMiddleware( + 'route-middleware-2', + executionOrder + ); + + app.get('/test', [routeMiddleware1, routeMiddleware2], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'route-middleware-1-start', + 'route-middleware-2-start', + 'handler', + 'route-middleware-2-end', + 'route-middleware-1-end', + 'global-middleware-end', + ]); + }); + + it('routes without middleware only run global middleware', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + + app.get('/test', async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + await app.resolve(createTestEvent('/test', 'GET'), context); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'handler', + 'global-middleware-end', + ]); + }); + + it('allows route middleware to short-circuit and skip handler', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + const routeMiddleware = createReturningMiddleware( + 'route-middleware', + executionOrder, + new Response('Route middleware response', { status: 403 }) + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'route-middleware', + 'global-middleware-end', + ]); + expect(result?.statusCode).toBe(403); + expect(result?.body).toBe('Route middleware response'); + }); + + it.each([ + { + path: '/auth', + middlewareNames: ['auth-middleware'], + expectedOrder: ['global-middleware', 'auth-middleware', 'handler'], + }, + { + path: '/admin', + middlewareNames: ['auth-middleware', 'admin-middleware'], + expectedOrder: [ + 'global-middleware', + 'auth-middleware', + 'admin-middleware', + 'handler', + ], + }, + { + path: '/public', + middlewareNames: [], + expectedOrder: ['global-middleware', 'handler'], + }, + ])( + 'different routes can have different middleware: $path', + async ({ path, middlewareNames, expectedOrder }) => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(async (params, options, next) => { + executionOrder.push('global-middleware'); + await next(); + }); + + const middleware: Middleware[] = middlewareNames.map( + (name) => async (params, options, next) => { + executionOrder.push(name); + await next(); + } + ); + + app.get(path as Path, middleware, async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + await app.resolve(createTestEvent(path, 'GET'), context); + + // Assess + expect(executionOrder).toEqual(expectedOrder); + } + ); + + it('handles errors thrown in route middleware before next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + const routeMiddleware = createThrowingMiddleware( + 'route-middleware', + executionOrder, + 'Route middleware error' + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['route-middleware']); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles errors thrown in route middleware after next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + const routeMiddleware: Middleware = async (params, options, next) => { + executionOrder.push('route-middleware-start'); + await next(); + executionOrder.push('route-middleware-end'); + throw new Error('Route cleanup error'); + }; + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'route-middleware-start', + 'handler', + 'route-middleware-end', + ]); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles route middleware not calling next()', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + const routeMiddleware = createNoNextMiddleware( + 'route-middleware', + executionOrder + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['route-middleware']); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles route middleware returning JSON objects', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + const routeMiddleware = createReturningMiddleware( + 'route-middleware', + executionOrder, + { statusCode: 202, message: 'Accepted by route middleware' } + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['route-middleware']); + expect(result?.statusCode).toBe(200); + const body = JSON.parse(result?.body ?? '{}'); + expect(body).toEqual({ + statusCode: 202, + message: 'Accepted by route middleware', + }); + }); + + it('passes params and options to route middleware', async () => { + // Prepare + const app = new TestResolver(); + let middlewareParams: Record | undefined; + let middlewareOptions: RequestOptions | undefined; + + const routeMiddleware: Middleware = async (params, options, next) => { + middlewareParams = params; + middlewareOptions = options; + await next(); + }; + + app.get('/test/:id', [routeMiddleware], 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('propagates errors through mixed global and route middleware', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + const routeMiddleware = createTrackingMiddleware( + 'route-middleware', + executionOrder + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + throw new Error('Handler error'); + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'route-middleware-start', + 'handler', + ]); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles errors when global middleware throws before route middleware', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use( + createThrowingMiddleware( + 'global-middleware', + executionOrder, + 'Global middleware error' + ) + ); + const routeMiddleware = createTrackingMiddleware( + 'route-middleware', + executionOrder + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual(['global-middleware']); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + + it('handles errors when route middleware throws with global middleware present', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + + app.use(createTrackingMiddleware('global-middleware', executionOrder)); + const routeMiddleware = createThrowingMiddleware( + 'route-middleware', + executionOrder, + 'Route middleware error' + ); + + app.get('/test', [routeMiddleware], async () => { + executionOrder.push('handler'); + return { success: true }; + }); + + // Act + const result = await app.resolve( + createTestEvent('/test', 'GET'), + context + ); + + // Assess + expect(executionOrder).toEqual([ + 'global-middleware-start', + 'route-middleware', + ]); + expect(result?.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR); + }); + }); + describe('decorators', () => { const app = new TestResolver(); @@ -583,6 +959,135 @@ describe('Class: BaseRouter', () => { }); }); + describe('decorators with middleware', () => { + it('executes middleware with decorator syntax', async () => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + const middleware = createTrackingMiddleware( + 'decorator-middleware', + executionOrder + ); + + class Lambda { + public scope = 'class-scope'; + + @app.get('/test', [middleware]) + public async getTest() { + executionOrder.push('handler'); + return { result: `${this.scope}: decorator-with-middleware` }; + } + + 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([ + 'decorator-middleware-start', + 'handler', + 'decorator-middleware-end', + ]); + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ + result: 'class-scope: decorator-with-middleware', + }), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); + }); + + it.each([ + ['GET', { result: 'get-decorator-middleware' }], + ['POST', { result: 'post-decorator-middleware' }], + ['PUT', { result: 'put-decorator-middleware' }], + ['PATCH', { result: 'patch-decorator-middleware' }], + ['DELETE', { result: 'delete-decorator-middleware' }], + ['HEAD', { result: 'head-decorator-middleware' }], + ['OPTIONS', { result: 'options-decorator-middleware' }], + ])( + 'routes %s requests with decorator middleware', + async (method, expected) => { + // Prepare + const app = new TestResolver(); + const executionOrder: string[] = []; + const middleware = createTrackingMiddleware( + `${method.toLowerCase()}-middleware`, + executionOrder + ); + + class Lambda { + @app.get('/test', [middleware]) + public async getTest() { + return { result: 'get-decorator-middleware' }; + } + + @app.post('/test', [middleware]) + public async postTest() { + return { result: 'post-decorator-middleware' }; + } + + @app.put('/test', [middleware]) + public async putTest() { + return { result: 'put-decorator-middleware' }; + } + + @app.patch('/test', [middleware]) + public async patchTest() { + return { result: 'patch-decorator-middleware' }; + } + + @app.delete('/test', [middleware]) + public async deleteTest() { + return { result: 'delete-decorator-middleware' }; + } + + @app.head('/test', [middleware]) + public async headTest() { + return { result: 'head-decorator-middleware' }; + } + + @app.options('/test', [middleware]) + public async optionsTest() { + return { result: 'options-decorator-middleware' }; + } + + public async handler(event: unknown, context: Context) { + return app.resolve(event, context); + } + } + + const lambda = new Lambda(); + + // Act + const result = await lambda.handler( + createTestEvent('/test', method), + context + ); + + // Assess + expect(executionOrder).toEqual([ + `${method.toLowerCase()}-middleware-start`, + `${method.toLowerCase()}-middleware-end`, + ]); + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify(expected), + headers: { 'Content-Type': 'application/json' }, + isBase64Encoded: false, + }); + } + ); + }); + describe('error handling', () => { it('calls registered error handler when BadRequestError is thrown', async () => { // Prepare diff --git a/packages/event-handler/tests/unit/rest/helpers.ts b/packages/event-handler/tests/unit/rest/helpers.ts new file mode 100644 index 0000000000..4c846d3e81 --- /dev/null +++ b/packages/event-handler/tests/unit/rest/helpers.ts @@ -0,0 +1,67 @@ +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import type { Middleware } from '../../../src/types/rest.js'; + +export const createTestEvent = ( + path: string, + httpMethod: string +): APIGatewayProxyEvent => ({ + path, + httpMethod, + headers: {}, + body: null, + multiValueHeaders: {}, + isBase64Encoded: false, + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + httpMethod, + path, + domainName: 'localhost', + } as any, + resource: '', +}); + +export const createTrackingMiddleware = ( + name: string, + executionOrder: string[] +): Middleware => { + return async (_params, _options, next) => { + executionOrder.push(`${name}-start`); + await next(); + executionOrder.push(`${name}-end`); + }; +}; + +export const createThrowingMiddleware = ( + name: string, + executionOrder: string[], + errorMessage: string +): Middleware => { + return async (_params, _options, _next) => { + executionOrder.push(name); + throw new Error(errorMessage); + }; +}; + +export const createReturningMiddleware = ( + name: string, + executionOrder: string[], + response: any +): Middleware => { + return async (_params, _options, _next) => { + executionOrder.push(name); + return response; + }; +}; + +export const createNoNextMiddleware = ( + name: string, + executionOrder: string[] +): Middleware => { + return async (_params, _options, _next) => { + executionOrder.push(name); + // Intentionally doesn't call next() + }; +};