diff --git a/packages/event-handler/src/rest/Route.ts b/packages/event-handler/src/rest/Route.ts index e5690787b3..d5b2653198 100644 --- a/packages/event-handler/src/rest/Route.ts +++ b/packages/event-handler/src/rest/Route.ts @@ -1,4 +1,4 @@ -import type { Path, RouteHandler } from '../types/rest.js'; +import type { HttpMethod, Path, RouteHandler } from '../types/rest.js'; class Route { readonly id: string; @@ -6,9 +6,9 @@ class Route { readonly path: Path; readonly handler: RouteHandler; - constructor(method: string, path: Path, handler: RouteHandler) { + constructor(method: HttpMethod, path: Path, handler: RouteHandler) { this.id = `${method}:${path}`; - this.method = method.toUpperCase(); + this.method = method; this.path = path; this.handler = handler; } diff --git a/packages/event-handler/src/rest/RouteHandlerRegistry.ts b/packages/event-handler/src/rest/RouteHandlerRegistry.ts index 2ebd0b45c3..47e2eda1ac 100644 --- a/packages/event-handler/src/rest/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/rest/RouteHandlerRegistry.ts @@ -1,11 +1,21 @@ import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; -import type { RouteRegistryOptions } from '../types/rest.js'; +import type { + DynamicRoute, + HttpMethod, + Path, + RouteHandlerOptions, + RouteRegistryOptions, + ValidationResult, +} from '../types/rest.js'; +import { ParameterValidationError } from './errors.js'; import type { Route } from './Route.js'; -import { validatePathPattern } from './utils.js'; +import { compilePath, validatePathPattern } from './utils.js'; class RouteHandlerRegistry { - readonly #routes: Map = new Map(); - readonly #routesByMethod: Map = new Map(); + readonly #staticRoutes: Map = new Map(); + readonly #dynamicRoutesSet: Set = new Set(); + readonly #dynamicRoutes: DynamicRoute[] = []; + #shouldSort = true; readonly #logger: Pick; @@ -13,7 +23,72 @@ class RouteHandlerRegistry { this.#logger = options.logger; } + /** + * Compares two dynamic routes to determine their specificity order. + * Routes with fewer parameters and more path segments are considered more specific. + * @param a - First dynamic route to compare + * @param b - Second dynamic route to compare + * @returns Negative if a is more specific, positive if b is more specific, 0 if equal + */ + #compareRouteSpecificity(a: DynamicRoute, b: DynamicRoute): number { + // Routes with fewer parameters are more specific + const aParams = a.paramNames.length; + const bParams = b.paramNames.length; + + if (aParams !== bParams) { + return aParams - bParams; + } + + // Routes with more path segments are more specific + const aSegments = a.path.split('/').length; + const bSegments = b.path.split('/').length; + + return bSegments - aSegments; + } + /** + * Processes route parameters by URL-decoding their values. + * @param params - Raw parameter values extracted from the route path + * @returns Processed parameters with URL-decoded values + */ + #processParams(params: Record): Record { + const processed: Record = {}; + + for (const [key, value] of Object.entries(params)) { + processed[key] = decodeURIComponent(value); + } + + return processed; + } + /** + * Validates route parameters to ensure they are not empty or whitespace-only. + * @param params - Parameters to validate + * @returns Validation result with success status and any issues found + */ + #validateParams(params: Record): ValidationResult { + const issues: string[] = []; + + for (const [key, value] of Object.entries(params)) { + if (!value || value.trim() === '') { + issues.push(`Parameter '${key}' cannot be empty`); + } + } + + return { + isValid: issues.length === 0, + issues, + }; + } + /** + * Registers a route in the registry after validating its path pattern. + * + * The function decides whether to store the route in the static registry + * (for exact paths like `/users`) or dynamic registry (for parameterized + * paths like `/users/:id`) based on the compiled path analysis. + * + * @param route - The route to register + */ public register(route: Route): void { + this.#shouldSort = true; const { isValid, issues } = validatePathPattern(route.path); if (!isValid) { for (const issue of issues) { @@ -22,29 +97,96 @@ class RouteHandlerRegistry { return; } - if (this.#routes.has(route.id)) { - this.#logger.warn( - `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` - ); + const compiled = compilePath(route.path); + + if (compiled.isDynamic) { + const dynamicRoute = { + ...route, + ...compiled, + }; + if (this.#dynamicRoutesSet.has(route.id)) { + this.#logger.warn( + `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` + ); + // as dynamic routes are stored in an array, we can't rely on + // overwriting a key in a map like with static routes so have + // to manually manage overwriting them + const i = this.#dynamicRoutes.findIndex( + (oldRoute) => oldRoute.id === route.id + ); + this.#dynamicRoutes[i] = dynamicRoute; + } else { + this.#dynamicRoutes.push(dynamicRoute); + this.#dynamicRoutesSet.add(route.id); + } + } else { + if (this.#staticRoutes.has(route.id)) { + this.#logger.warn( + `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` + ); + } + this.#staticRoutes.set(route.id, route); } + } + /** + * Resolves a route handler for the given HTTP method and path. + * + * Static routes are checked first for exact matches. Dynamic routes are then + * checked in order of specificity (fewer parameters and more segments first). + * If no handler is found, it returns `null`. + * + * Examples of specificity (given registered routes `/users/:id` and `/users/:id/posts/:postId`): + * - For path `'/users/123/posts/456'`: + * - `/users/:id` matches but has fewer segments (2 vs 4) + * - `/users/:id/posts/:postId` matches and is more specific -> **selected** + * - For path `'/users/123'`: + * - `/users/:id` matches exactly -> **selected** + * - `/users/:id/posts/:postId` doesn't match (too many segments) + * + * @param method - The HTTP method to match + * @param path - The path to match + * @returns Route handler options or null if no match found + */ + public resolve(method: HttpMethod, path: Path): RouteHandlerOptions | null { + if (this.#shouldSort) { + this.#dynamicRoutes.sort(this.#compareRouteSpecificity); + this.#shouldSort = false; + } + const routeId = `${method}:${path}`; - this.#routes.set(route.id, route); + const staticRoute = this.#staticRoutes.get(routeId); + if (staticRoute != null) { + return { + handler: staticRoute.handler, + rawParams: {}, + params: {}, + }; + } - const routesByMethod = this.#routesByMethod.get(route.method) ?? []; - routesByMethod.push(route); - this.#routesByMethod.set(route.method, routesByMethod); - } + for (const route of this.#dynamicRoutes) { + if (route.method !== method) continue; - public getRouteCount(): number { - return this.#routes.size; - } + const match = route.regex.exec(path); + if (match?.groups) { + const params = match.groups; - public getRoutesByMethod(method: string): Route[] { - return this.#routesByMethod.get(method.toUpperCase()) || []; - } + const processedParams = this.#processParams(params); + + const validation = this.#validateParams(processedParams); + + if (!validation.isValid) { + throw new ParameterValidationError(validation.issues); + } + + return { + handler: route.handler, + params: processedParams, + rawParams: params, + }; + } + } - public getAllRoutes(): Route[] { - return Array.from(this.#routes.values()); + return null; } } diff --git a/packages/event-handler/src/rest/errors.ts b/packages/event-handler/src/rest/errors.ts new file mode 100644 index 0000000000..f72339d386 --- /dev/null +++ b/packages/event-handler/src/rest/errors.ts @@ -0,0 +1,17 @@ +export class RouteMatchingError extends Error { + constructor( + message: string, + public readonly path: string, + public readonly method: string + ) { + super(message); + this.name = 'RouteMatchingError'; + } +} + +export class ParameterValidationError extends RouteMatchingError { + constructor(public readonly issues: string[]) { + super(`Parameter validation failed: ${issues.join(', ')}`, '', ''); + this.name = 'ParameterValidationError'; + } +} diff --git a/packages/event-handler/src/rest/utils.ts b/packages/event-handler/src/rest/utils.ts index 5d79396d77..58abcc7338 100644 --- a/packages/event-handler/src/rest/utils.ts +++ b/packages/event-handler/src/rest/utils.ts @@ -12,7 +12,7 @@ export function compilePath(path: Path): CompiledRoute { const finalPattern = `^${regexPattern}$`; return { - originalPath: path, + path, regex: new RegExp(finalPattern), paramNames, isDynamic: paramNames.length > 0, diff --git a/packages/event-handler/src/types/rest.ts b/packages/event-handler/src/types/rest.ts index 9b9144f3ff..9665d2f08e 100644 --- a/packages/event-handler/src/types/rest.ts +++ b/packages/event-handler/src/types/rest.ts @@ -1,6 +1,7 @@ import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; import type { BaseRouter } from '../rest/BaseRouter.js'; import type { HttpVerbs } from '../rest/constants.js'; +import type { Route } from '../rest/Route.js'; /** * Options for the {@link BaseRouter} class @@ -15,12 +16,14 @@ type RouterOptions = { }; interface CompiledRoute { - originalPath: string; + path: Path; regex: RegExp; paramNames: string[]; isDynamic: boolean; } +type DynamicRoute = Route & CompiledRoute; + // biome-ignore lint/suspicious/noExplicitAny: we want to keep arguments and return types as any to accept any type of function type RouteHandler = (...args: T[]) => R; @@ -28,6 +31,12 @@ type HttpMethod = keyof typeof HttpVerbs; type Path = `/${string}`; +type RouteHandlerOptions = { + handler: RouteHandler; + params: Record; + rawParams: Record; +}; + type RouteOptions = { method: HttpMethod | HttpMethod[]; path: Path; @@ -49,11 +58,13 @@ type ValidationResult = { export type { CompiledRoute, + DynamicRoute, HttpMethod, Path, RouterOptions, RouteHandler, RouteOptions, + RouteHandlerOptions, RouteRegistryOptions, ValidationResult, }; diff --git a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts index 621a96f525..582139a8b5 100644 --- a/packages/event-handler/tests/unit/rest/BaseRouter.test.ts +++ b/packages/event-handler/tests/unit/rest/BaseRouter.test.ts @@ -5,6 +5,7 @@ import { BaseRouter } from '../../../src/rest/BaseRouter.js'; import { HttpVerbs } from '../../../src/rest/constants.js'; import type { HttpMethod, + Path, RouteHandler, RouterOptions, } from '../../../src/types/rest.js'; @@ -18,12 +19,16 @@ describe('Class: BaseRouter', () => { this.logger.error('test error'); } - #isEvent(obj: unknown): asserts obj is { path: string; method: string } { + #isEvent(obj: unknown): asserts obj is { path: Path; method: HttpMethod } { if ( typeof obj !== 'object' || obj === null || !('path' in obj) || - !('method' in obj) + !('method' in obj) || + typeof (obj as any).path !== 'string' || + !(obj as any).path.startsWith('/') || + typeof (obj as any).method !== 'string' || + !Object.values(HttpVerbs).includes((obj as any).method as HttpMethod) ) { throw new Error('Invalid event object'); } @@ -32,8 +37,7 @@ describe('Class: BaseRouter', () => { public resolve(event: unknown, context: Context): Promise { this.#isEvent(event); const { method, path } = event; - const routes = this.routeRegistry.getRoutesByMethod(method); - const route = routes.find((x) => x.path === path); + const route = this.routeRegistry.resolve(method, path); if (route == null) throw new Error('404'); return route.handler(event, context); } diff --git a/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts b/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts index ff5f8de8be..b49d1c5e5b 100644 --- a/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts +++ b/packages/event-handler/tests/unit/rest/RouteHandlerRegistry.test.ts @@ -5,30 +5,35 @@ import { RouteHandlerRegistry } from '../../../src/rest/RouteHandlerRegistry.js' import type { Path } from '../../../src/types/rest.js'; describe('Class: RouteHandlerRegistry', () => { - it('should warn when registering a duplicate route', () => { - // Prepare - const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; - const path = '/test'; - const method = HttpVerbs.GET; + it.each([ + { path: '/test', resolvePath: '/test', type: 'static' }, + { path: '/users/:id', resolvePath: '/users/123', type: 'dynamic' }, + ])( + 'logs a warning when registering a duplicate $type route', + ({ path, resolvePath }) => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler1 = () => 'first'; + const handler2 = () => 'second'; + const method = HttpVerbs.GET; - // Act - const route1 = new Route(method, path, handler); - registry.register(route1); + // Act + const route1 = new Route(method, path as Path, handler1); + registry.register(route1); - const route2 = new Route(method, path, () => 'another handler'); - registry.register(route2); + const route2 = new Route(method, path as Path, handler2); + registry.register(route2); - // Assert - expect(console.warn).toHaveBeenCalledWith( - `Handler for method: ${method} and path: ${path} already exists. The previous handler will be replaced.` - ); - expect(registry.getRouteCount()).toBe(1); + // Assess + expect(console.warn).toHaveBeenCalledWith( + `Handler for method: ${method} and path: ${path} already exists. The previous handler will be replaced.` + ); - const routes = registry.getAllRoutes(); - expect(routes).toHaveLength(1); - expect(routes[0]).toBe(route2); - }); + const result = registry.resolve(method, resolvePath as Path); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(handler2); + } + ); it.each([ { path: '/users/:id:', description: 'malformed parameter syntax' }, @@ -41,7 +46,7 @@ describe('Class: RouteHandlerRegistry', () => { description: 'consecutive parameters without separator', }, ])( - 'should not register routes with invalid path pattern: $description', + "doesn't register routes with invalid path pattern: $description", ({ path }) => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); @@ -52,17 +57,15 @@ describe('Class: RouteHandlerRegistry', () => { // Act registry.register(route); - // Assert + // Assess expect(console.warn).toHaveBeenCalledWith( 'Malformed parameter syntax. Use :paramName format.' ); - expect(registry.getRouteCount()).toBe(0); - expect(registry.getAllRoutes()).toHaveLength(0); - expect(registry.getRoutesByMethod(HttpVerbs.GET)).toHaveLength(0); + expect(registry.resolve(HttpVerbs.GET, '/users/123')).toBeNull(); } ); - it('should not register routes with duplicate parameter names', () => { + it("doesn't register routes with duplicate parameter names", () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); const handler = () => 'test'; @@ -74,122 +77,536 @@ describe('Class: RouteHandlerRegistry', () => { // Act registry.register(route); - // Assert + // Assess expect(console.warn).toHaveBeenCalledWith('Duplicate parameter names: id'); - expect(registry.getRouteCount()).toBe(0); // Route should not be registered - expect(registry.getAllRoutes()).toHaveLength(0); - expect(registry.getRoutesByMethod(HttpVerbs.GET)).toHaveLength(0); + expect(registry.resolve(HttpVerbs.GET, '/users/123/posts/456')).toBeNull(); + }); + + it('returns null when no route is found', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/users', handler)); + + // Assess + expect(registry.resolve(HttpVerbs.GET, '/posts')).toBeNull(); + expect(registry.resolve(HttpVerbs.POST, '/users')).toBeNull(); + expect(registry.resolve(HttpVerbs.GET, '/users/123')).toBeNull(); + }); + + it('skips dynamic routes with different HTTP methods', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const getHandler = () => 'get'; + const postHandler = () => 'post'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:id', getHandler)); + registry.register(new Route(HttpVerbs.POST, '/users/:id', postHandler)); + + // Assess + const getResult = registry.resolve(HttpVerbs.GET, '/users/123'); + expect(getResult).not.toBeNull(); + expect(getResult?.handler).toBe(getHandler); + + const postResult = registry.resolve(HttpVerbs.POST, '/users/123'); + expect(postResult).not.toBeNull(); + expect(postResult?.handler).toBe(postHandler); }); - describe('getRouteCount', () => { - it('returns 0 for empty registry', () => { + describe('#compareRouteSpecificity', () => { + it('handles routes of different specificity', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); + const generalHandler = () => 'general'; + const specificHandler = () => 'specific'; + const mostSpecificHandler = () => 'most-specific'; - // Act & Assert - expect(registry.getRouteCount()).toBe(0); + // Act + registry.register( + new Route(HttpVerbs.GET, '/:category/:id/:action', generalHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/:action', specificHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/profile', mostSpecificHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/123/profile'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(mostSpecificHandler); }); - it('returns correct count after registering routes', () => { + it('prioritizes static routes over dynamic routes', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); - const handler = () => 'test'; + const dynamicHandler = () => 'dynamic'; + const staticHandler = () => 'static'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:id', dynamicHandler)); + registry.register( + new Route(HttpVerbs.GET, '/users/profile', staticHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/profile'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(staticHandler); + }); + + it('prioritizes deeper paths over shallower ones', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const shallowHandler = () => 'shallow'; + const deepHandler = () => 'deep'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/api/:id', shallowHandler)); + registry.register(new Route(HttpVerbs.GET, '/api/v1/:id', deepHandler)); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/api/v1/123'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(deepHandler); + }); - // Act & Assert - registry.register(new Route(HttpVerbs.GET, '/users', handler)); - expect(registry.getRouteCount()).toBe(1); + it('prioritizes more specific segments over generic parameters', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const genericHandler = () => 'generic'; + const specificHandler = () => 'specific'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/:a/:b', genericHandler)); + registry.register( + new Route(HttpVerbs.GET, '/users/:id', specificHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/123'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(specificHandler); + }); + + it('prioritizes routes with fewer parameters', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const moreParamsHandler = () => 'more-params'; + const fewerParamsHandler = () => 'fewer-params'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/users/:id/:action', moreParamsHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/posts', fewerParamsHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/123/posts'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(fewerParamsHandler); + }); + + it('prioritizes static segments over parameters when parameter count differs', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const moreParamsHandler = () => 'more-params'; + const staticHandler = () => 'static'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/api/:service/:id/:action', moreParamsHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/api/users/123/:action', staticHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/api/users/123/delete'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(staticHandler); + }); - registry.register(new Route(HttpVerbs.POST, '/users', handler)); - expect(registry.getRouteCount()).toBe(2); + it('prioritizes more static segments in mixed routes', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const lessStaticHandler = () => 'less-static'; + const moreStaticHandler = () => 'more-static'; - registry.register(new Route(HttpVerbs.GET, '/posts', handler)); - expect(registry.getRouteCount()).toBe(3); + // Act + registry.register( + new Route(HttpVerbs.GET, '/api/:version/users/:id', lessStaticHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/api/v1/users/:id', moreStaticHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/api/v1/users/123'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(moreStaticHandler); + }); + + it('handles complex mixed static/dynamic precedence', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const allDynamicHandler = () => 'all-dynamic'; + const mixedHandler = () => 'mixed'; + const mostStaticHandler = () => 'most-static'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/:category/:id/settings', allDynamicHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/settings', mixedHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/profile/settings', mostStaticHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/profile/settings'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(mostStaticHandler); + }); + + it('maintains specificity regardless of registration order - specific first', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const specificHandler = () => 'specific'; + const generalHandler = () => 'general'; + + // Act - Register specific route first + registry.register( + new Route(HttpVerbs.GET, '/users/:id/profile', specificHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/:action', generalHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/123/profile'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(specificHandler); + }); + + it('maintains specificity regardless of registration order - general first', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const specificHandler = () => 'specific'; + const generalHandler = () => 'general'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/users/:id/:action', generalHandler) + ); + registry.register( + new Route(HttpVerbs.GET, '/users/:id/profile', specificHandler) + ); + + // Assess + const result = registry.resolve(HttpVerbs.GET, '/users/123/profile'); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(specificHandler); + }); + + it('handles root-level routes', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const rootHandler = () => 'root'; + const paramHandler = () => 'param'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/:id', paramHandler)); + registry.register(new Route(HttpVerbs.GET, '/', rootHandler)); + + // Assess + const rootResult = registry.resolve(HttpVerbs.GET, '/'); + expect(rootResult).not.toBeNull(); + expect(rootResult?.handler).toBe(rootHandler); + + const paramResult = registry.resolve(HttpVerbs.GET, '/123'); + expect(paramResult).not.toBeNull(); + expect(paramResult?.handler).toBe(paramHandler); + }); + + it('handles very long paths with mixed segments', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const longGenericHandler = () => 'long-generic'; + const longSpecificHandler = () => 'long-specific'; + + // Act + registry.register( + new Route( + HttpVerbs.GET, + '/api/:v1/:v2/:v3/:v4/:v5/data', + longGenericHandler + ) + ); + registry.register( + new Route( + HttpVerbs.GET, + '/api/v1/users/123/profile/settings/data', + longSpecificHandler + ) + ); + + // Assess + const result = registry.resolve( + HttpVerbs.GET, + '/api/v1/users/123/profile/settings/data' + ); + expect(result).not.toBeNull(); + expect(result?.handler).toBe(longSpecificHandler); }); }); - describe('getRoutesByMethod', () => { - it('returns empty array for non-existent method', () => { + describe('Parameter Processing', () => { + it('extracts single parameter correctly', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; - // Act & Assert - expect(registry.getRoutesByMethod('GET')).toEqual([]); - expect(registry.getRoutesByMethod('POST')).toEqual([]); + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); + const result = registry.resolve(HttpVerbs.GET, '/users/123'); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ id: '123' }); + expect(result?.rawParams).toEqual({ id: '123' }); + expect(result?.handler).toBe(handler); }); - it.each([ - { method: HttpVerbs.GET }, - { method: HttpVerbs.POST }, - { method: HttpVerbs.PUT }, - { method: HttpVerbs.DELETE }, - ])('returns routes for $method method', ({ method }) => { + it('extracts multiple parameters correctly', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); const handler = () => 'test'; - const route1 = new Route(method, '/users', handler); - const route2 = new Route(method, '/posts', handler); - const otherMethodRoute = new Route(HttpVerbs.PATCH, '/other', handler); + // Act + registry.register( + new Route(HttpVerbs.GET, '/users/:userId/posts/:postId', handler) + ); + const result = registry.resolve(HttpVerbs.GET, '/users/123/posts/456'); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ userId: '123', postId: '456' }); + expect(result?.rawParams).toEqual({ userId: '123', postId: '456' }); + expect(result?.handler).toBe(handler); + }); + + it('returns empty params for static routes', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; // Act - registry.register(route1); - registry.register(route2); - registry.register(otherMethodRoute); + registry.register(new Route(HttpVerbs.GET, '/users/profile', handler)); + const result = registry.resolve(HttpVerbs.GET, '/users/profile'); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({}); + expect(result?.rawParams).toEqual({}); + expect(result?.handler).toBe(handler); + }); + + it('decodes URL-encoded spaces in parameters', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/search/:query', handler)); + const result = registry.resolve(HttpVerbs.GET, '/search/hello%20world'); - // Assert - const routes = registry.getRoutesByMethod(method); - expect(routes).toHaveLength(2); - expect(routes).toContain(route1); - expect(routes).toContain(route2); - expect(routes).not.toContain(otherMethodRoute); + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ query: 'hello world' }); + expect(result?.rawParams).toEqual({ query: 'hello%20world' }); }); - it('handles case-insensitive method lookup', () => { + it('decodes URL-encoded special characters in parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); const handler = () => 'test'; - const getRoute = new Route(HttpVerbs.GET, '/users', handler); + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:email', handler)); + const result = registry.resolve( + HttpVerbs.GET, + '/users/user%40example.com' + ); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ email: 'user@example.com' }); + expect(result?.rawParams).toEqual({ email: 'user%40example.com' }); + }); + + it('decodes multiple URL-encoded parameters', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; // Act - registry.register(getRoute); + registry.register( + new Route(HttpVerbs.GET, '/files/:folder/:filename', handler) + ); + const result = registry.resolve( + HttpVerbs.GET, + '/files/my%20folder/test%2Bfile.txt' + ); - // Assert - expect(registry.getRoutesByMethod('get')).toContain(getRoute); - expect(registry.getRoutesByMethod('GET')).toContain(getRoute); - expect(registry.getRoutesByMethod('Get')).toContain(getRoute); + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ + folder: 'my folder', + filename: 'test+file.txt', + }); + expect(result?.rawParams).toEqual({ + folder: 'my%20folder', + filename: 'test%2Bfile.txt', + }); }); - }); - describe('getAllRoutes', () => { - it('returns empty array for empty registry', () => { + it('throws ParameterValidationError for whitespace-only parameters', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; - // Act & Assert - expect(registry.getAllRoutes()).toEqual([]); + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); + + // Assess + expect(() => { + registry.resolve(HttpVerbs.GET, '/users/%20%20%20'); + }).toThrow('Parameter validation failed'); }); - it('returns all registered routes', () => { + it('extracts parameters with complex route patterns', () => { // Prepare const registry = new RouteHandlerRegistry({ logger: console }); const handler = () => 'test'; - const route1 = new Route(HttpVerbs.GET, '/users', handler); - const route2 = new Route(HttpVerbs.POST, '/users', handler); - const route3 = new Route(HttpVerbs.GET, '/posts', handler); + // Act + registry.register( + new Route( + HttpVerbs.GET, + '/api/:version/users/:userId/posts/:postId/comments/:commentId', + handler + ) + ); + const result = registry.resolve( + HttpVerbs.GET, + '/api/v1/users/123/posts/456/comments/789' + ); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ + version: 'v1', + userId: '123', + postId: '456', + commentId: '789', + }); + expect(result?.rawParams).toEqual({ + version: 'v1', + userId: '123', + postId: '456', + commentId: '789', + }); + }); + + it('handles mixed parameter types and URL encoding', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; // Act - registry.register(route1); - registry.register(route2); - registry.register(route3); - - // Assert - const allRoutes = registry.getAllRoutes(); - expect(allRoutes).toHaveLength(3); - expect(allRoutes).toContain(route1); - expect(allRoutes).toContain(route2); - expect(allRoutes).toContain(route3); + registry.register( + new Route(HttpVerbs.GET, '/search/:category/:query/:page', handler) + ); + const result = registry.resolve( + HttpVerbs.GET, + '/search/electronics/C%2B%2B/1' + ); + + // Assess + expect(result).not.toBeNull(); + expect(result?.params).toEqual({ + category: 'electronics', + query: 'C++', + page: '1', + }); + expect(result?.rawParams).toEqual({ + category: 'electronics', + query: 'C%2B%2B', + page: '1', + }); + }); + + it('throws ParameterValidationError with correct error message for whitespace-only parameter', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; + + // Act + registry.register(new Route(HttpVerbs.GET, '/users/:id', handler)); + + // Assess + expect(() => { + registry.resolve(HttpVerbs.GET, '/users/%20'); + }).toThrow("Parameter 'id' cannot be empty"); + }); + + it('throws ParameterValidationError with multiple error messages for multiple invalid parameters', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/users/:userId/posts/:postId', handler) + ); + + // Assess + expect(() => { + registry.resolve(HttpVerbs.GET, '/users/%20/posts/%20%20'); + }).toThrow('Parameter validation failed'); + }); + + it('includes all validation issues in error message', () => { + // Prepare + const registry = new RouteHandlerRegistry({ logger: console }); + const handler = () => 'test'; + + // Act + registry.register( + new Route(HttpVerbs.GET, '/api/:version/:resource/:id', handler) + ); + + // Assess + expect(() => { + registry.resolve(HttpVerbs.GET, '/api/%20/users/%20'); + }).toThrow(); + + try { + registry.resolve(HttpVerbs.GET, '/api/%20/users/%20'); + } catch (error: any) { + expect(error.message).toContain('Parameter validation failed'); + expect(error.issues).toContain("Parameter 'version' cannot be empty"); + expect(error.issues).toContain("Parameter 'id' cannot be empty"); + } }); }); }); diff --git a/packages/event-handler/tests/unit/rest/utils.test.ts b/packages/event-handler/tests/unit/rest/utils.test.ts index 5f71837967..ea31eb68df 100644 --- a/packages/event-handler/tests/unit/rest/utils.test.ts +++ b/packages/event-handler/tests/unit/rest/utils.test.ts @@ -180,7 +180,7 @@ describe('Path Utilities', () => { const compiled = compilePath(path as Path); // Assert - expect(compiled.originalPath).toBe(path); + expect(compiled.path).toBe(path); expect(compiled.paramNames).toEqual(expectedParams); expect(compiled.isDynamic).toBe(expectedParams.length > 0);