@63klabs/cache-data 1.3.7 → 1.3.9

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.
@@ -0,0 +1,405 @@
1
+ const DebugAndLog = require('../tools/DebugAndLog.class');
2
+
3
+ /**
4
+ * Internal class for matching validation rules to request parameters.
5
+ * Implements a four-tier priority system for validation rule resolution:
6
+ * 1. Method-and-route match (BY_ROUTE with "METHOD:route")
7
+ * 2. Route-only match (BY_ROUTE with "route")
8
+ * 3. Method-only match (BY_METHOD with "METHOD")
9
+ * 4. Global parameter name
10
+ *
11
+ * @private
12
+ * @example
13
+ * // Internal use only - not exposed in public API
14
+ * const matcher = new ValidationMatcher(
15
+ * paramValidations,
16
+ * 'GET',
17
+ * '/product/{id}'
18
+ * );
19
+ * const rule = matcher.findValidationRule('id');
20
+ */
21
+ class ValidationMatcher {
22
+ #paramValidations;
23
+ #httpMethod;
24
+ #resourcePath;
25
+ #normalizedResourcePath;
26
+ #cachedPatterns;
27
+
28
+ /**
29
+ * Creates a new ValidationMatcher instance.
30
+ *
31
+ * @param {Object} paramValidations - Parameter validation configuration
32
+ * @param {string} httpMethod - HTTP method of request (e.g., 'GET', 'POST')
33
+ * @param {string} resourcePath - Resource path template (e.g., '/product/{id}')
34
+ */
35
+ constructor(paramValidations, httpMethod, resourcePath) {
36
+ this.#paramValidations = paramValidations || {};
37
+ this.#httpMethod = (httpMethod || '').toUpperCase();
38
+ this.#resourcePath = resourcePath || '';
39
+ this.#normalizedResourcePath = this.#normalizeRoute(this.#resourcePath);
40
+ this.#cachedPatterns = new Map();
41
+
42
+ // >! Cache normalized patterns during initialization for performance
43
+ this.#cacheNormalizedPatterns();
44
+ }
45
+
46
+ /**
47
+ * Find the best matching validation rule for a parameter.
48
+ * Uses four-tier priority resolution:
49
+ * 1. Method-and-route match (BY_ROUTE with "METHOD:route")
50
+ * 2. Route-only match (BY_ROUTE with "route")
51
+ * 3. Method-only match (BY_METHOD with "METHOD")
52
+ * 4. Global parameter name
53
+ *
54
+ * @param {string} paramName - Parameter name to find validation for
55
+ * @returns {{validate: Function, params: Array<string>}|null} Validation rule with function and parameter list, or null if no match
56
+ * @example
57
+ * const rule = matcher.findValidationRule('id');
58
+ * if (rule) {
59
+ * const isValid = rule.validate(paramValue);
60
+ * }
61
+ */
62
+ findValidationRule(paramName) {
63
+ // >! Early exit on first match to minimize pattern comparisons
64
+ // Priority 1: Method-and-route match
65
+ const methodRouteMatch = this.#findMethodRouteMatch(paramName);
66
+ if (methodRouteMatch) return methodRouteMatch;
67
+
68
+ // Priority 2: Route-only match
69
+ const routeMatch = this.#findRouteMatch(paramName);
70
+ if (routeMatch) return routeMatch;
71
+
72
+ // Priority 3: Method-only match
73
+ const methodMatch = this.#findMethodMatch(paramName);
74
+ if (methodMatch) return methodMatch;
75
+
76
+ // Priority 4: Global parameter name
77
+ return this.#findGlobalMatch(paramName);
78
+ }
79
+
80
+ /**
81
+ * Normalize route by removing leading/trailing slashes and converting to lowercase.
82
+ *
83
+ * @private
84
+ * @param {string} route - Route to normalize
85
+ * @returns {string} Normalized route
86
+ */
87
+ #normalizeRoute(route) {
88
+ if (!route || typeof route !== 'string') {
89
+ return '';
90
+ }
91
+ return route.replace(/^\/|\/$/g, '').toLowerCase();
92
+ }
93
+
94
+ /**
95
+ * Cache normalized patterns during initialization for performance optimization.
96
+ *
97
+ * @private
98
+ */
99
+ #cacheNormalizedPatterns() {
100
+ // Cache BY_ROUTE patterns
101
+ if (this.#paramValidations.BY_ROUTE && Array.isArray(this.#paramValidations.BY_ROUTE)) {
102
+ for (const rule of this.#paramValidations.BY_ROUTE) {
103
+ if (rule.route && typeof rule.route === 'string') {
104
+ // Extract route part (remove method prefix if present)
105
+ let routePart = rule.route;
106
+ if (routePart.includes(':')) {
107
+ const parts = routePart.split(':', 2);
108
+ routePart = parts[1] || '';
109
+ }
110
+
111
+ // Remove query parameter specification for pattern matching
112
+ const routeWithoutQuery = routePart.split('?')[0];
113
+ const normalized = this.#normalizeRoute(routeWithoutQuery);
114
+ this.#cachedPatterns.set(rule.route, normalized);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check if a route pattern matches the request route.
122
+ * Supports placeholder matching for any number of placeholders (e.g., {id}, {userId}, {postId}).
123
+ * Performs segment-by-segment comparison with placeholder detection.
124
+ *
125
+ * @private
126
+ * @param {string} pattern - Route pattern to match (e.g., 'users/{userId}/posts/{postId}')
127
+ * @param {string} requestRoute - Normalized request route (e.g., 'users/123/posts/456')
128
+ * @returns {boolean} True if pattern matches request route, false otherwise
129
+ * @example
130
+ * // Single placeholder
131
+ * #routeMatches('product/{id}', 'product/123') // true
132
+ *
133
+ * @example
134
+ * // Multiple placeholders
135
+ * #routeMatches('users/{userId}/posts/{postId}', 'users/123/posts/456') // true
136
+ *
137
+ * @example
138
+ * // Segment count mismatch
139
+ * #routeMatches('product/{id}', 'product/123/extra') // false
140
+ */
141
+ #routeMatches(pattern, requestRoute) {
142
+ if (!pattern || !requestRoute) {
143
+ return false;
144
+ }
145
+
146
+ // Split into segments
147
+ const patternSegments = pattern.split('/').filter(s => s.length > 0);
148
+ const requestSegments = requestRoute.split('/').filter(s => s.length > 0);
149
+
150
+ // Segment count must match
151
+ if (patternSegments.length !== requestSegments.length) {
152
+ return false;
153
+ }
154
+
155
+ // >! Check each segment - placeholders match any value
156
+ for (let i = 0; i < patternSegments.length; i++) {
157
+ const patternSeg = patternSegments[i];
158
+ const requestSeg = requestSegments[i];
159
+
160
+ // Check if pattern segment is a placeholder
161
+ if (patternSeg.startsWith('{') && patternSeg.endsWith('}')) {
162
+ continue; // Placeholder matches any value
163
+ }
164
+
165
+ // Exact match (case-insensitive)
166
+ if (patternSeg.toLowerCase() !== requestSeg.toLowerCase()) {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ return true;
172
+ }
173
+
174
+ /**
175
+ * Find method-and-route match (Priority 1: METHOD:route).
176
+ * Matches validation rules with method prefix (e.g., 'POST:product/{id}').
177
+ * Extracts method and route parts, validates both match the request.
178
+ *
179
+ * @private
180
+ * @param {string} paramName - Parameter name to find validation for
181
+ * @returns {{validate: Function, params: Array<string>}|null} Validation rule with function and parameter list, or null if no match
182
+ * @example
183
+ * // Matches 'POST:product/{id}' for POST request to /product/123
184
+ * #findMethodRouteMatch('id') // { validate: Function, params: ['id'] }
185
+ *
186
+ * @example
187
+ * // Does not match 'GET:product/{id}' for POST request
188
+ * #findMethodRouteMatch('id') // null
189
+ */
190
+ #findMethodRouteMatch(paramName) {
191
+ if (!this.#paramValidations.BY_ROUTE || !Array.isArray(this.#paramValidations.BY_ROUTE)) {
192
+ return null;
193
+ }
194
+
195
+ for (const rule of this.#paramValidations.BY_ROUTE) {
196
+ if (!rule.route || typeof rule.route !== 'string' || !rule.validate) {
197
+ continue;
198
+ }
199
+
200
+ // Check if route includes method prefix
201
+ if (!rule.route.includes(':')) {
202
+ continue;
203
+ }
204
+
205
+ const [method, routePart] = rule.route.split(':', 2);
206
+
207
+ // >! Match method case-insensitively
208
+ if (method.toUpperCase() !== this.#httpMethod) {
209
+ continue;
210
+ }
211
+
212
+ // Extract route pattern (without query params)
213
+ const routeWithoutQuery = routePart.split('?')[0];
214
+ const normalizedPattern = this.#cachedPatterns.get(rule.route) || this.#normalizeRoute(routeWithoutQuery);
215
+
216
+ // Check if route matches
217
+ if (!this.#routeMatches(normalizedPattern, this.#normalizedResourcePath)) {
218
+ continue;
219
+ }
220
+
221
+ // Extract parameter names from route pattern
222
+ const params = this.#extractParamNames(rule.route);
223
+
224
+ // Check if this rule applies to the parameter
225
+ if (params.length === 0 || params.includes(paramName)) {
226
+ return {
227
+ validate: rule.validate,
228
+ params: params.length > 0 ? params : [paramName]
229
+ };
230
+ }
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * Find route-only match (Priority 2: route).
238
+ *
239
+ * @private
240
+ * @param {string} paramName - Parameter name
241
+ * @returns {{validate: Function, params: Array<string>}|null} Validation rule or null
242
+ */
243
+ #findRouteMatch(paramName) {
244
+ if (!this.#paramValidations.BY_ROUTE || !Array.isArray(this.#paramValidations.BY_ROUTE)) {
245
+ return null;
246
+ }
247
+
248
+ for (const rule of this.#paramValidations.BY_ROUTE) {
249
+ if (!rule.route || typeof rule.route !== 'string' || !rule.validate) {
250
+ continue;
251
+ }
252
+
253
+ // Skip method-and-route patterns
254
+ if (rule.route.includes(':')) {
255
+ continue;
256
+ }
257
+
258
+ // Extract route pattern (without query params)
259
+ const routeWithoutQuery = rule.route.split('?')[0];
260
+ const normalizedPattern = this.#cachedPatterns.get(rule.route) || this.#normalizeRoute(routeWithoutQuery);
261
+
262
+ // Check if route matches
263
+ if (!this.#routeMatches(normalizedPattern, this.#normalizedResourcePath)) {
264
+ continue;
265
+ }
266
+
267
+ // Extract parameter names from route pattern
268
+ const params = this.#extractParamNames(rule.route);
269
+
270
+ // Check if this rule applies to the parameter
271
+ if (params.length === 0 || params.includes(paramName)) {
272
+ return {
273
+ validate: rule.validate,
274
+ params: params.length > 0 ? params : [paramName]
275
+ };
276
+ }
277
+ }
278
+
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * Find method-only match (Priority 3: METHOD).
284
+ *
285
+ * @private
286
+ * @param {string} paramName - Parameter name
287
+ * @returns {{validate: Function, params: Array<string>}|null} Validation rule or null
288
+ */
289
+ #findMethodMatch(paramName) {
290
+ if (!this.#paramValidations.BY_METHOD || !Array.isArray(this.#paramValidations.BY_METHOD)) {
291
+ return null;
292
+ }
293
+
294
+ for (const rule of this.#paramValidations.BY_METHOD) {
295
+ if (!rule.method || typeof rule.method !== 'string' || !rule.validate) {
296
+ continue;
297
+ }
298
+
299
+ // >! Match method case-insensitively
300
+ if (rule.method.toUpperCase() === this.#httpMethod) {
301
+ return {
302
+ validate: rule.validate,
303
+ params: [paramName]
304
+ };
305
+ }
306
+ }
307
+
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * Find global parameter match (Priority 4: global parameter name).
313
+ *
314
+ * @private
315
+ * @param {string} paramName - Parameter name
316
+ * @returns {{validate: Function, params: Array<string>}|null} Validation rule or null
317
+ */
318
+ #findGlobalMatch(paramName) {
319
+ // Check if parameter has a global validation function
320
+ if (this.#paramValidations[paramName] && typeof this.#paramValidations[paramName] === 'function') {
321
+ return {
322
+ validate: this.#paramValidations[paramName],
323
+ params: [paramName]
324
+ };
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ /**
331
+ * Extract parameter names from route pattern.
332
+ * Extracts both path parameters (from {}) and query parameters (after ?).
333
+ * When ? is present, query parameters are added to path parameters.
334
+ * When ? is absent, only path parameters in {} are extracted.
335
+ *
336
+ * @private
337
+ * @param {string} routePattern - Route pattern to parse (e.g., 'product/{id}?key', 'POST:users/{userId}/posts/{postId}')
338
+ * @returns {Array<string>} Array of unique parameter names to validate
339
+ * @example
340
+ * // Path parameters only (no ? specification)
341
+ * #extractParamNames('product/{id}') // ['id']
342
+ *
343
+ * @example
344
+ * // Multiple path parameters (no ? specification)
345
+ * #extractParamNames('users/{userId}/posts/{postId}') // ['userId', 'postId']
346
+ *
347
+ * @example
348
+ * // Query parameters only (? specification, no path params)
349
+ * #extractParamNames('search?query,limit') // ['query', 'limit']
350
+ *
351
+ * @example
352
+ * // Path and query parameters combined
353
+ * #extractParamNames('POST:product/{id}?key') // ['id', 'key'] - both path and query params
354
+ *
355
+ * @example
356
+ * // Multiple path and query parameters
357
+ * #extractParamNames('users/{userId}/posts/{postId}?includeProfile') // ['userId', 'postId', 'includeProfile']
358
+ *
359
+ * @example
360
+ * // Duplicate parameters removed
361
+ * #extractParamNames('product/{id}?id,key') // ['id', 'key'] (not ['id', 'id', 'key'])
362
+ */
363
+ #extractParamNames(routePattern) {
364
+ if (!routePattern || typeof routePattern !== 'string') {
365
+ return [];
366
+ }
367
+
368
+ const params = [];
369
+
370
+ // Remove method prefix if present
371
+ let pattern = routePattern;
372
+ if (pattern.includes(':')) {
373
+ const parts = pattern.split(':', 2);
374
+ pattern = parts[1] || '';
375
+ }
376
+
377
+ // Split route and query parts
378
+ const [routePart, queryPart] = pattern.split('?', 2);
379
+
380
+ // >! Extract path parameters from {} placeholders
381
+ if (routePart) {
382
+ const pathParamMatches = routePart.match(/\{([^}]+)\}/g);
383
+ if (pathParamMatches) {
384
+ for (const match of pathParamMatches) {
385
+ // Remove braces and add to params
386
+ const paramName = match.slice(1, -1).trim();
387
+ if (paramName.length > 0) {
388
+ params.push(paramName);
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ // >! Add query parameters to the list
395
+ if (queryPart) {
396
+ const queryParams = queryPart.split(',').map(p => p.trim()).filter(p => p.length > 0);
397
+ params.push(...queryParams);
398
+ }
399
+
400
+ // >! Remove duplicates while preserving order
401
+ return [...new Set(params)];
402
+ }
403
+ }
404
+
405
+ module.exports = ValidationMatcher;