1
1
import 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' ;
3
11
import type { Route } from './Route.js' ;
4
- import { validatePathPattern } from './utils.js' ;
12
+ import { compilePath , validatePathPattern } from './utils.js' ;
5
13
6
14
class 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 ;
9
19
10
20
readonly #logger: Pick < GenericLogger , 'debug' | 'warn' | 'error' > ;
11
21
12
22
constructor ( options : RouteRegistryOptions ) {
13
23
this . #logger = options . logger ;
14
24
}
15
25
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
+ */
16
90
public register ( route : Route ) : void {
91
+ this . #shouldSort = true ;
17
92
const { isValid, issues } = validatePathPattern ( route . path ) ;
18
93
if ( ! isValid ) {
19
94
for ( const issue of issues ) {
@@ -22,29 +97,96 @@ class RouteHandlerRegistry {
22
97
return ;
23
98
}
24
99
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 ) ;
29
129
}
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 } ` ;
30
156
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
+ }
32
165
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 ;
37
168
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 ;
41
172
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
+ }
45
188
46
- public getAllRoutes ( ) : Route [ ] {
47
- return Array . from ( this . #routes. values ( ) ) ;
189
+ return null ;
48
190
}
49
191
}
50
192
0 commit comments