diff --git a/packages/event-handler/src/rest/ErrorHandlerRegistry.ts b/packages/event-handler/src/rest/ErrorHandlerRegistry.ts index 2a68b72d53..3dd3bbc479 100644 --- a/packages/event-handler/src/rest/ErrorHandlerRegistry.ts +++ b/packages/event-handler/src/rest/ErrorHandlerRegistry.ts @@ -71,4 +71,21 @@ export class ErrorHandlerRegistry { return null; } + + /** + * Merges another {@link ErrorHandlerRegistry | `ErrorHandlerRegistry`} instance into the current instance. + * It takes the handlers from the provided registry and adds them to the current registry. + * + * Error handlers from the included router are merged with existing handlers. If handlers for the same error type exist in both routers, the included router's handler takes precedence. + * + * @param errorHandlerRegistry - The registry instance to merge with the current instance + */ + public merge(errorHandlerRegistry: ErrorHandlerRegistry): void { + for (const [ + errorConstructor, + errorHandler, + ] of errorHandlerRegistry.#handlers) { + this.register(errorConstructor, errorHandler); + } + } } diff --git a/packages/event-handler/src/rest/RouteHandlerRegistry.ts b/packages/event-handler/src/rest/RouteHandlerRegistry.ts index 0c338f141d..116b95b91e 100644 --- a/packages/event-handler/src/rest/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/rest/RouteHandlerRegistry.ts @@ -8,8 +8,12 @@ import type { ValidationResult, } from '../types/rest.js'; import { ParameterValidationError } from './errors.js'; -import type { Route } from './Route.js'; -import { compilePath, validatePathPattern } from './utils.js'; +import { Route } from './Route.js'; +import { + compilePath, + resolvePrefixedPath, + validatePathPattern, +} from './utils.js'; class RouteHandlerRegistry { readonly #staticRoutes: Map = new Map(); @@ -193,6 +197,36 @@ class RouteHandlerRegistry { return null; } + + /** + * Merges another {@link RouteHandlerRegistry | `RouteHandlerRegistry`} instance into the current instance. + * It takes the static and dynamic routes from the provided registry and adds them to the current registry. + * + * Routes from the included router are added to the current router's registry. If a route with the same method and path already exists, the included router's route takes precedence. + * + * @param routeHandlerRegistry - The registry instance to merge with the current instance + * @param options - Configuration options for merging the router + * @param options.prefix - An optional prefix to be added to the paths defined in the router + */ + public merge( + routeHandlerRegistry: RouteHandlerRegistry, + options?: { prefix: Path } + ): void { + const routes = [ + ...routeHandlerRegistry.#staticRoutes.values(), + ...routeHandlerRegistry.#dynamicRoutes, + ]; + for (const route of routes) { + this.register( + new Route( + route.method as HttpMethod, + resolvePrefixedPath(route.path, options?.prefix), + route.handler, + route.middleware + ) + ); + } + } } export { RouteHandlerRegistry }; diff --git a/packages/event-handler/src/rest/Router.ts b/packages/event-handler/src/rest/Router.ts index cbff596d09..8ce3f5e191 100644 --- a/packages/event-handler/src/rest/Router.ts +++ b/packages/event-handler/src/rest/Router.ts @@ -41,6 +41,7 @@ import { isAPIGatewayProxyEvent, isAPIGatewayProxyResult, isHttpMethod, + resolvePrefixedPath, } from './utils.js'; class Router { @@ -293,10 +294,7 @@ class Router { public route(handler: RouteHandler, options: RestRouteOptions): void { const { method, path, middleware = [] } = options; const methods = Array.isArray(method) ? method : [method]; - let resolvedPath = path; - if (this.prefix) { - resolvedPath = path === '/' ? this.prefix : `${this.prefix}${path}`; - } + const resolvedPath = resolvePrefixedPath(path, this.prefix); for (const method of methods) { this.routeRegistry.register( @@ -551,6 +549,50 @@ class Router { handler ); } + + /** + * Merges the routes, context and middleware from the passed router instance into this router instance. + * + * **Override Behaviors:** + * - **Context**: Properties from the included router override existing properties with the same key in the current router. A warning is logged when conflicts occur. + * - **Routes**: Routes from the included router are added to the current router's registry. If a route with the same method and path already exists, the included router's route takes precedence. + * - **Error Handlers**: Error handlers from the included router are merged with existing handlers. If handlers for the same error type exist in both routers, the included router's handler takes precedence. + * - **Middleware**: Middleware from the included router is appended to the current router's middleware array. All middleware executes in registration order (current router's middleware first, then included router's middleware). + * + * @example + * ```typescript + * import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest'; + * + * const todosRouter = new Router(); + * + * todosRouter.get('/todos', async () => { + * // List API + * }); + * + * todosRouter.get('/todos/{todoId}', async () => { + * // Get API + * }); + * + * const app = new Router(); + * app.includeRouter(todosRouter); + * + * export const handler = async (event: unknown, context: Context) => { + * return app.resolve(event, context); + * }; + * ``` + * @param router - The `Router` from which to merge the routes, context and middleware + * @param options - Configuration options for merging the router + * @param options.prefix - An optional prefix to be added to the paths defined in the router + */ + public includeRouter(router: Router, options?: { prefix: Path }): void { + this.context = { + ...this.context, + ...router.context, + }; + this.routeRegistry.merge(router.routeRegistry, options); + this.errorHandlerRegistry.merge(router.errorHandlerRegistry); + this.middleware.push(...router.middleware); + } } export { Router }; diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index e8fbd1e5d0..eb587a0b7d 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -196,3 +196,16 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { return result; }; }; + +/** + * Resolves a prefixed path by combining the provided path and prefix. + * + * @param path - The path to resolve + * @param prefix - The prefix to prepend to the path + */ +export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => { + if (prefix) { + return path === '/' ? prefix : `${prefix}${path}`; + } + return path; +}; diff --git a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts index d4dffadb44..a17ce1eb46 100644 --- a/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts +++ b/packages/event-handler/tests/unit/rest/Router/basic-routing.test.ts @@ -1,5 +1,5 @@ import context from '@aws-lambda-powertools/testing-utils/context'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { HttpStatusCodes, HttpVerbs, @@ -144,4 +144,52 @@ describe('Class: Router - Basic Routing', () => { expect(JSON.parse(createResult.body).actualPath).toBe('/todos'); expect(JSON.parse(getResult.body).actualPath).toBe('/todos/1'); }); + + it('routes to the included router when using split routers', async () => { + // Prepare + const todoRouter = new Router({ logger: console }); + todoRouter.use(async ({ next }) => { + console.log('todoRouter middleware'); + await next(); + }); + todoRouter.get('/', async () => ({ api: 'listTodos' })); + todoRouter.notFound(async () => { + return { + error: 'Route not found', + }; + }); + const consoleLogSpy = vi.spyOn(console, 'log'); + const consoleWarnSpy = vi.spyOn(console, 'warn'); + + const app = new Router(); + app.use(async ({ next }) => { + console.log('app middleware'); + await next(); + }); + app.get('/todos', async () => ({ api: 'rootTodos' })); + app.get('/', async () => ({ api: 'root' })); + app.includeRouter(todoRouter, { prefix: '/todos' }); + + // Act + const rootResult = await app.resolve(createTestEvent('/', 'GET'), context); + const listTodosResult = await app.resolve( + createTestEvent('/todos', 'GET'), + context + ); + const notFoundResult = await app.resolve( + createTestEvent('/non-existent', 'GET'), + context + ); + + // Assert + expect(JSON.parse(rootResult.body).api).toEqual('root'); + expect(JSON.parse(listTodosResult.body).api).toEqual('listTodos'); + expect(JSON.parse(notFoundResult.body).error).toEqual('Route not found'); + expect(consoleLogSpy).toHaveBeenNthCalledWith(1, 'app middleware'); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, 'todoRouter middleware'); + expect(consoleWarnSpy).toHaveBeenNthCalledWith( + 1, + 'Handler for method: GET and path: /todos already exists. The previous handler will be replaced.' + ); + }); }); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 5082eb6608..50de2f4f03 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -9,7 +9,11 @@ import { isAPIGatewayProxyEvent, isAPIGatewayProxyResult, } from '../../../src/rest/index.js'; -import { compilePath, validatePathPattern } from '../../../src/rest/utils.js'; +import { + compilePath, + resolvePrefixedPath, + validatePathPattern, +} from '../../../src/rest/utils.js'; import type { Middleware, Path, @@ -567,4 +571,18 @@ describe('Path Utilities', () => { expect(result).toBeUndefined(); }); }); + + describe('resolvePrefixedPath', () => { + it.each([ + { path: '/test', prefix: '/prefix', expected: '/prefix/test' }, + { path: '/', prefix: '/prefix', expected: '/prefix' }, + { path: '/test', expected: '/test' }, + ])('resolves prefixed path', ({ path, prefix, expected }) => { + // Prepare & Act + const resolvedPath = resolvePrefixedPath(path as Path, prefix as Path); + + // Assert + expect(resolvedPath).toBe(expected); + }); + }); });