@objectstack/core 0.8.2 → 0.9.1

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.
Files changed (73) hide show
  1. package/API_REGISTRY.md +392 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +36 -0
  4. package/dist/api-registry-plugin.d.ts +54 -0
  5. package/dist/api-registry-plugin.d.ts.map +1 -0
  6. package/dist/api-registry-plugin.js +53 -0
  7. package/dist/api-registry-plugin.test.d.ts +2 -0
  8. package/dist/api-registry-plugin.test.d.ts.map +1 -0
  9. package/dist/api-registry-plugin.test.js +332 -0
  10. package/dist/api-registry.d.ts +259 -0
  11. package/dist/api-registry.d.ts.map +1 -0
  12. package/dist/api-registry.js +599 -0
  13. package/dist/api-registry.test.d.ts +2 -0
  14. package/dist/api-registry.test.d.ts.map +1 -0
  15. package/dist/api-registry.test.js +957 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -0
  19. package/dist/logger.d.ts +1 -0
  20. package/dist/logger.d.ts.map +1 -1
  21. package/dist/logger.js +35 -11
  22. package/dist/plugin-loader.d.ts +3 -2
  23. package/dist/plugin-loader.d.ts.map +1 -1
  24. package/dist/plugin-loader.js +13 -11
  25. package/dist/qa/adapter.d.ts +14 -0
  26. package/dist/qa/adapter.d.ts.map +1 -0
  27. package/dist/qa/adapter.js +1 -0
  28. package/dist/qa/http-adapter.d.ts +16 -0
  29. package/dist/qa/http-adapter.d.ts.map +1 -0
  30. package/dist/qa/http-adapter.js +107 -0
  31. package/dist/qa/index.d.ts +4 -0
  32. package/dist/qa/index.d.ts.map +1 -0
  33. package/dist/qa/index.js +3 -0
  34. package/dist/qa/runner.d.ts +27 -0
  35. package/dist/qa/runner.d.ts.map +1 -0
  36. package/dist/qa/runner.js +157 -0
  37. package/dist/security/index.d.ts +14 -0
  38. package/dist/security/index.d.ts.map +1 -0
  39. package/dist/security/index.js +13 -0
  40. package/dist/security/plugin-config-validator.d.ts +79 -0
  41. package/dist/security/plugin-config-validator.d.ts.map +1 -0
  42. package/dist/security/plugin-config-validator.js +166 -0
  43. package/dist/security/plugin-config-validator.test.d.ts +2 -0
  44. package/dist/security/plugin-config-validator.test.d.ts.map +1 -0
  45. package/dist/security/plugin-config-validator.test.js +223 -0
  46. package/dist/security/plugin-permission-enforcer.d.ts +154 -0
  47. package/dist/security/plugin-permission-enforcer.d.ts.map +1 -0
  48. package/dist/security/plugin-permission-enforcer.js +323 -0
  49. package/dist/security/plugin-permission-enforcer.test.d.ts +2 -0
  50. package/dist/security/plugin-permission-enforcer.test.d.ts.map +1 -0
  51. package/dist/security/plugin-permission-enforcer.test.js +205 -0
  52. package/dist/security/plugin-signature-verifier.d.ts +96 -0
  53. package/dist/security/plugin-signature-verifier.d.ts.map +1 -0
  54. package/dist/security/plugin-signature-verifier.js +250 -0
  55. package/examples/api-registry-example.ts +557 -0
  56. package/package.json +2 -2
  57. package/src/api-registry-plugin.test.ts +391 -0
  58. package/src/api-registry-plugin.ts +86 -0
  59. package/src/api-registry.test.ts +1089 -0
  60. package/src/api-registry.ts +736 -0
  61. package/src/index.ts +6 -0
  62. package/src/logger.ts +36 -11
  63. package/src/plugin-loader.ts +17 -13
  64. package/src/qa/adapter.ts +14 -0
  65. package/src/qa/http-adapter.ts +114 -0
  66. package/src/qa/index.ts +3 -0
  67. package/src/qa/runner.ts +179 -0
  68. package/src/security/index.ts +29 -0
  69. package/src/security/plugin-config-validator.test.ts +276 -0
  70. package/src/security/plugin-config-validator.ts +191 -0
  71. package/src/security/plugin-permission-enforcer.test.ts +251 -0
  72. package/src/security/plugin-permission-enforcer.ts +408 -0
  73. package/src/security/plugin-signature-verifier.ts +359 -0
