Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/event-handler/src/rest/ErrorHandlerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
38 changes: 36 additions & 2 deletions packages/event-handler/src/rest/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Route> = new Map();
Expand Down Expand Up @@ -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 };
50 changes: 46 additions & 4 deletions packages/event-handler/src/rest/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
isAPIGatewayProxyEvent,
isAPIGatewayProxyResult,
isHttpMethod,
resolvePrefixedPath,
} from './utils.js';

class Router {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 };
13 changes: 13 additions & 0 deletions packages/event-handler/src/rest/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.'
);
});
});
20 changes: 19 additions & 1 deletion packages/event-handler/tests/unit/rest/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});