11import type { GenericLogger } from '@aws-lambda-powertools/commons/types' ;
2- import type { RouteRegistryOptions } from '../types/rest.js' ;
2+ import type {
3+ DynamicRoute ,
4+ HttpMethod ,
5+ Path ,
6+ RouteHandlerOptions ,
7+ RouteRegistryOptions ,
8+ ValidationResult ,
9+ } from '../types/rest.js' ;
10+ import { ParameterValidationError } from './errors.js' ;
311import type { Route } from './Route.js' ;
4- import { validatePathPattern } from './utils.js' ;
12+ import { compilePath , validatePathPattern } from './utils.js' ;
513
614class RouteHandlerRegistry {
7- readonly #routes: Map < string , Route > = new Map ( ) ;
8- readonly #routesByMethod: Map < string , Route [ ] > = new Map ( ) ;
15+ readonly #staticRoutes: Map < string , Route > = new Map ( ) ;
16+ readonly #dynamicRoutesSet: Set < string > = new Set ( ) ;
17+ readonly #dynamicRoutes: DynamicRoute [ ] = [ ] ;
18+ #shouldSort = true ;
919
1020 readonly #logger: Pick < GenericLogger , 'debug' | 'warn' | 'error' > ;
1121
1222 constructor ( options : RouteRegistryOptions ) {
1323 this . #logger = options . logger ;
1424 }
1525
26+ /**
27+ * Compares two dynamic routes to determine their specificity order.
28+ * Routes with fewer parameters and more path segments are considered more specific.
29+ * @param a - First dynamic route to compare
30+ * @param b - Second dynamic route to compare
31+ * @returns Negative if a is more specific, positive if b is more specific, 0 if equal
32+ */
33+ #compareRouteSpecificity( a : DynamicRoute , b : DynamicRoute ) : number {
34+ // Routes with fewer parameters are more specific
35+ const aParams = a . paramNames . length ;
36+ const bParams = b . paramNames . length ;
37+
38+ if ( aParams !== bParams ) {
39+ return aParams - bParams ;
40+ }
41+
42+ // Routes with more path segments are more specific
43+ const aSegments = a . path . split ( '/' ) . length ;
44+ const bSegments = b . path . split ( '/' ) . length ;
45+
46+ return bSegments - aSegments ;
47+ }
48+ /**
49+ * Processes route parameters by URL-decoding their values.
50+ * @param params - Raw parameter values extracted from the route path
51+ * @returns Processed parameters with URL-decoded values
52+ */
53+ #processParams( params : Record < string , string > ) : Record < string , string > {
54+ const processed : Record < string , string > = { } ;
55+
56+ for ( const [ key , value ] of Object . entries ( params ) ) {
57+ processed [ key ] = decodeURIComponent ( value ) ;
58+ }
59+
60+ return processed ;
61+ }
62+ /**
63+ * Validates route parameters to ensure they are not empty or whitespace-only.
64+ * @param params - Parameters to validate
65+ * @returns Validation result with success status and any issues found
66+ */
67+ #validateParams( params : Record < string , string > ) : ValidationResult {
68+ const issues : string [ ] = [ ] ;
69+
70+ for ( const [ key , value ] of Object . entries ( params ) ) {
71+ if ( ! value || value . trim ( ) === '' ) {
72+ issues . push ( `Parameter '${ key } ' cannot be empty` ) ;
73+ }
74+ }
75+
76+ return {
77+ isValid : issues . length === 0 ,
78+ issues,
79+ } ;
80+ }
81+ /**
82+ * Registers a route in the registry after validating its path pattern.
83+ *
84+ * The function decides whether to store the route in the static registry
85+ * (for exact paths like `/users`) or dynamic registry (for parameterized
86+ * paths like `/users/:id`) based on the compiled path analysis.
87+ *
88+ * @param route - The route to register
89+ */
1690 public register ( route : Route ) : void {
91+ this . #shouldSort = true ;
1792 const { isValid, issues } = validatePathPattern ( route . path ) ;
1893 if ( ! isValid ) {
1994 for ( const issue of issues ) {
@@ -22,29 +97,96 @@ class RouteHandlerRegistry {
2297 return ;
2398 }
2499
25- if ( this . #routes. has ( route . id ) ) {
26- this . #logger. warn (
27- `Handler for method: ${ route . method } and path: ${ route . path } already exists. The previous handler will be replaced.`
28- ) ;
100+ const compiled = compilePath ( route . path ) ;
101+
102+ if ( compiled . isDynamic ) {
103+ const dynamicRoute = {
104+ ...route ,
105+ ...compiled ,
106+ } ;
107+ if ( this . #dynamicRoutesSet. has ( route . id ) ) {
108+ this . #logger. warn (
109+ `Handler for method: ${ route . method } and path: ${ route . path } already exists. The previous handler will be replaced.`
110+ ) ;
111+ // as dynamic routes are stored in an array, we can't rely on
112+ // overwriting a key in a map like with static routes so have
113+ // to manually manage overwriting them
114+ const i = this . #dynamicRoutes. findIndex (
115+ ( oldRoute ) => oldRoute . id === route . id
116+ ) ;
117+ this . #dynamicRoutes[ i ] = dynamicRoute ;
118+ } else {
119+ this . #dynamicRoutes. push ( dynamicRoute ) ;
120+ this . #dynamicRoutesSet. add ( route . id ) ;
121+ }
122+ } else {
123+ if ( this . #staticRoutes. has ( route . id ) ) {
124+ this . #logger. warn (
125+ `Handler for method: ${ route . method } and path: ${ route . path } already exists. The previous handler will be replaced.`
126+ ) ;
127+ }
128+ this . #staticRoutes. set ( route . id , route ) ;
29129 }
130+ }
131+ /**
132+ * Resolves a route handler for the given HTTP method and path.
133+ *
134+ * Static routes are checked first for exact matches. Dynamic routes are then
135+ * checked in order of specificity (fewer parameters and more segments first).
136+ * If no handler is found, it returns `null`.
137+ *
138+ * Examples of specificity (given registered routes `/users/:id` and `/users/:id/posts/:postId`):
139+ * - For path `'/users/123/posts/456'`:
140+ * - `/users/:id` matches but has fewer segments (2 vs 4)
141+ * - `/users/:id/posts/:postId` matches and is more specific -> **selected**
142+ * - For path `'/users/123'`:
143+ * - `/users/:id` matches exactly -> **selected**
144+ * - `/users/:id/posts/:postId` doesn't match (too many segments)
145+ *
146+ * @param method - The HTTP method to match
147+ * @param path - The path to match
148+ * @returns Route handler options or null if no match found
149+ */
150+ public resolve ( method : HttpMethod , path : Path ) : RouteHandlerOptions | null {
151+ if ( this . #shouldSort) {
152+ this . #dynamicRoutes. sort ( this . #compareRouteSpecificity) ;
153+ this . #shouldSort = false ;
154+ }
155+ const routeId = `${ method } :${ path } ` ;
30156
31- this . #routes. set ( route . id , route ) ;
157+ const staticRoute = this . #staticRoutes. get ( routeId ) ;
158+ if ( staticRoute != null ) {
159+ return {
160+ handler : staticRoute . handler ,
161+ rawParams : { } ,
162+ params : { } ,
163+ } ;
164+ }
32165
33- const routesByMethod = this . #routesByMethod. get ( route . method ) ?? [ ] ;
34- routesByMethod . push ( route ) ;
35- this . #routesByMethod. set ( route . method , routesByMethod ) ;
36- }
166+ for ( const route of this . #dynamicRoutes) {
167+ if ( route . method !== method ) continue ;
37168
38- public getRouteCount ( ) : number {
39- return this . #routes . size ;
40- }
169+ const match = route . regex . exec ( path ) ;
170+ if ( match ?. groups ) {
171+ const params = match . groups ;
41172
42- public getRoutesByMethod ( method : string ) : Route [ ] {
43- return this . #routesByMethod. get ( method . toUpperCase ( ) ) || [ ] ;
44- }
173+ const processedParams = this . #processParams( params ) ;
174+
175+ const validation = this . #validateParams( processedParams ) ;
176+
177+ if ( ! validation . isValid ) {
178+ throw new ParameterValidationError ( validation . issues ) ;
179+ }
180+
181+ return {
182+ handler : route . handler ,
183+ params : processedParams ,
184+ rawParams : params ,
185+ } ;
186+ }
187+ }
45188
46- public getAllRoutes ( ) : Route [ ] {
47- return Array . from ( this . #routes. values ( ) ) ;
189+ return null ;
48190 }
49191}
50192
0 commit comments