@@ -0,0 +1,736 @@
1
+ import type {
2
+ ApiRegistry as ApiRegistryType,
3
+ ApiRegistryEntry,
4
+ ApiRegistryEntryInput,
5
+ ApiEndpointRegistration,
6
+ ConflictResolutionStrategy,
7
+ ApiDiscoveryQuery,
8
+ ApiDiscoveryResponse,
9
+ } from '@objectstack/spec/api';
10
+ import { ApiRegistryEntrySchema } from '@objectstack/spec/api';
11
+ import type { Logger } from '@objectstack/spec/contracts';
12
+
13
+ /**
14
+ * API Registry Service
15
+ *
16
+ * Central registry for managing API endpoints across different protocols.
17
+ * Provides endpoint registration, discovery, and conflict resolution.
18
+ *
19
+ * **Features:**
20
+ * - Multi-protocol support (REST, GraphQL, OData, WebSocket, etc.)
21
+ * - Route conflict detection with configurable resolution strategies
22
+ * - RBAC permission integration
23
+ * - Dynamic schema linking with ObjectQL references
24
+ * - Plugin API registration
25
+ *
26
+ * **Architecture Alignment:**
27
+ * - Kubernetes: Service Discovery & API Server
28
+ * - AWS API Gateway: Unified API Management
29
+ * - Kong Gateway: Plugin-based API Management
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const registry = new ApiRegistry(logger, 'priority');
34
+ *
35
+ * // Register an API
36
+ * registry.registerApi({
37
+ * id: 'customer_api',
38
+ * name: 'Customer API',
39
+ * type: 'rest',
40
+ * version: 'v1',
41
+ * basePath: '/api/v1/customers',
42
+ * endpoints: [...]
43
+ * });
44
+ *
45
+ * // Discover APIs
46
+ * const apis = registry.findApis({ type: 'rest', status: 'active' });
47
+ *
48
+ * // Get registry snapshot
49
+ * const snapshot = registry.getRegistry();
50
+ * ```
51
+ */
52
+ export class ApiRegistry {
53
+ private apis: Map<string, ApiRegistryEntry> = new Map();
54
+ private endpoints: Map<string, { api: string; endpoint: ApiEndpointRegistration }> = new Map();
55
+ private routes: Map<string, { api: string; endpointId: string; priority: number }> = new Map();
56
+
57
+ // Performance optimization: Auxiliary indices for O(1) lookups
58
+ private apisByType: Map<string, Set<string>> = new Map();
59
+ private apisByTag: Map<string, Set<string>> = new Map();
60
+ private apisByStatus: Map<string, Set<string>> = new Map();
61
+
62
+ private conflictResolution: ConflictResolutionStrategy;
63
+ private logger: Logger;
64
+ private version: string;
65
+ private updatedAt: string;
66
+
67
+ constructor(
68
+ logger: Logger,
69
+ conflictResolution: ConflictResolutionStrategy = 'error',
70
+ version: string = '1.0.0'
71
+ ) {
72
+ this.logger = logger;
73
+ this.conflictResolution = conflictResolution;
74
+ this.version = version;
75
+ this.updatedAt = new Date().toISOString();
76
+ }
77
+
78
+ /**
79
+ * Register an API with its endpoints
80
+ *
81
+ * @param api - API registry entry
82
+ * @throws Error if API already registered or route conflicts detected
83
+ */
84
+ registerApi(api: ApiRegistryEntryInput): void {
85
+ // Check if API already exists
86
+ if (this.apis.has(api.id)) {
87
+ throw new Error(`[ApiRegistry] API '${api.id}' already registered`);
88
+ }
89
+
90
+ // Parse and validate the input using Zod schema
91
+ const fullApi = ApiRegistryEntrySchema.parse(api);
92
+
93
+ // Validate and register endpoints
94
+ for (const endpoint of fullApi.endpoints) {
95
+ this.validateEndpoint(endpoint, fullApi.id);
96
+ }
97
+
98
+ // Register the API
99
+ this.apis.set(fullApi.id, fullApi);
100
+
101
+ // Register endpoints
102
+ for (const endpoint of fullApi.endpoints) {
103
+ this.registerEndpoint(fullApi.id, endpoint);
104
+ }
105
+
106
+ // Update auxiliary indices for performance optimization
107
+ this.updateIndices(fullApi);
108
+
109
+ this.updatedAt = new Date().toISOString();
110
+ this.logger.info(`API registered: ${fullApi.id}`, {
111
+ api: fullApi.id,
112
+ type: fullApi.type,
113
+ endpointCount: fullApi.endpoints.length,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Unregister an API and all its endpoints
119
+ *
120
+ * @param apiId - API identifier
121
+ */
122
+ unregisterApi(apiId: string): void {
123
+ const api = this.apis.get(apiId);
124
+ if (!api) {
125
+ throw new Error(`[ApiRegistry] API '${apiId}' not found`);
126
+ }
127
+
128
+ // Remove all endpoints
129
+ for (const endpoint of api.endpoints) {
130
+ this.unregisterEndpoint(apiId, endpoint.id);
131
+ }
132
+
133
+ // Remove from auxiliary indices
134
+ this.removeFromIndices(api);
135
+
136
+ // Remove the API
137
+ this.apis.delete(apiId);
138
+ this.updatedAt = new Date().toISOString();
139
+
140
+ this.logger.info(`API unregistered: ${apiId}`);
141
+ }
142
+
143
+ /**
144
+ * Register a single endpoint
145
+ *
146
+ * @param apiId - API identifier
147
+ * @param endpoint - Endpoint registration
148
+ * @throws Error if route conflict detected
149
+ */
150
+ private registerEndpoint(apiId: string, endpoint: ApiEndpointRegistration): void {
151
+ const endpointKey = `${apiId}:${endpoint.id}`;
152
+
153
+ // Check if endpoint already registered
154
+ if (this.endpoints.has(endpointKey)) {
155
+ throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`);
156
+ }
157
+
158
+ // Register endpoint
159
+ this.endpoints.set(endpointKey, { api: apiId, endpoint });
160
+
161
+ // Register route if path is defined
162
+ if (endpoint.path) {
163
+ this.registerRoute(apiId, endpoint);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Unregister a single endpoint
169
+ *
170
+ * @param apiId - API identifier
171
+ * @param endpointId - Endpoint identifier
172
+ */
173
+ private unregisterEndpoint(apiId: string, endpointId: string): void {
174
+ const endpointKey = `${apiId}:${endpointId}`;
175
+ const entry = this.endpoints.get(endpointKey);
176
+
177
+ if (!entry) {
178
+ return; // Already unregistered
179
+ }
180
+
181
+ // Unregister route
182
+ if (entry.endpoint.path) {
183
+ const routeKey = this.getRouteKey(entry.endpoint);
184
+ this.routes.delete(routeKey);
185
+ }
186
+
187
+ // Unregister endpoint
188
+ this.endpoints.delete(endpointKey);
189
+ }
190
+
191
+ /**
192
+ * Register a route with conflict detection
193
+ *
194
+ * @param apiId - API identifier
195
+ * @param endpoint - Endpoint registration
196
+ * @throws Error if route conflict detected (based on strategy)
197
+ */
198
+ private registerRoute(apiId: string, endpoint: ApiEndpointRegistration): void {
199
+ const routeKey = this.getRouteKey(endpoint);
200
+ const priority = endpoint.priority ?? 100;
201
+ const existingRoute = this.routes.get(routeKey);
202
+
203
+ if (existingRoute) {
204
+ // Route conflict detected
205
+ this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority);
206
+ return;
207
+ }
208
+
209
+ // Register route
210
+ this.routes.set(routeKey, {
211
+ api: apiId,
212
+ endpointId: endpoint.id,
213
+ priority,
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Handle route conflict based on resolution strategy
219
+ *
220
+ * @param routeKey - Route key
221
+ * @param apiId - New API identifier
222
+ * @param endpoint - New endpoint
223
+ * @param existingRoute - Existing route registration
224
+ * @param newPriority - New endpoint priority
225
+ * @throws Error if strategy is 'error'
226
+ */
227
+ private handleRouteConflict(
228
+ routeKey: string,
229
+ apiId: string,
230
+ endpoint: ApiEndpointRegistration,
231
+ existingRoute: { api: string; endpointId: string; priority: number },
232
+ newPriority: number
233
+ ): void {
234
+ const strategy = this.conflictResolution;
235
+
236
+ switch (strategy) {
237
+ case 'error':
238
+ throw new Error(
239
+ `[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'`
240
+ );
241
+
242
+ case 'priority':
243
+ if (newPriority > existingRoute.priority) {
244
+ // New endpoint has higher priority, replace
245
+ this.logger.warn(
246
+ `Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`,
247
+ {
248
+ oldApi: existingRoute.api,
249
+ oldEndpoint: existingRoute.endpointId,
250
+ newApi: apiId,
251
+ newEndpoint: endpoint.id,
252
+ }
253
+ );
254
+ this.routes.set(routeKey, {
255
+ api: apiId,
256
+ endpointId: endpoint.id,
257
+ priority: newPriority,
258
+ });
259
+ } else {
260
+ // Existing endpoint has higher priority, keep it
261
+ this.logger.warn(
262
+ `Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`,
263
+ {
264
+ existingApi: existingRoute.api,
265
+ existingEndpoint: existingRoute.endpointId,
266
+ newApi: apiId,
267
+ newEndpoint: endpoint.id,
268
+ }
269
+ );
270
+ }
271
+ break;
272
+
273
+ case 'first-wins':
274
+ // Keep existing route
275
+ this.logger.warn(
276
+ `Route conflict: keeping first registered '${routeKey}'`,
277
+ {
278
+ existingApi: existingRoute.api,
279
+ newApi: apiId,
280
+ }
281
+ );
282
+ break;
283
+
284
+ case 'last-wins':
285
+ // Replace with new route
286
+ this.logger.warn(
287
+ `Route conflict: replacing with last registered '${routeKey}'`,
288
+ {
289
+ oldApi: existingRoute.api,
290
+ newApi: apiId,
291
+ }
292
+ );
293
+ this.routes.set(routeKey, {
294
+ api: apiId,
295
+ endpointId: endpoint.id,
296
+ priority: newPriority,
297
+ });
298
+ break;
299
+
300
+ default:
301
+ throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Generate a unique route key for conflict detection
307
+ *
308
+ * NOTE: This implementation uses exact string matching for route conflict detection.
309
+ * It works well for static paths but has limitations with parameterized routes.
310
+ * For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts
311
+ * even though they are semantically identical parameterized patterns. Similarly,
312
+ * `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting.
313
+ *
314
+ * For more advanced conflict detection (e.g., path-to-regexp pattern matching),
315
+ * consider integrating with your routing library's conflict detection mechanism.
316
+ *
317
+ * @param endpoint - Endpoint registration
318
+ * @returns Route key (e.g., "GET:/api/v1/customers/:id")
319
+ */
320
+ private getRouteKey(endpoint: ApiEndpointRegistration): string {
321
+ const method = endpoint.method || 'ANY';
322
+ return `${method}:${endpoint.path}`;
323
+ }
324
+
325
+ /**
326
+ * Validate endpoint registration
327
+ *
328
+ * @param endpoint - Endpoint to validate
329
+ * @param apiId - API identifier (for error messages)
330
+ * @throws Error if endpoint is invalid
331
+ */
332
+ private validateEndpoint(endpoint: ApiEndpointRegistration, apiId: string): void {
333
+ if (!endpoint.id) {
334
+ throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`);
335
+ }
336
+
337
+ if (!endpoint.path) {
338
+ throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Get an API by ID
344
+ *
345
+ * @param apiId - API identifier
346
+ * @returns API registry entry or undefined
347
+ */
348
+ getApi(apiId: string): ApiRegistryEntry | undefined {
349
+ return this.apis.get(apiId);
350
+ }
351
+
352
+ /**
353
+ * Get all registered APIs
354
+ *
355
+ * @returns Array of all APIs
356
+ */
357
+ getAllApis(): ApiRegistryEntry[] {
358
+ return Array.from(this.apis.values());
359
+ }
360
+
361
+ /**
362
+ * Find APIs matching query criteria
363
+ *
364
+ * Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status.
365
+ *
366
+ * @param query - Discovery query parameters
367
+ * @returns Matching APIs
368
+ */
369
+ findApis(query: ApiDiscoveryQuery): ApiDiscoveryResponse {
370
+ let resultIds: Set<string> | undefined;
371
+
372
+ // Use indices for performance-optimized filtering
373
+ // Start with the most restrictive filter to minimize subsequent filtering
374
+
375
+ // Filter by type (using index for O(1) lookup)
376
+ if (query.type) {
377
+ const typeIds = this.apisByType.get(query.type);
378
+ if (!typeIds || typeIds.size === 0) {
379
+ return { apis: [], total: 0, filters: query };
380
+ }
381
+ resultIds = new Set(typeIds);
382
+ }
383
+
384
+ // Filter by status (using index for O(1) lookup)
385
+ if (query.status) {
386
+ const statusIds = this.apisByStatus.get(query.status);
387
+ if (!statusIds || statusIds.size === 0) {
388
+ return { apis: [], total: 0, filters: query };
389
+ }
390
+
391
+ if (resultIds) {
392
+ // Intersect with previous results
393
+ resultIds = new Set([...resultIds].filter(id => statusIds.has(id)));
394
+ } else {
395
+ resultIds = new Set(statusIds);
396
+ }
397
+
398
+ if (resultIds.size === 0) {
399
+ return { apis: [], total: 0, filters: query };
400
+ }
401
+ }
402
+
403
+ // Filter by tags (using index for O(M) lookup where M is number of tags)
404
+ if (query.tags && query.tags.length > 0) {
405
+ const tagMatches = new Set<string>();
406
+
407
+ for (const tag of query.tags) {
408
+ const tagIds = this.apisByTag.get(tag);
409
+ if (tagIds) {
410
+ tagIds.forEach(id => tagMatches.add(id));
411
+ }
412
+ }
413
+
414
+ if (tagMatches.size === 0) {
415
+ return { apis: [], total: 0, filters: query };
416
+ }
417
+
418
+ if (resultIds) {
419
+ // Intersect with previous results
420
+ resultIds = new Set([...resultIds].filter(id => tagMatches.has(id)));
421
+ } else {
422
+ resultIds = tagMatches;
423
+ }
424
+
425
+ if (resultIds.size === 0) {
426
+ return { apis: [], total: 0, filters: query };
427
+ }
428
+ }
429
+
430
+ // Get the actual API objects
431
+ let results: ApiRegistryEntry[];
432
+ if (resultIds) {
433
+ results = Array.from(resultIds)
434
+ .map(id => this.apis.get(id))
435
+ .filter((api): api is ApiRegistryEntry => api !== undefined);
436
+ } else {
437
+ results = Array.from(this.apis.values());
438
+ }
439
+
440
+ // Apply remaining filters that don't have indices (less common filters)
441
+
442
+ // Filter by plugin source
443
+ if (query.pluginSource) {
444
+ results = results.filter(
445
+ (api) => api.metadata?.pluginSource === query.pluginSource
446
+ );
447
+ }
448
+
449
+ // Filter by version
450
+ if (query.version) {
451
+ results = results.filter((api) => api.version === query.version);
452
+ }
453
+
454
+ // Search in name/description
455
+ if (query.search) {
456
+ const searchLower = query.search.toLowerCase();
457
+ results = results.filter(
458
+ (api) =>
459
+ api.name.toLowerCase().includes(searchLower) ||
460
+ (api.description && api.description.toLowerCase().includes(searchLower))
461
+ );
462
+ }
463
+
464
+ return {
465
+ apis: results,
466
+ total: results.length,
467
+ filters: query,
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Get endpoint by API ID and endpoint ID
473
+ *
474
+ * @param apiId - API identifier
475
+ * @param endpointId - Endpoint identifier
476
+ * @returns Endpoint registration or undefined
477
+ */
478
+ getEndpoint(apiId: string, endpointId: string): ApiEndpointRegistration | undefined {
479
+ const key = `${apiId}:${endpointId}`;
480
+ return this.endpoints.get(key)?.endpoint;
481
+ }
482
+
483
+ /**
484
+ * Find endpoint by route (method + path)
485
+ *
486
+ * @param method - HTTP method
487
+ * @param path - URL path
488
+ * @returns Endpoint registration or undefined
489
+ */
490
+ findEndpointByRoute(method: string, path: string): {
491
+ api: ApiRegistryEntry;
492
+ endpoint: ApiEndpointRegistration;
493
+ } | undefined {
494
+ const routeKey = `${method}:${path}`;
495
+ const route = this.routes.get(routeKey);
496
+
497
+ if (!route) {
498
+ return undefined;
499
+ }
500
+
501
+ const api = this.apis.get(route.api);
502
+ const endpoint = this.getEndpoint(route.api, route.endpointId);
503
+
504
+ if (!api || !endpoint) {
505
+ return undefined;
506
+ }
507
+
508
+ return { api, endpoint };
509
+ }
510
+
511
+ /**
512
+ * Get complete registry snapshot
513
+ *
514
+ * @returns Current registry state
515
+ */
516
+ getRegistry(): ApiRegistryType {
517
+ const apis = Array.from(this.apis.values());
518
+
519
+ // Group by type
520
+ const byType: Record<string, ApiRegistryEntry[]> = {};
521
+ for (const api of apis) {
522
+ if (!byType[api.type]) {
523
+ byType[api.type] = [];
524
+ }
525
+ byType[api.type].push(api);
526
+ }
527
+
528
+ // Group by status
529
+ const byStatus: Record<string, ApiRegistryEntry[]> = {};
530
+ for (const api of apis) {
531
+ const status = api.metadata?.status || 'active';
532
+ if (!byStatus[status]) {
533
+ byStatus[status] = [];
534
+ }
535
+ byStatus[status].push(api);
536
+ }
537
+
538
+ // Count total endpoints
539
+ const totalEndpoints = apis.reduce(
540
+ (sum, api) => sum + api.endpoints.length,
541
+ 0
542
+ );
543
+
544
+ return {
545
+ version: this.version,
546
+ conflictResolution: this.conflictResolution,
547
+ apis,
548
+ totalApis: apis.length,
549
+ totalEndpoints,
550
+ byType,
551
+ byStatus,
552
+ updatedAt: this.updatedAt,
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Clear all registered APIs
558
+ *
559
+ * **⚠️ SAFETY WARNING:**
560
+ * This method clears all registered APIs and should be used with caution.
561
+ *
562
+ * **Usage Restrictions:**
563
+ * - In production environments (NODE_ENV=production), a `force: true` parameter is required
564
+ * - Primarily intended for testing and development hot-reload scenarios
565
+ *
566
+ * @param options - Clear options
567
+ * @param options.force - Force clear in production environment (default: false)
568
+ * @throws Error if called in production without force flag
569
+ *
570
+ * @example Safe usage in tests
571
+ * ```typescript
572
+ * beforeEach(() => {
573
+ * registry.clear(); // OK in test environment
574
+ * });
575
+ * ```
576
+ *
577
+ * @example Usage in production (requires explicit force)
578
+ * ```typescript
579
+ * // In production, explicit force is required
580
+ * registry.clear({ force: true });
581
+ * ```
582
+ */
583
+ clear(options: { force?: boolean } = {}): void {
584
+ const isProduction = this.isProductionEnvironment();
585
+
586
+ if (isProduction && !options.force) {
587
+ throw new Error(
588
+ '[ApiRegistry] Cannot clear registry in production environment without force flag. ' +
589
+ 'Use clear({ force: true }) if you really want to clear the registry.'
590
+ );
591
+ }
592
+
593
+ this.apis.clear();
594
+ this.endpoints.clear();
595
+ this.routes.clear();
596
+
597
+ // Clear auxiliary indices
598
+ this.apisByType.clear();
599
+ this.apisByTag.clear();
600
+ this.apisByStatus.clear();
601
+
602
+ this.updatedAt = new Date().toISOString();
603
+
604
+ if (isProduction) {
605
+ this.logger.warn('API registry forcefully cleared in production', { force: options.force });
606
+ } else {
607
+ this.logger.info('API registry cleared');
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Get registry statistics
613
+ *
614
+ * @returns Registry statistics
615
+ */
616
+ getStats(): {
617
+ totalApis: number;
618
+ totalEndpoints: number;
619
+ totalRoutes: number;
620
+ apisByType: Record<string, number>;
621
+ endpointsByApi: Record<string, number>;
622
+ } {
623
+ const apis = Array.from(this.apis.values());
624
+
625
+ const apisByType: Record<string, number> = {};
626
+ for (const api of apis) {
627
+ apisByType[api.type] = (apisByType[api.type] || 0) + 1;
628
+ }
629
+
630
+ const endpointsByApi: Record<string, number> = {};
631
+ for (const api of apis) {
632
+ endpointsByApi[api.id] = api.endpoints.length;
633
+ }
634
+
635
+ return {
636
+ totalApis: this.apis.size,
637
+ totalEndpoints: this.endpoints.size,
638
+ totalRoutes: this.routes.size,
639
+ apisByType,
640
+ endpointsByApi,
641
+ };
642
+ }
643
+
644
+ /**
645
+ * Update auxiliary indices when an API is registered
646
+ *
647
+ * @param api - API entry to index
648
+ * @private
649
+ * @internal
650
+ */
651
+ private updateIndices(api: ApiRegistryEntry): void {
652
+ // Index by type
653
+ this.ensureIndexSet(this.apisByType, api.type).add(api.id);
654
+
655
+ // Index by status
656
+ const status = api.metadata?.status || 'active';
657
+ this.ensureIndexSet(this.apisByStatus, status).add(api.id);
658
+
659
+ // Index by tags
660
+ const tags = api.metadata?.tags || [];
661
+ for (const tag of tags) {
662
+ this.ensureIndexSet(this.apisByTag, tag).add(api.id);
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Remove API from auxiliary indices when unregistered
668
+ *
669
+ * @param api - API entry to remove from indices
670
+ * @private
671
+ * @internal
672
+ */
673
+ private removeFromIndices(api: ApiRegistryEntry): void {
674
+ // Remove from type index
675
+ this.removeFromIndexSet(this.apisByType, api.type, api.id);
676
+
677
+ // Remove from status index
678
+ const status = api.metadata?.status || 'active';
679
+ this.removeFromIndexSet(this.apisByStatus, status, api.id);
680
+
681
+ // Remove from tag indices
682
+ const tags = api.metadata?.tags || [];
683
+ for (const tag of tags) {
684
+ this.removeFromIndexSet(this.apisByTag, tag, api.id);
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Helper to ensure an index set exists and return it
690
+ *
691
+ * @param map - Index map
692
+ * @param key - Index key
693
+ * @returns The Set for this key (created if needed)
694
+ * @private
695
+ * @internal
696
+ */
697
+ private ensureIndexSet(map: Map<string, Set<string>>, key: string): Set<string> {
698
+ let set = map.get(key);
699
+ if (!set) {
700
+ set = new Set();
701
+ map.set(key, set);
702
+ }
703
+ return set;
704
+ }
705
+
706
+ /**
707
+ * Helper to remove an ID from an index set and clean up empty sets
708
+ *
709
+ * @param map - Index map
710
+ * @param key - Index key
711
+ * @param id - API ID to remove
712
+ * @private
713
+ * @internal
714
+ */
715
+ private removeFromIndexSet(map: Map<string, Set<string>>, key: string, id: string): void {
716
+ const set = map.get(key);
717
+ if (set) {
718
+ set.delete(id);
719
+ // Clean up empty sets to avoid memory leaks
720
+ if (set.size === 0) {
721
+ map.delete(key);
722
+ }
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Check if running in production environment
728
+ *
729
+ * @returns true if NODE_ENV is 'production'
730
+ * @private
731
+ * @internal
732
+ */
733
+ private isProductionEnvironment(): boolean {
734
+ return process.env.NODE_ENV === 'production';
735
+ }
736
+ }