@63klabs/cache-data 1.3.8 → 1.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -5
- package/CONTRIBUTING.md +58 -5
- package/README.md +20 -34
- package/package.json +16 -27
- package/src/lib/dao-cache.js +55 -47
- package/src/lib/dao-endpoint.js +68 -38
- package/src/lib/tools/AWS.classes.js +58 -5
- package/src/lib/tools/{APIRequest.class.js → ApiRequest.class.js} +164 -51
- package/src/lib/tools/CachedParametersSecrets.classes.js +10 -9
- package/src/lib/tools/ClientRequest.class.js +987 -44
- package/src/lib/tools/Connections.classes.js +5 -5
- package/src/lib/tools/Response.class.js +25 -0
- package/src/lib/tools/generic.response.html.js +8 -113
- package/src/lib/tools/generic.response.js +73 -0
- package/src/lib/tools/generic.response.json.js +5 -135
- package/src/lib/tools/generic.response.rss.js +10 -114
- package/src/lib/tools/generic.response.text.js +5 -115
- package/src/lib/tools/generic.response.xml.js +10 -114
- package/src/lib/tools/index.js +113 -40
- package/src/lib/utils/ValidationExecutor.class.js +70 -0
- package/src/lib/utils/ValidationMatcher.class.js +417 -0
- package/AGENTS.md +0 -1012
|
@@ -2,13 +2,62 @@ const RequestInfo = require('./RequestInfo.class');
|
|
|
2
2
|
const Timer = require('./Timer.class');
|
|
3
3
|
const DebugAndLog = require('./DebugAndLog.class');
|
|
4
4
|
const { safeClone } = require('./utils');
|
|
5
|
+
const ValidationMatcher = require('../utils/ValidationMatcher.class');
|
|
6
|
+
const ValidationExecutor = require('../utils/ValidationExecutor.class');
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* Extends RequestInfo
|
|
9
|
-
*
|
|
10
|
+
* Extends RequestInfo to provide request validation, parameter extraction, and authentication.
|
|
11
|
+
*
|
|
12
|
+
* ClientRequest processes Lambda API Gateway events and validates parameters using a flexible
|
|
13
|
+
* validation system that supports global, route-specific, method-specific, and method-and-route
|
|
14
|
+
* specific validation rules with clear priority ordering.
|
|
15
|
+
*
|
|
16
|
+
* Validation Priority Order (highest to lowest):
|
|
17
|
+
* 1. Method-and-route match (BY_ROUTE with "METHOD:route")
|
|
18
|
+
* 2. Route-only match (BY_ROUTE with "route")
|
|
19
|
+
* 3. Method-only match (BY_METHOD with "METHOD")
|
|
20
|
+
* 4. Global parameter name
|
|
21
|
+
*
|
|
22
|
+
* Header Key Format Conversion:
|
|
23
|
+
*
|
|
24
|
+
* HTTP headers use kebab-case naming (e.g., 'content-type', 'x-api-key'), but JavaScript
|
|
25
|
+
* property naming conventions prefer camelCase. ClientRequest automatically converts header
|
|
26
|
+
* keys from kebab-case to camelCase during validation and parameter extraction to maintain
|
|
27
|
+
* consistency with JavaScript naming standards.
|
|
28
|
+
*
|
|
29
|
+
* This conversion is necessary because:
|
|
30
|
+
* - JavaScript object properties conventionally use camelCase
|
|
31
|
+
* - Accessing headers as object properties (e.g., headers.contentType) is more idiomatic
|
|
32
|
+
* - Validation rule keys should match JavaScript property naming conventions
|
|
33
|
+
*
|
|
34
|
+
* Conversion Algorithm:
|
|
35
|
+
* 1. Convert entire header name to lowercase
|
|
36
|
+
* 2. Replace each hyphen followed by a letter with the uppercase letter
|
|
37
|
+
* 3. Remove all hyphens
|
|
38
|
+
*
|
|
39
|
+
* Header Key Conversion Reference Table:
|
|
40
|
+
*
|
|
41
|
+
* | HTTP Header (kebab-case) | JavaScript Property (camelCase) |
|
|
42
|
+
* |---------------------------|----------------------------------|
|
|
43
|
+
* | content-type | contentType |
|
|
44
|
+
* | Content-Type | contentType |
|
|
45
|
+
* | authorization | authorization |
|
|
46
|
+
* | x-api-key | xApiKey |
|
|
47
|
+
* | X-API-Key | xApiKey |
|
|
48
|
+
* | x-custom-header | xCustomHeader |
|
|
49
|
+
* | if-modified-since | ifModifiedSince |
|
|
50
|
+
* | if-none-match | ifNoneMatch |
|
|
51
|
+
* | cache-control | cacheControl |
|
|
52
|
+
* | user-agent | userAgent |
|
|
53
|
+
* | accept-encoding | acceptEncoding |
|
|
54
|
+
*
|
|
55
|
+
* When configuring header parameter validation rules, use the camelCase property names
|
|
56
|
+
* shown in the right column. Use the static method convertHeaderKeyToCamelCase() to
|
|
57
|
+
* determine the correct property name for any HTTP header
|
|
58
|
+
*
|
|
10
59
|
* @example
|
|
11
|
-
* // Initialize ClientRequest with validations
|
|
60
|
+
* // Initialize ClientRequest with global validations (backwards compatible)
|
|
12
61
|
* ClientRequest.init({
|
|
13
62
|
* validations: {
|
|
14
63
|
* referrers: ['example.com', 'myapp.com'],
|
|
@@ -25,6 +74,294 @@ const { safeClone } = require('./utils');
|
|
|
25
74
|
* });
|
|
26
75
|
*
|
|
27
76
|
* @example
|
|
77
|
+
* // Initialize with route-specific validations
|
|
78
|
+
* ClientRequest.init({
|
|
79
|
+
* parameters: {
|
|
80
|
+
* pathParameters: {
|
|
81
|
+
* // Global validation (Priority 4) - applies to all routes
|
|
82
|
+
* id: (value) => typeof value === 'string' && value.length > 0,
|
|
83
|
+
*
|
|
84
|
+
* // Route-specific validations (Priority 2)
|
|
85
|
+
* BY_ROUTE: [
|
|
86
|
+
* {
|
|
87
|
+
* route: "product/{id}",
|
|
88
|
+
* validate: (value) => /^P-[0-9]+$/.test(value)
|
|
89
|
+
* },
|
|
90
|
+
* {
|
|
91
|
+
* route: "employee/{id}",
|
|
92
|
+
* validate: (value) => /^E-[0-9]+$/.test(value)
|
|
93
|
+
* }
|
|
94
|
+
* ]
|
|
95
|
+
* }
|
|
96
|
+
* }
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* // Initialize with method-specific validations
|
|
101
|
+
* ClientRequest.init({
|
|
102
|
+
* parameters: {
|
|
103
|
+
* pathParameters: {
|
|
104
|
+
* id: (value) => typeof value === 'string',
|
|
105
|
+
*
|
|
106
|
+
* // Method-specific validations (Priority 3)
|
|
107
|
+
* BY_METHOD: [
|
|
108
|
+
* {
|
|
109
|
+
* method: "POST",
|
|
110
|
+
* validate: (value) => value.length <= 50
|
|
111
|
+
* },
|
|
112
|
+
* {
|
|
113
|
+
* method: "GET",
|
|
114
|
+
* validate: (value) => value.length > 0
|
|
115
|
+
* }
|
|
116
|
+
* ]
|
|
117
|
+
* }
|
|
118
|
+
* }
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* // Initialize with method-and-route validations (highest priority)
|
|
123
|
+
* ClientRequest.init({
|
|
124
|
+
* parameters: {
|
|
125
|
+
* pathParameters: {
|
|
126
|
+
* BY_ROUTE: [
|
|
127
|
+
* {
|
|
128
|
+
* route: "POST:game/join/{id}", // Priority 1: Method-and-route
|
|
129
|
+
* validate: (value) => /^[0-9]{6}$/.test(value)
|
|
130
|
+
* },
|
|
131
|
+
* {
|
|
132
|
+
* route: "GET:game/join/{id}", // Priority 1: Method-and-route
|
|
133
|
+
* validate: (value) => /^[0-9]+$/.test(value)
|
|
134
|
+
* }
|
|
135
|
+
* ]
|
|
136
|
+
* }
|
|
137
|
+
* }
|
|
138
|
+
* });
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* // Multi-parameter validation - validate multiple parameters together
|
|
142
|
+
* ClientRequest.init({
|
|
143
|
+
* parameters: {
|
|
144
|
+
* queryStringParameters: {
|
|
145
|
+
* BY_ROUTE: [
|
|
146
|
+
* {
|
|
147
|
+
* route: "search?query,limit", // Specify multiple parameters
|
|
148
|
+
* validate: ({query, limit}) => {
|
|
149
|
+
* // Validation function receives object with all specified parameters
|
|
150
|
+
* return query.length > 0 && limit >= 1 && limit <= 100;
|
|
151
|
+
* }
|
|
152
|
+
* }
|
|
153
|
+
* ]
|
|
154
|
+
* }
|
|
155
|
+
* }
|
|
156
|
+
* });
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* // Header parameter validation with camelCase property names
|
|
160
|
+
* // Note: HTTP headers are automatically converted from kebab-case to camelCase
|
|
161
|
+
* ClientRequest.init({
|
|
162
|
+
* parameters: {
|
|
163
|
+
* headerParameters: {
|
|
164
|
+
* // Use camelCase for header validation rules
|
|
165
|
+
* contentType: (value) => value === 'application/json',
|
|
166
|
+
* authorization: (value) => value.startsWith('Bearer '),
|
|
167
|
+
* xApiKey: (value) => /^[a-zA-Z0-9]{32}$/.test(value),
|
|
168
|
+
*
|
|
169
|
+
* // Use convertHeaderKeyToCamelCase() to determine correct property names
|
|
170
|
+
* // ClientRequest.convertHeaderKeyToCamelCase('x-custom-header') returns 'xCustomHeader'
|
|
171
|
+
* xCustomHeader: (value) => value.length > 0
|
|
172
|
+
* }
|
|
173
|
+
* }
|
|
174
|
+
* });
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* // Body parameter validation - basic single-field validation
|
|
178
|
+
* // Body content is automatically parsed from JSON before validation
|
|
179
|
+
* ClientRequest.init({
|
|
180
|
+
* parameters: {
|
|
181
|
+
* bodyParameters: {
|
|
182
|
+
* // Global validation for specific fields
|
|
183
|
+
* email: (value) => typeof value === 'string' && value.includes('@'),
|
|
184
|
+
* age: (value) => typeof value === 'number' && value >= 0 && value <= 150,
|
|
185
|
+
* username: (value) => /^[a-zA-Z0-9_-]{3,20}$/.test(value)
|
|
186
|
+
* }
|
|
187
|
+
* }
|
|
188
|
+
* });
|
|
189
|
+
*
|
|
190
|
+
* // In Lambda handler
|
|
191
|
+
* exports.handler = async (event, context) => {
|
|
192
|
+
* // event.body = '{"email":"user@example.com","age":25,"username":"john_doe"}'
|
|
193
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
194
|
+
*
|
|
195
|
+
* if (!clientRequest.isValid()) {
|
|
196
|
+
* return { statusCode: 400, body: 'Invalid request body' };
|
|
197
|
+
* }
|
|
198
|
+
*
|
|
199
|
+
* // Access validated body parameters
|
|
200
|
+
* const bodyParams = clientRequest.getBodyParameters();
|
|
201
|
+
* console.log(bodyParams.email); // 'user@example.com'
|
|
202
|
+
* console.log(bodyParams.age); // 25
|
|
203
|
+
* console.log(bodyParams.username); // 'john_doe'
|
|
204
|
+
* };
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Body parameter validation - multi-field validation
|
|
208
|
+
* // Validate multiple fields together with complex business logic
|
|
209
|
+
* ClientRequest.init({
|
|
210
|
+
* parameters: {
|
|
211
|
+
* bodyParameters: {
|
|
212
|
+
* BY_ROUTE: [
|
|
213
|
+
* {
|
|
214
|
+
* route: 'POST:users',
|
|
215
|
+
* validate: ({email, password, confirmPassword}) => {
|
|
216
|
+
* // Multi-field validation: password confirmation
|
|
217
|
+
* return email && password &&
|
|
218
|
+
* password === confirmPassword &&
|
|
219
|
+
* password.length >= 8 &&
|
|
220
|
+
* email.includes('@');
|
|
221
|
+
* }
|
|
222
|
+
* },
|
|
223
|
+
* {
|
|
224
|
+
* route: 'PUT:users/{id}',
|
|
225
|
+
* validate: ({email, username}) => {
|
|
226
|
+
* // At least one field must be provided for update
|
|
227
|
+
* return email || username;
|
|
228
|
+
* }
|
|
229
|
+
* }
|
|
230
|
+
* ]
|
|
231
|
+
* }
|
|
232
|
+
* }
|
|
233
|
+
* });
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* // Body parameter validation - error handling for invalid JSON
|
|
237
|
+
* // ClientRequest handles JSON parsing errors gracefully
|
|
238
|
+
* exports.handler = async (event, context) => {
|
|
239
|
+
* // event.body = 'invalid json{' (malformed JSON)
|
|
240
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
241
|
+
*
|
|
242
|
+
* if (!clientRequest.isValid()) {
|
|
243
|
+
* // Validation fails for invalid JSON
|
|
244
|
+
* return {
|
|
245
|
+
* statusCode: 400,
|
|
246
|
+
* body: JSON.stringify({ error: 'Invalid request body' })
|
|
247
|
+
* };
|
|
248
|
+
* }
|
|
249
|
+
*
|
|
250
|
+
* // This code only runs if body is valid JSON and passes validation
|
|
251
|
+
* const bodyParams = clientRequest.getBodyParameters();
|
|
252
|
+
* // Process valid body parameters...
|
|
253
|
+
* };
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* // Body parameter validation - complex nested objects and arrays
|
|
257
|
+
* ClientRequest.init({
|
|
258
|
+
* parameters: {
|
|
259
|
+
* bodyParameters: {
|
|
260
|
+
* // Validate nested object structure
|
|
261
|
+
* user: (value) => {
|
|
262
|
+
* return value &&
|
|
263
|
+
* typeof value === 'object' &&
|
|
264
|
+
* typeof value.name === 'string' &&
|
|
265
|
+
* typeof value.email === 'string' &&
|
|
266
|
+
* value.email.includes('@');
|
|
267
|
+
* },
|
|
268
|
+
*
|
|
269
|
+
* // Validate array of items
|
|
270
|
+
* items: (value) => {
|
|
271
|
+
* return Array.isArray(value) &&
|
|
272
|
+
* value.length > 0 &&
|
|
273
|
+
* value.every(item =>
|
|
274
|
+
* item.id &&
|
|
275
|
+
* typeof item.quantity === 'number' &&
|
|
276
|
+
* item.quantity > 0
|
|
277
|
+
* );
|
|
278
|
+
* },
|
|
279
|
+
*
|
|
280
|
+
* // Validate nested array of objects with specific structure
|
|
281
|
+
* addresses: (value) => {
|
|
282
|
+
* return Array.isArray(value) &&
|
|
283
|
+
* value.every(addr =>
|
|
284
|
+
* addr.street &&
|
|
285
|
+
* addr.city &&
|
|
286
|
+
* addr.zipCode &&
|
|
287
|
+
* /^\d{5}$/.test(addr.zipCode)
|
|
288
|
+
* );
|
|
289
|
+
* }
|
|
290
|
+
* }
|
|
291
|
+
* }
|
|
292
|
+
* });
|
|
293
|
+
*
|
|
294
|
+
* // Example request body:
|
|
295
|
+
* // {
|
|
296
|
+
* // "user": {
|
|
297
|
+
* // "name": "John Doe",
|
|
298
|
+
* // "email": "john@example.com"
|
|
299
|
+
* // },
|
|
300
|
+
* // "items": [
|
|
301
|
+
* // {"id": "item-1", "quantity": 2},
|
|
302
|
+
* // {"id": "item-2", "quantity": 1}
|
|
303
|
+
* // ],
|
|
304
|
+
* // "addresses": [
|
|
305
|
+
* // {"street": "123 Main St", "city": "Boston", "zipCode": "02101"}
|
|
306
|
+
* // ]
|
|
307
|
+
* // }
|
|
308
|
+
*
|
|
309
|
+
* exports.handler = async (event, context) => {
|
|
310
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
311
|
+
*
|
|
312
|
+
* if (!clientRequest.isValid()) {
|
|
313
|
+
* return { statusCode: 400, body: 'Invalid request body structure' };
|
|
314
|
+
* }
|
|
315
|
+
*
|
|
316
|
+
* const bodyParams = clientRequest.getBodyParameters();
|
|
317
|
+
* console.log(bodyParams.user.name); // 'John Doe'
|
|
318
|
+
* console.log(bodyParams.items.length); // 2
|
|
319
|
+
* console.log(bodyParams.addresses[0].city); // 'Boston'
|
|
320
|
+
* };
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* // Body parameter validation - combining with other parameter types
|
|
324
|
+
* ClientRequest.init({
|
|
325
|
+
* parameters: {
|
|
326
|
+
* pathParameters: {
|
|
327
|
+
* id: (value) => /^[0-9]+$/.test(value)
|
|
328
|
+
* },
|
|
329
|
+
* bodyParameters: {
|
|
330
|
+
* BY_ROUTE: [
|
|
331
|
+
* {
|
|
332
|
+
* route: 'PUT:users/{id}',
|
|
333
|
+
* validate: ({email, username, bio}) => {
|
|
334
|
+
* // Validate update payload
|
|
335
|
+
* const hasAtLeastOneField = email || username || bio;
|
|
336
|
+
* const emailValid = !email || email.includes('@');
|
|
337
|
+
* const usernameValid = !username || /^[a-zA-Z0-9_-]{3,20}$/.test(username);
|
|
338
|
+
* const bioValid = !bio || bio.length <= 500;
|
|
339
|
+
*
|
|
340
|
+
* return hasAtLeastOneField && emailValid && usernameValid && bioValid;
|
|
341
|
+
* }
|
|
342
|
+
* }
|
|
343
|
+
* ]
|
|
344
|
+
* }
|
|
345
|
+
* }
|
|
346
|
+
* });
|
|
347
|
+
*
|
|
348
|
+
* exports.handler = async (event, context) => {
|
|
349
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
350
|
+
*
|
|
351
|
+
* if (!clientRequest.isValid()) {
|
|
352
|
+
* return { statusCode: 400, body: 'Invalid request' };
|
|
353
|
+
* }
|
|
354
|
+
*
|
|
355
|
+
* // All parameter types are validated
|
|
356
|
+
* const userId = clientRequest.getPathParameters().id;
|
|
357
|
+
* const updates = clientRequest.getBodyParameters();
|
|
358
|
+
*
|
|
359
|
+
* // Update user with validated data
|
|
360
|
+
* await updateUser(userId, updates);
|
|
361
|
+
* return { statusCode: 200, body: 'User updated' };
|
|
362
|
+
* };
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
28
365
|
* // Use in Lambda handler
|
|
29
366
|
* exports.handler = async (event, context) => {
|
|
30
367
|
* const clientRequest = new ClientRequest(event, context);
|
|
@@ -61,6 +398,10 @@ class ClientRequest extends RequestInfo {
|
|
|
61
398
|
#context = null;
|
|
62
399
|
#authorizations = safeClone(ClientRequest.#unauthenticatedAuthorizations);
|
|
63
400
|
#roles = [];
|
|
401
|
+
|
|
402
|
+
/* Validation system */
|
|
403
|
+
#validationMatchers = {};
|
|
404
|
+
#validationReason = { isValid: true, statusCode: 200, messages: [] };
|
|
64
405
|
|
|
65
406
|
/* The request data */
|
|
66
407
|
#props = {};
|
|
@@ -74,9 +415,36 @@ class ClientRequest extends RequestInfo {
|
|
|
74
415
|
}
|
|
75
416
|
|
|
76
417
|
/**
|
|
77
|
-
* Initializes the request data based on the event
|
|
78
|
-
*
|
|
79
|
-
*
|
|
418
|
+
* Initializes the request data based on the event and performs parameter validation.
|
|
419
|
+
*
|
|
420
|
+
* The constructor initializes ValidationMatchers for each parameter type (path, query, header, cookie)
|
|
421
|
+
* using the configured validation rules. Validation is performed immediately during construction,
|
|
422
|
+
* and the request validity can be checked using isValid().
|
|
423
|
+
*
|
|
424
|
+
* Validation uses a four-tier priority system:
|
|
425
|
+
* 1. Method-and-route match (BY_ROUTE with "METHOD:route") - Most specific
|
|
426
|
+
* 2. Route-only match (BY_ROUTE with "route")
|
|
427
|
+
* 3. Method-only match (BY_METHOD with "METHOD")
|
|
428
|
+
* 4. Global parameter name - Least specific
|
|
429
|
+
*
|
|
430
|
+
* @param {Object} event - Lambda API Gateway event object
|
|
431
|
+
* @param {Object} context - Lambda context object
|
|
432
|
+
* @example
|
|
433
|
+
* // Basic usage
|
|
434
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
435
|
+
* if (clientRequest.isValid()) {
|
|
436
|
+
* // Process valid request
|
|
437
|
+
* }
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* // With route-specific validation
|
|
441
|
+
* // If event.resource = "/product/{id}" and event.httpMethod = "GET"
|
|
442
|
+
* // ValidationMatcher will check:
|
|
443
|
+
* // 1. GET:product/{id} validation (if exists)
|
|
444
|
+
* // 2. product/{id} validation (if exists)
|
|
445
|
+
* // 3. GET method validation (if exists)
|
|
446
|
+
* // 4. Global 'id' parameter validation (if exists)
|
|
447
|
+
* const clientRequest = new ClientRequest(event, context);
|
|
80
448
|
*/
|
|
81
449
|
constructor(event, context) {
|
|
82
450
|
super(event);
|
|
@@ -100,8 +468,13 @@ class ClientRequest extends RequestInfo {
|
|
|
100
468
|
queryStringParameters: {},
|
|
101
469
|
headerParameters: {},
|
|
102
470
|
cookieParameters: {},
|
|
471
|
+
bodyParameters: {},
|
|
103
472
|
bodyPayload: this.#event?.body || null, // from body
|
|
104
473
|
client: {
|
|
474
|
+
ip: this.getClientIp(),
|
|
475
|
+
userAgent: this.getClientUserAgent(),
|
|
476
|
+
origin: this.getClientOrigin(),
|
|
477
|
+
referrer: this.getClientReferrer(),
|
|
105
478
|
isAuthenticated: this.isAuthenticated(),
|
|
106
479
|
isGuest: this.isGuest(),
|
|
107
480
|
authorizations: this.getAuthorizations(),
|
|
@@ -111,18 +484,176 @@ class ClientRequest extends RequestInfo {
|
|
|
111
484
|
calcMsToDeadline: this.calcMsToDeadline
|
|
112
485
|
};
|
|
113
486
|
|
|
487
|
+
// >! Initialize ValidationMatchers for each parameter type
|
|
488
|
+
// >! This enables route-specific and method-specific validations
|
|
489
|
+
const httpMethod = this.#event.httpMethod || '';
|
|
490
|
+
const resourcePath = resource || '';
|
|
491
|
+
const paramValidations = ClientRequest.getParameterValidations();
|
|
492
|
+
|
|
493
|
+
// >! Support both queryParameters and queryStringParameters for backwards compatibility
|
|
494
|
+
const queryValidations = paramValidations?.queryStringParameters || paramValidations?.queryParameters;
|
|
495
|
+
|
|
496
|
+
this.#validationMatchers = {
|
|
497
|
+
pathParameters: new ValidationMatcher(paramValidations?.pathParameters, httpMethod, resourcePath),
|
|
498
|
+
queryStringParameters: new ValidationMatcher(queryValidations, httpMethod, resourcePath),
|
|
499
|
+
headerParameters: new ValidationMatcher(paramValidations?.headerParameters, httpMethod, resourcePath),
|
|
500
|
+
cookieParameters: new ValidationMatcher(paramValidations?.cookieParameters, httpMethod, resourcePath),
|
|
501
|
+
bodyParameters: new ValidationMatcher(paramValidations?.bodyParameters, httpMethod, resourcePath)
|
|
502
|
+
};
|
|
503
|
+
|
|
114
504
|
this.#validate();
|
|
115
505
|
|
|
116
506
|
};
|
|
117
507
|
|
|
118
508
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
509
|
+
* Initialize the ClientRequest class with validation configuration.
|
|
510
|
+
*
|
|
511
|
+
* This method configures the validation system for all ClientRequest instances.
|
|
512
|
+
* Call this once during application initialization, before creating any ClientRequest instances.
|
|
513
|
+
*
|
|
514
|
+
* Validation Configuration Structure:
|
|
515
|
+
* - referrers: Array of allowed referrer domains (use ['*'] to allow all)
|
|
516
|
+
* - parameters: Object containing validation rules for each parameter type
|
|
517
|
+
* - pathParameters, queryStringParameters, headerParameters, cookieParameters, bodyParameters
|
|
518
|
+
* - Each parameter type can have:
|
|
519
|
+
* - Global validations: paramName: (value) => boolean
|
|
520
|
+
* - BY_ROUTE: Array of route-specific validation rules
|
|
521
|
+
* - BY_METHOD: Array of method-specific validation rules
|
|
522
|
+
*
|
|
523
|
+
* Parameter Specification Syntax:
|
|
524
|
+
* - Path parameters: "route/{param}" - Validates 'param' path parameter
|
|
525
|
+
* - Query parameters: "route?param" - Validates 'param' query parameter
|
|
526
|
+
* - Multiple parameters: "route?param1,param2" - Validates both parameters together
|
|
527
|
+
* - Method-and-route: "METHOD:route" - Applies only to specific HTTP method and route
|
|
528
|
+
*
|
|
529
|
+
* @param {Object} options - Configuration options
|
|
530
|
+
* @param {Array<string>} [options.referrers] - Array of allowed referrers (use ['*'] for all)
|
|
531
|
+
* @param {Object} [options.parameters] - Parameter validation configuration
|
|
532
|
+
* @param {Object} [options.parameters.pathParameters] - Path parameter validations
|
|
533
|
+
* @param {Object} [options.parameters.queryStringParameters] - Query parameter validations
|
|
534
|
+
* @param {Object} [options.parameters.headerParameters] - Header parameter validations
|
|
535
|
+
* @param {Object} [options.parameters.cookieParameters] - Cookie parameter validations
|
|
536
|
+
* @param {Object} [options.parameters.bodyParameters] - Body parameter validations
|
|
537
|
+
* @param {Array<{route: string, validate: Function}>} [options.parameters.*.BY_ROUTE] - Route-specific validations
|
|
538
|
+
* @param {Array<{method: string, validate: Function}>} [options.parameters.*.BY_METHOD] - Method-specific validations
|
|
539
|
+
* @param {boolean} [options.parameters.excludeParamsWithNoValidationMatch=true] - Exclude parameters without validation rules
|
|
125
540
|
* @throws {Error} If options is not an object
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* // Global validations only (backwards compatible)
|
|
544
|
+
* ClientRequest.init({
|
|
545
|
+
* referrers: ['example.com'],
|
|
546
|
+
* parameters: {
|
|
547
|
+
* pathParameters: {
|
|
548
|
+
* id: (value) => /^[a-zA-Z0-9-]+$/.test(value)
|
|
549
|
+
* },
|
|
550
|
+
* queryStringParameters: {
|
|
551
|
+
* limit: (value) => !isNaN(value) && value > 0 && value <= 100
|
|
552
|
+
* }
|
|
553
|
+
* }
|
|
554
|
+
* });
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* // Route-specific validations (Priority 2)
|
|
558
|
+
* ClientRequest.init({
|
|
559
|
+
* parameters: {
|
|
560
|
+
* pathParameters: {
|
|
561
|
+
* id: (value) => typeof value === 'string', // Global fallback
|
|
562
|
+
* BY_ROUTE: [
|
|
563
|
+
* {
|
|
564
|
+
* route: "product/{id}",
|
|
565
|
+
* validate: (value) => /^P-[0-9]+$/.test(value)
|
|
566
|
+
* },
|
|
567
|
+
* {
|
|
568
|
+
* route: "employee/{id}",
|
|
569
|
+
* validate: (value) => /^E-[0-9]+$/.test(value)
|
|
570
|
+
* }
|
|
571
|
+
* ]
|
|
572
|
+
* }
|
|
573
|
+
* }
|
|
574
|
+
* });
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* // Method-specific validations (Priority 3)
|
|
578
|
+
* ClientRequest.init({
|
|
579
|
+
* parameters: {
|
|
580
|
+
* pathParameters: {
|
|
581
|
+
* BY_METHOD: [
|
|
582
|
+
* {
|
|
583
|
+
* method: "POST",
|
|
584
|
+
* validate: (value) => value.length <= 50
|
|
585
|
+
* },
|
|
586
|
+
* {
|
|
587
|
+
* method: "GET",
|
|
588
|
+
* validate: (value) => value.length > 0
|
|
589
|
+
* }
|
|
590
|
+
* ]
|
|
591
|
+
* }
|
|
592
|
+
* }
|
|
593
|
+
* });
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* // Method-and-route validations (Priority 1 - highest)
|
|
597
|
+
* ClientRequest.init({
|
|
598
|
+
* parameters: {
|
|
599
|
+
* pathParameters: {
|
|
600
|
+
* BY_ROUTE: [
|
|
601
|
+
* {
|
|
602
|
+
* route: "POST:game/join/{id}",
|
|
603
|
+
* validate: (value) => /^[0-9]{6}$/.test(value)
|
|
604
|
+
* },
|
|
605
|
+
* {
|
|
606
|
+
* route: "GET:game/join/{id}",
|
|
607
|
+
* validate: (value) => /^[0-9]+$/.test(value)
|
|
608
|
+
* }
|
|
609
|
+
* ]
|
|
610
|
+
* }
|
|
611
|
+
* }
|
|
612
|
+
* });
|
|
613
|
+
*
|
|
614
|
+
* @example
|
|
615
|
+
* // Multi-parameter validation
|
|
616
|
+
* ClientRequest.init({
|
|
617
|
+
* parameters: {
|
|
618
|
+
* queryStringParameters: {
|
|
619
|
+
* BY_ROUTE: [
|
|
620
|
+
* {
|
|
621
|
+
* route: "search?query,limit", // Specify multiple parameters
|
|
622
|
+
* validate: ({query, limit}) => {
|
|
623
|
+
* // Validation function receives object with all parameters
|
|
624
|
+
* return query.length > 0 && limit >= 1 && limit <= 100;
|
|
625
|
+
* }
|
|
626
|
+
* }
|
|
627
|
+
* ]
|
|
628
|
+
* }
|
|
629
|
+
* }
|
|
630
|
+
* });
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* // Mixed priority levels
|
|
634
|
+
* ClientRequest.init({
|
|
635
|
+
* parameters: {
|
|
636
|
+
* pathParameters: {
|
|
637
|
+
* id: (value) => typeof value === 'string', // Priority 4: Global
|
|
638
|
+
* BY_METHOD: [
|
|
639
|
+
* {
|
|
640
|
+
* method: "POST", // Priority 3: Method-only
|
|
641
|
+
* validate: (value) => value.length <= 50
|
|
642
|
+
* }
|
|
643
|
+
* ],
|
|
644
|
+
* BY_ROUTE: [
|
|
645
|
+
* {
|
|
646
|
+
* route: "product/{id}", // Priority 2: Route-only
|
|
647
|
+
* validate: (value) => /^P-[0-9]+$/.test(value)
|
|
648
|
+
* },
|
|
649
|
+
* {
|
|
650
|
+
* route: "POST:product/{id}", // Priority 1: Method-and-route
|
|
651
|
+
* validate: (value) => /^P-[0-9]{4}$/.test(value)
|
|
652
|
+
* }
|
|
653
|
+
* ]
|
|
654
|
+
* }
|
|
655
|
+
* }
|
|
656
|
+
* });
|
|
126
657
|
*/
|
|
127
658
|
static init(options) {
|
|
128
659
|
if (typeof options === 'object') {
|
|
@@ -172,7 +703,7 @@ class ClientRequest extends RequestInfo {
|
|
|
172
703
|
* Parameter validations
|
|
173
704
|
* @returns {{
|
|
174
705
|
* pathParameters?: object,
|
|
175
|
-
*
|
|
706
|
+
* queryStringParameters?: object,
|
|
176
707
|
* headerParameters?: object,
|
|
177
708
|
* cookieParameters?: object,
|
|
178
709
|
* bodyParameters?: object
|
|
@@ -181,6 +712,47 @@ class ClientRequest extends RequestInfo {
|
|
|
181
712
|
static getParameterValidations() {
|
|
182
713
|
return ClientRequest.#validations.parameters;
|
|
183
714
|
};
|
|
715
|
+
/**
|
|
716
|
+
* Convert HTTP header key from kebab-case to camelCase.
|
|
717
|
+
*
|
|
718
|
+
* This utility method helps developers determine the correct key names for header validation rules.
|
|
719
|
+
* HTTP headers use kebab-case (e.g., 'content-type'), but ClientRequest converts them to camelCase
|
|
720
|
+
* (e.g., 'contentType') during validation for JavaScript property naming conventions.
|
|
721
|
+
*
|
|
722
|
+
* The conversion algorithm:
|
|
723
|
+
* 1. Convert entire string to lowercase
|
|
724
|
+
* 2. Replace each hyphen followed by a letter with the uppercase letter
|
|
725
|
+
* 3. Remove all hyphens
|
|
726
|
+
*
|
|
727
|
+
* @param {string} headerKey - HTTP header key in kebab-case (e.g., 'Content-Type', 'x-custom-header')
|
|
728
|
+
* @returns {string} Header key in camelCase (e.g., 'contentType', 'xCustomHeader')
|
|
729
|
+
* @example
|
|
730
|
+
* // Common HTTP headers
|
|
731
|
+
* ClientRequest.convertHeaderKeyToCamelCase('content-type'); // 'contentType'
|
|
732
|
+
* ClientRequest.convertHeaderKeyToCamelCase('Content-Type'); // 'contentType'
|
|
733
|
+
* ClientRequest.convertHeaderKeyToCamelCase('x-api-key'); // 'xApiKey'
|
|
734
|
+
*
|
|
735
|
+
* @example
|
|
736
|
+
* // Multiple hyphens
|
|
737
|
+
* ClientRequest.convertHeaderKeyToCamelCase('x-custom-header-name'); // 'xCustomHeaderName'
|
|
738
|
+
*
|
|
739
|
+
* @example
|
|
740
|
+
* // Use in validation configuration
|
|
741
|
+
* const headerKey = ClientRequest.convertHeaderKeyToCamelCase('X-Custom-Header');
|
|
742
|
+
* // Now use 'xCustomHeader' in validation rules
|
|
743
|
+
*/
|
|
744
|
+
static convertHeaderKeyToCamelCase(headerKey) {
|
|
745
|
+
if (!headerKey || typeof headerKey !== 'string') {
|
|
746
|
+
return '';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// >! Convert to lowercase and replace -([a-z]) with uppercase letter
|
|
750
|
+
// >! Then remove any remaining hyphens (e.g., from consecutive hyphens or trailing hyphens)
|
|
751
|
+
// >! This prevents injection via special characters in header names
|
|
752
|
+
return headerKey.toLowerCase()
|
|
753
|
+
.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase())
|
|
754
|
+
.replace(/-/g, '');
|
|
755
|
+
}
|
|
184
756
|
|
|
185
757
|
/**
|
|
186
758
|
* Used in the constructor to set validity of the request
|
|
@@ -188,80 +760,440 @@ class ClientRequest extends RequestInfo {
|
|
|
188
760
|
*/
|
|
189
761
|
#validate() {
|
|
190
762
|
|
|
191
|
-
|
|
763
|
+
const reasons = [];
|
|
764
|
+
let statusCode = 200;
|
|
765
|
+
|
|
766
|
+
// Referrer check
|
|
767
|
+
const referrerValid = this.isAuthorizedReferrer();
|
|
768
|
+
if (!referrerValid) {
|
|
769
|
+
reasons.push("Forbidden");
|
|
770
|
+
statusCode = this.#upgradeStatusCode(statusCode, 403);
|
|
771
|
+
}
|
|
192
772
|
|
|
193
|
-
//
|
|
194
|
-
|
|
773
|
+
// Authentication check
|
|
774
|
+
const authFailed = this.hasNoAuthorization();
|
|
775
|
+
if (authFailed) {
|
|
776
|
+
reasons.push("Unauthorized");
|
|
777
|
+
statusCode = this.#upgradeStatusCode(statusCode, 401);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Parameter checks - collect invalid parameter names from each
|
|
781
|
+
const pathResult = this.#hasValidPathParameters();
|
|
782
|
+
const queryResult = this.#hasValidQueryStringParameters();
|
|
783
|
+
const headerResult = this.#hasValidHeaderParameters();
|
|
784
|
+
const cookieResult = this.#hasValidCookieParameters();
|
|
785
|
+
const bodyResult = this.#hasValidBodyParameters();
|
|
786
|
+
|
|
787
|
+
// Collect invalid parameter messages
|
|
788
|
+
const paramResults = [pathResult, queryResult, headerResult, cookieResult, bodyResult];
|
|
789
|
+
for (const result of paramResults) {
|
|
790
|
+
if (result.invalidParams) {
|
|
791
|
+
for (const paramName of result.invalidParams) {
|
|
792
|
+
reasons.push(`Invalid parameter: ${paramName}`);
|
|
793
|
+
statusCode = this.#upgradeStatusCode(statusCode, 400);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Handle invalid JSON body
|
|
799
|
+
if (bodyResult.invalidBody) {
|
|
800
|
+
reasons.push("Invalid request body");
|
|
801
|
+
statusCode = this.#upgradeStatusCode(statusCode, 400);
|
|
802
|
+
}
|
|
195
803
|
|
|
196
|
-
//
|
|
804
|
+
// Compute combined valid boolean from all check results
|
|
805
|
+
const valid = referrerValid && !authFailed
|
|
806
|
+
&& pathResult.isValid && queryResult.isValid && headerResult.isValid
|
|
807
|
+
&& cookieResult.isValid && bodyResult.isValid;
|
|
808
|
+
|
|
809
|
+
// Preserve backwards compatibility
|
|
197
810
|
super._isValid = valid;
|
|
198
811
|
|
|
812
|
+
// Populate validation reason
|
|
813
|
+
this.#validationReason = {
|
|
814
|
+
isValid: valid,
|
|
815
|
+
statusCode: valid ? 200 : statusCode,
|
|
816
|
+
messages: valid ? [] : reasons
|
|
817
|
+
};
|
|
818
|
+
|
|
199
819
|
};
|
|
200
820
|
|
|
201
|
-
|
|
821
|
+
/**
|
|
822
|
+
* Returns the higher-priority HTTP status code between two candidates.
|
|
823
|
+
* Priority order: 401 > 403 > 400 > 200.
|
|
824
|
+
*
|
|
825
|
+
* @private
|
|
826
|
+
* @param {number} current - The current status code
|
|
827
|
+
* @param {number} candidate - The candidate status code to compare
|
|
828
|
+
* @returns {number} The status code with higher priority
|
|
829
|
+
*/
|
|
830
|
+
#upgradeStatusCode(current, candidate) {
|
|
831
|
+
const priority = { 401: 3, 403: 2, 400: 1, 200: 0 };
|
|
832
|
+
return (priority[candidate] || 0) > (priority[current] || 0) ? candidate : current;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Returns a structured validation result object describing why the request
|
|
837
|
+
* passed or failed validation. The object includes the validation status,
|
|
838
|
+
* an appropriate HTTP status code, and descriptive messages identifying
|
|
839
|
+
* each failure.
|
|
840
|
+
*
|
|
841
|
+
* A new object is returned on each call to prevent external mutation of
|
|
842
|
+
* internal state.
|
|
843
|
+
*
|
|
844
|
+
* @returns {{ isValid: boolean, statusCode: number, messages: Array<string> }}
|
|
845
|
+
* A new object on each call containing:
|
|
846
|
+
* - isValid: whether the request passed all validation checks
|
|
847
|
+
* - statusCode: the appropriate HTTP status code (200, 400, 401, or 403)
|
|
848
|
+
* - messages: array of descriptive failure messages (empty when valid)
|
|
849
|
+
* @example
|
|
850
|
+
* // Valid request
|
|
851
|
+
* const reason = clientRequest.getValidationReason();
|
|
852
|
+
* // { isValid: true, statusCode: 200, messages: [] }
|
|
853
|
+
*
|
|
854
|
+
* @example
|
|
855
|
+
* // Invalid request with bad parameters
|
|
856
|
+
* const reason = clientRequest.getValidationReason();
|
|
857
|
+
* // { isValid: false, statusCode: 400, messages: ["Invalid parameter: limit"] }
|
|
858
|
+
*/
|
|
859
|
+
getValidationReason() {
|
|
860
|
+
return {
|
|
861
|
+
isValid: this.#validationReason.isValid,
|
|
862
|
+
statusCode: this.#validationReason.statusCode,
|
|
863
|
+
messages: [...this.#validationReason.messages]
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Validate parameters using ValidationMatcher and ValidationExecutor.
|
|
869
|
+
*
|
|
870
|
+
* This method implements the core parameter validation logic:
|
|
871
|
+
* 1. Uses ValidationMatcher to find the best matching validation rule (4-tier priority)
|
|
872
|
+
* 2. Uses ValidationExecutor to execute validation with appropriate interface (single or multi-parameter)
|
|
873
|
+
* 3. Extracts ALL validated parameters specified in the matching rule and returns them
|
|
874
|
+
* 4. Respects excludeParamsWithNoValidationMatch flag (default: true)
|
|
875
|
+
*
|
|
876
|
+
* When a validation rule matches and validation passes, ALL parameters specified in rule.params
|
|
877
|
+
* are extracted from clientParameters and included in the returned params object. This ensures
|
|
878
|
+
* that multi-parameter validation rules (e.g., validating query?param1,param2 together) correctly
|
|
879
|
+
* extract all validated parameters, not just the one that triggered the rule match.
|
|
880
|
+
*
|
|
881
|
+
* For single-parameter validation with multi-placeholder routes (e.g., users/{userId}/posts/{id}):
|
|
882
|
+
* - ValidationMatcher returns validateParam field indicating which parameter to validate
|
|
883
|
+
* - ValidationExecutor validates only that parameter with single-parameter interface
|
|
884
|
+
* - This method extracts ALL parameters from rule.params array (e.g., both userId and id)
|
|
885
|
+
*
|
|
886
|
+
* @private
|
|
887
|
+
* @param {Object} paramValidations - Parameter validation configuration (may include BY_ROUTE, BY_METHOD, and global validations)
|
|
888
|
+
* @param {Object} clientParameters - Parameters from the request (path, query, header, or cookie parameters)
|
|
889
|
+
* @param {ValidationMatcher} validationMatcher - ValidationMatcher instance for finding validation rules
|
|
890
|
+
* @returns {{isValid: boolean, params: Object}} Object with validation result and extracted parameters
|
|
891
|
+
* @example
|
|
892
|
+
* // Internal use - validates path parameters
|
|
893
|
+
* const { isValid, params } = #hasValidParameters(
|
|
894
|
+
* paramValidations.pathParameters,
|
|
895
|
+
* event.pathParameters,
|
|
896
|
+
* validationMatcher
|
|
897
|
+
* );
|
|
898
|
+
*/
|
|
899
|
+
#hasValidParameters(paramValidations, clientParameters, validationMatcher) {
|
|
202
900
|
|
|
203
901
|
let rValue = {
|
|
204
902
|
isValid: true,
|
|
205
|
-
params: {}
|
|
903
|
+
params: {},
|
|
904
|
+
invalidParams: []
|
|
206
905
|
}
|
|
207
906
|
|
|
208
|
-
if (clientParameters
|
|
907
|
+
if (clientParameters) {
|
|
908
|
+
// >! Check excludeParamsWithNoValidationMatch flag (default: true)
|
|
909
|
+
const excludeUnmatched = ClientRequest.#validations.parameters?.excludeParamsWithNoValidationMatch !== false;
|
|
910
|
+
|
|
911
|
+
// >! When no validation rules exist for this parameter type,
|
|
912
|
+
// >! pass through all parameters if excludeUnmatched is false
|
|
913
|
+
if (!paramValidations) {
|
|
914
|
+
if (!excludeUnmatched) {
|
|
915
|
+
rValue.params = { ...clientParameters };
|
|
916
|
+
}
|
|
917
|
+
return rValue;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// >! Track which parameters have been validated to avoid duplicate validation
|
|
921
|
+
const validatedParams = new Set();
|
|
922
|
+
|
|
923
|
+
// >! Create normalized parameter map for validation execution
|
|
924
|
+
// >! Include ALL parameter types so multi-parameter validations can access them
|
|
925
|
+
const normalizedParams = {};
|
|
926
|
+
|
|
927
|
+
// >! Add path parameters (if available)
|
|
928
|
+
if (this.#event?.pathParameters) {
|
|
929
|
+
for (const [key, value] of Object.entries(this.#event.pathParameters)) {
|
|
930
|
+
const normalizedKey = key.replace(/^\/|\/$/g, '');
|
|
931
|
+
normalizedParams[normalizedKey] = value;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// >! Add query parameters (if available, lowercased)
|
|
936
|
+
if (this.#event?.queryStringParameters) {
|
|
937
|
+
for (const [key, value] of Object.entries(this.#event.queryStringParameters)) {
|
|
938
|
+
const normalizedKey = key.toLowerCase().replace(/^\/|\/$/g, '');
|
|
939
|
+
normalizedParams[normalizedKey] = value;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// >! Add header parameters (if available, camelCased)
|
|
944
|
+
if (this.#event?.headers) {
|
|
945
|
+
for (const [key, value] of Object.entries(this.#event.headers)) {
|
|
946
|
+
const camelCaseKey = key.toLowerCase().replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
947
|
+
normalizedParams[camelCaseKey] = value;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// >! Add cookie parameters (if available)
|
|
952
|
+
if (this.#event?.cookie) {
|
|
953
|
+
for (const [key, value] of Object.entries(this.#event.cookie)) {
|
|
954
|
+
const normalizedKey = key.replace(/^\/|\/$/g, '');
|
|
955
|
+
normalizedParams[normalizedKey] = value;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// >! Add client parameters being validated to normalizedParams
|
|
960
|
+
// >! This ensures validation functions can access the parameters they're validating
|
|
961
|
+
for (const [key, value] of Object.entries(clientParameters)) {
|
|
962
|
+
const normalizedKey = key.replace(/^\/|\/$/g, '');
|
|
963
|
+
normalizedParams[normalizedKey] = value;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// >! Collect valid params separately so we can clear them if any fail
|
|
967
|
+
const collectedParams = {};
|
|
968
|
+
|
|
209
969
|
// Use a for...of loop instead of forEach for better control flow
|
|
210
970
|
for (const [key, value] of Object.entries(clientParameters)) {
|
|
971
|
+
// >! Preserve existing parameter key normalization
|
|
211
972
|
const paramKey = key.replace(/^\/|\/$/g, '');
|
|
212
973
|
const paramValue = value;
|
|
213
974
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
975
|
+
// >! Skip parameters that have already been validated
|
|
976
|
+
if (validatedParams.has(paramKey)) {
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// >! Use ValidationMatcher to find the best matching validation rule
|
|
981
|
+
const rule = validationMatcher.findValidationRule(paramKey);
|
|
982
|
+
|
|
983
|
+
if (rule) {
|
|
984
|
+
// >! Use ValidationExecutor to execute validation with appropriate interface
|
|
985
|
+
// Pass normalized parameters so validation functions can access them by normalized names
|
|
986
|
+
const isValid = ValidationExecutor.execute(rule.validate, rule.params, normalizedParams);
|
|
987
|
+
|
|
988
|
+
if (isValid) {
|
|
989
|
+
// >! Extract ALL parameters specified in rule.params when validation passes
|
|
990
|
+
// >! This fixes the bug where only the current paramKey was added
|
|
991
|
+
for (const ruleParamName of rule.params) {
|
|
992
|
+
// >! Find the parameter value in clientParameters
|
|
993
|
+
// >! Use normalized key matching to handle case differences
|
|
994
|
+
const normalizedRuleParam = ruleParamName.replace(/^\/|\/$/g, '');
|
|
995
|
+
|
|
996
|
+
// >! Search for matching parameter in clientParameters
|
|
997
|
+
for (const [clientKey, clientValue] of Object.entries(clientParameters)) {
|
|
998
|
+
const normalizedClientKey = clientKey.replace(/^\/|\/$/g, '');
|
|
999
|
+
|
|
1000
|
+
if (normalizedClientKey === normalizedRuleParam) {
|
|
1001
|
+
collectedParams[clientKey] = clientValue;
|
|
1002
|
+
// >! Mark this parameter as validated to avoid duplicate validation
|
|
1003
|
+
validatedParams.add(normalizedClientKey);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
218
1008
|
} else {
|
|
1009
|
+
// >! Maintain existing logging for invalid parameters
|
|
219
1010
|
DebugAndLog.warn(`Invalid parameter: ${paramKey} = ${paramValue}`);
|
|
220
1011
|
rValue.isValid = false;
|
|
221
|
-
rValue.
|
|
222
|
-
return rValue; // exit right away
|
|
1012
|
+
rValue.invalidParams.push(paramKey);
|
|
223
1013
|
}
|
|
1014
|
+
} else if (!excludeUnmatched) {
|
|
1015
|
+
// No validation rule found, but excludeUnmatched is false
|
|
1016
|
+
// Include parameter without validation
|
|
1017
|
+
collectedParams[paramKey] = paramValue;
|
|
224
1018
|
}
|
|
1019
|
+
// If excludeUnmatched is true and no rule found, skip parameter (existing behavior)
|
|
225
1020
|
}
|
|
1021
|
+
|
|
1022
|
+
// >! If any parameter failed, clear params (preserves existing behavior)
|
|
1023
|
+
rValue.params = rValue.isValid ? collectedParams : {};
|
|
226
1024
|
}
|
|
227
1025
|
return rValue;
|
|
228
1026
|
}
|
|
229
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Validate path parameters from the request.
|
|
1030
|
+
*
|
|
1031
|
+
* Uses ValidationMatcher to find matching validation rules based on route pattern and HTTP method.
|
|
1032
|
+
* Extracts validated path parameters and stores them in this.#props.pathParameters.
|
|
1033
|
+
*
|
|
1034
|
+
* @private
|
|
1035
|
+
* @returns {boolean} True if all path parameters are valid, false otherwise
|
|
1036
|
+
* @example
|
|
1037
|
+
* // Internal use during request validation
|
|
1038
|
+
* const isValid = #hasValidPathParameters();
|
|
1039
|
+
*/
|
|
230
1040
|
#hasValidPathParameters() {
|
|
231
|
-
const { isValid, params } = this.#hasValidParameters(
|
|
1041
|
+
const { isValid, params, invalidParams } = this.#hasValidParameters(
|
|
1042
|
+
ClientRequest.getParameterValidations()?.pathParameters,
|
|
1043
|
+
this.#event?.pathParameters,
|
|
1044
|
+
this.#validationMatchers.pathParameters
|
|
1045
|
+
);
|
|
232
1046
|
this.#props.pathParameters = params;
|
|
233
|
-
return isValid;
|
|
1047
|
+
return { isValid, invalidParams };
|
|
234
1048
|
}
|
|
235
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Validate query string parameters from the request.
|
|
1052
|
+
*
|
|
1053
|
+
* Normalizes query parameter keys to lowercase before validation.
|
|
1054
|
+
* Uses ValidationMatcher to find matching validation rules based on route pattern and HTTP method.
|
|
1055
|
+
* Extracts validated query parameters and stores them in this.#props.queryStringParameters.
|
|
1056
|
+
* Supports both queryStringParameters and queryParameters for backwards compatibility.
|
|
1057
|
+
*
|
|
1058
|
+
* @private
|
|
1059
|
+
* @returns {boolean} True if all query string parameters are valid, false otherwise
|
|
1060
|
+
* @example
|
|
1061
|
+
* // Internal use during request validation
|
|
1062
|
+
* const isValid = #hasValidQueryStringParameters();
|
|
1063
|
+
*/
|
|
236
1064
|
#hasValidQueryStringParameters() {
|
|
237
1065
|
// lowercase all the this.#event.queryStringParameters keys
|
|
238
1066
|
const qs = {};
|
|
239
1067
|
for (const key in this.#event.queryStringParameters) {
|
|
240
1068
|
qs[key.toLowerCase()] = this.#event.queryStringParameters[key];
|
|
241
1069
|
}
|
|
242
|
-
|
|
1070
|
+
|
|
1071
|
+
// >! Support both queryParameters and queryStringParameters for backwards compatibility
|
|
1072
|
+
const paramValidations = ClientRequest.getParameterValidations();
|
|
1073
|
+
const queryValidations = paramValidations?.queryStringParameters || paramValidations?.queryParameters;
|
|
1074
|
+
|
|
1075
|
+
const { isValid, params, invalidParams } = this.#hasValidParameters(
|
|
1076
|
+
queryValidations,
|
|
1077
|
+
qs,
|
|
1078
|
+
this.#validationMatchers.queryStringParameters
|
|
1079
|
+
);
|
|
243
1080
|
this.#props.queryStringParameters = params;
|
|
244
|
-
return isValid;
|
|
1081
|
+
return { isValid, invalidParams };
|
|
245
1082
|
}
|
|
246
1083
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* Validate header parameters from the request.
|
|
1086
|
+
*
|
|
1087
|
+
* Normalizes header keys to camelCase (e.g., 'content-type' becomes 'contentType') before validation.
|
|
1088
|
+
* Uses ValidationMatcher to find matching validation rules based on route pattern and HTTP method.
|
|
1089
|
+
* Extracts validated header parameters and stores them in this.#props.headerParameters.
|
|
1090
|
+
*
|
|
1091
|
+
* @private
|
|
1092
|
+
* @returns {boolean} True if all header parameters are valid, false otherwise
|
|
1093
|
+
* @example
|
|
1094
|
+
* // Internal use during request validation
|
|
1095
|
+
* const isValid = #hasValidHeaderParameters();
|
|
1096
|
+
*/
|
|
1097
|
+
/**
|
|
1098
|
+
* Validate header parameters from the request.
|
|
1099
|
+
*
|
|
1100
|
+
* HTTP headers are automatically converted from kebab-case to camelCase for JavaScript
|
|
1101
|
+
* property naming conventions. The conversion algorithm:
|
|
1102
|
+
* 1. Convert entire header key to lowercase
|
|
1103
|
+
* 2. Replace each hyphen followed by a letter with the uppercase letter
|
|
1104
|
+
* 3. Remove all hyphens
|
|
1105
|
+
*
|
|
1106
|
+
* This allows validation rules to use JavaScript-friendly property names while
|
|
1107
|
+
* maintaining compatibility with standard HTTP header naming conventions.
|
|
1108
|
+
*
|
|
1109
|
+
* @private
|
|
1110
|
+
* @returns {boolean} True if all header parameters are valid, false otherwise
|
|
1111
|
+
* @example
|
|
1112
|
+
* // HTTP header conversion examples:
|
|
1113
|
+
* // 'Content-Type' → 'contentType'
|
|
1114
|
+
* // 'content-type' → 'contentType'
|
|
1115
|
+
* // 'X-API-Key' → 'xApiKey'
|
|
1116
|
+
* // 'x-custom-header' → 'xCustomHeader'
|
|
1117
|
+
* // 'authorization' → 'authorization' (no hyphens, unchanged)
|
|
1118
|
+
*
|
|
1119
|
+
* // Internal use during request validation
|
|
1120
|
+
* const isValid = this.#hasValidHeaderParameters();
|
|
1121
|
+
*/
|
|
1122
|
+
#hasValidHeaderParameters() {
|
|
1123
|
+
// camel case all the this.#event.headers keys and remove hyphens
|
|
1124
|
+
const headers = {};
|
|
1125
|
+
for (const key in this.#event.headers) {
|
|
1126
|
+
const camelCaseKey = key.toLowerCase().replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
1127
|
+
headers[camelCaseKey] = this.#event.headers[key];
|
|
1128
|
+
}
|
|
1129
|
+
const { isValid, params, invalidParams } = this.#hasValidParameters(
|
|
1130
|
+
ClientRequest.getParameterValidations()?.headerParameters,
|
|
1131
|
+
headers,
|
|
1132
|
+
this.#validationMatchers.headerParameters
|
|
1133
|
+
);
|
|
1134
|
+
this.#props.headerParameters = params;
|
|
1135
|
+
return { isValid, invalidParams };
|
|
253
1136
|
}
|
|
254
|
-
const { isValid, params } = this.#hasValidParameters(ClientRequest.getParameterValidations()?.headerParameters, headers);
|
|
255
|
-
this.#props.headerParameters = params;
|
|
256
|
-
return isValid;
|
|
257
|
-
}
|
|
258
1137
|
|
|
259
1138
|
#hasValidCookieParameters() {
|
|
260
|
-
const { isValid, params } = this.#hasValidParameters(
|
|
1139
|
+
const { isValid, params, invalidParams } = this.#hasValidParameters(
|
|
1140
|
+
ClientRequest.getParameterValidations()?.cookieParameters,
|
|
1141
|
+
this.#event?.cookie,
|
|
1142
|
+
this.#validationMatchers.cookieParameters
|
|
1143
|
+
);
|
|
261
1144
|
this.#props.cookieParameters = params;
|
|
262
|
-
return isValid;
|
|
1145
|
+
return { isValid, invalidParams };
|
|
263
1146
|
}
|
|
264
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Validate body parameters from the request.
|
|
1150
|
+
*
|
|
1151
|
+
* Parses JSON body content before validation. Handles both API Gateway v1 and v2 formats.
|
|
1152
|
+
* Uses ValidationMatcher to find matching validation rules based on route pattern and HTTP method.
|
|
1153
|
+
* Extracts validated body parameters and stores them in this.#props.bodyParameters.
|
|
1154
|
+
*
|
|
1155
|
+
* Null, undefined, or empty string bodies are treated as empty objects for validation.
|
|
1156
|
+
* If the body contains invalid JSON, the error is logged and validation fails.
|
|
1157
|
+
*
|
|
1158
|
+
* @private
|
|
1159
|
+
* @returns {boolean} True if all body parameters are valid, false otherwise
|
|
1160
|
+
* @example
|
|
1161
|
+
* // Internal use during request validation
|
|
1162
|
+
* const isValid = #hasValidBodyParameters();
|
|
1163
|
+
*/
|
|
1164
|
+
#hasValidBodyParameters() {
|
|
1165
|
+
// >! Parse body content before validation
|
|
1166
|
+
let bodyObject = {};
|
|
1167
|
+
|
|
1168
|
+
// >! Handle null, undefined, and empty string body cases
|
|
1169
|
+
if (this.#event.body && this.#event.body !== '') {
|
|
1170
|
+
try {
|
|
1171
|
+
// >! Parse JSON with error handling
|
|
1172
|
+
bodyObject = JSON.parse(this.#event.body);
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
// >! Log JSON parsing errors
|
|
1175
|
+
DebugAndLog.error(
|
|
1176
|
+
`Failed to parse request body as JSON: ${error?.message || 'Unknown error'}`,
|
|
1177
|
+
error?.stack
|
|
1178
|
+
);
|
|
1179
|
+
this.#props.bodyParameters = {};
|
|
1180
|
+
return { isValid: false, invalidParams: [], invalidBody: true };
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// >! Use existing validation framework with body validation matcher
|
|
1185
|
+
const { isValid, params, invalidParams } = this.#hasValidParameters(
|
|
1186
|
+
ClientRequest.getParameterValidations()?.bodyParameters,
|
|
1187
|
+
bodyObject,
|
|
1188
|
+
this.#validationMatchers.bodyParameters
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
// >! Store validated parameters
|
|
1192
|
+
this.#props.bodyParameters = params;
|
|
1193
|
+
return { isValid, invalidParams };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
|
|
265
1197
|
|
|
266
1198
|
// Utility function for getPathArray and getResourceArray
|
|
267
1199
|
// Returns array slice based on n parameter
|
|
@@ -389,6 +1321,15 @@ class ClientRequest extends RequestInfo {
|
|
|
389
1321
|
return this.#props.cookieParameters;
|
|
390
1322
|
};
|
|
391
1323
|
|
|
1324
|
+
/**
|
|
1325
|
+
* Returns the body parameters received in the request.
|
|
1326
|
+
* Body parameters are validated in the applications validation functions.
|
|
1327
|
+
* @returns {object} body parameters
|
|
1328
|
+
*/
|
|
1329
|
+
getBodyParameters() {
|
|
1330
|
+
return this.#props.bodyParameters || {};
|
|
1331
|
+
};
|
|
1332
|
+
|
|
392
1333
|
#authenticate() {
|
|
393
1334
|
// add your authentication logic here
|
|
394
1335
|
this.authenticated = false; // anonymous
|
|
@@ -425,9 +1366,11 @@ class ClientRequest extends RequestInfo {
|
|
|
425
1366
|
isAuthorizedReferrer() {
|
|
426
1367
|
/* Check the array of valid referrers */
|
|
427
1368
|
/* Check if the array includes a wildcard (*) OR if one of the whitelisted referrers matches the end of the referrer */
|
|
428
|
-
if (ClientRequest.requiresValidReferrer()) {
|
|
1369
|
+
if (!ClientRequest.requiresValidReferrer()) {
|
|
1370
|
+
// Wildcard (*) is in the list, allow all referrers
|
|
429
1371
|
return true;
|
|
430
1372
|
} else {
|
|
1373
|
+
// Check if referrer matches one of the whitelisted referrers
|
|
431
1374
|
for (let i = 0; i < ClientRequest.#validations.referrers.length; i++) {
|
|
432
1375
|
if (this.getClientReferer().endsWith(ClientRequest.#validations.referrers[i])) {
|
|
433
1376
|
return true;
|
|
@@ -458,7 +1401,7 @@ class ClientRequest extends RequestInfo {
|
|
|
458
1401
|
* Get the _processed_ request properties. These are the properties that
|
|
459
1402
|
* the ClientRequest object took from the event sent to Lambda, validated,
|
|
460
1403
|
* supplemented, and makes available to controllers.
|
|
461
|
-
* @returns {{ method: string, path: string, pathArray: string[], resource: string, resourceArray[], pathParameters: {}, queryStringParameters: {}, headerParameters: {}, cookieParameters: {}, bodyPayload: string, client: {isAuthenticated: boolean, isGuest: boolean, authorizations: string[], roles: string[]}, deadline: number, calcMsToDeadline: number}
|
|
1404
|
+
* @returns {{ method: string, path: string, pathArray: string[], resource: string, resourceArray[], pathParameters: {}, queryStringParameters: {}, headerParameters: {}, cookieParameters: {}, bodyParameters: {}, bodyPayload: string, client: {ip: string, userAgent: string, origin: string, referrer: string, isAuthenticated: boolean, isGuest: boolean, authorizations: string[], roles: string[]}, deadline: number, calcMsToDeadline: number}
|
|
462
1405
|
*/
|
|
463
1406
|
getProps() {
|
|
464
1407
|
return this.#props;
|