diff --git a/packages/core/examples/api-registry-example.ts b/packages/core/examples/api-registry-example.ts index c67be0f3e..8625307b4 100644 --- a/packages/core/examples/api-registry-example.ts +++ b/packages/core/examples/api-registry-example.ts @@ -477,6 +477,16 @@ async function example5_DynamicSchemas() { statusCode: 200, description: 'Customer retrieved', // Dynamic schema linked to ObjectQL + // + // IMPORTANT: The API Registry stores this ObjectQL reference as-is. + // The actual schema resolution (expanding the reference into a full JSON Schema) + // is performed by downstream tools: + // - API Gateway: For runtime request/response validation + // - OpenAPI/Swagger Generator: For API documentation generation + // - GraphQL Schema Builder: For GraphQL type generation + // + // The Registry's responsibility is to STORE the reference metadata, + // not to resolve or transform it. schema: { $ref: { objectId: 'customer', // References ObjectQL object @@ -506,10 +516,12 @@ async function example5_DynamicSchemas() { if (endpoint?.responses?.[0]?.schema && '$ref' in endpoint.responses[0].schema) { const ref = endpoint.responses[0].schema.$ref; - console.log('\n Schema Reference:'); + console.log('\n Schema Reference (stored as metadata):'); console.log(` Object: ${ref.objectId}`); console.log(` Excluded Fields: ${ref.excludeFields?.join(', ')}`); console.log(` Included Related: ${ref.includeRelated?.join(', ')}`); + console.log('\n ℹ️ Note: Schema resolution is handled by gateway/documentation tools,'); + console.log(' not by the API Registry itself.'); } await kernel.shutdown(); diff --git a/packages/core/src/api-registry.test.ts b/packages/core/src/api-registry.test.ts index 636b554cc..2352ffbc8 100644 --- a/packages/core/src/api-registry.test.ts +++ b/packages/core/src/api-registry.test.ts @@ -795,4 +795,295 @@ describe('ApiRegistry', () => { expect(result.total).toBe(1); }); }); + + describe('Performance Optimizations', () => { + it('should use indices for fast type-based lookups', () => { + // Register multiple APIs with different types + registry.registerApi({ + id: 'rest_api_1', + name: 'REST API 1', + type: 'rest', + version: 'v1', + basePath: '/api/rest1', + endpoints: [{ id: 'e1', path: '/api/rest1', responses: [] }], + }); + + registry.registerApi({ + id: 'rest_api_2', + name: 'REST API 2', + type: 'rest', + version: 'v1', + basePath: '/api/rest2', + endpoints: [{ id: 'e2', path: '/api/rest2', responses: [] }], + }); + + registry.registerApi({ + id: 'graphql_api', + name: 'GraphQL API', + type: 'graphql', + version: 'v1', + basePath: '/graphql', + endpoints: [{ id: 'e3', path: '/graphql', responses: [] }], + }); + + // Should efficiently find all REST APIs + const restApis = registry.findApis({ type: 'rest' }); + expect(restApis.total).toBe(2); + expect(restApis.apis.every(api => api.type === 'rest')).toBe(true); + + // Should efficiently find GraphQL APIs + const graphqlApis = registry.findApis({ type: 'graphql' }); + expect(graphqlApis.total).toBe(1); + expect(graphqlApis.apis[0].id).toBe('graphql_api'); + }); + + it('should use indices for fast tag-based lookups', () => { + registry.registerApi({ + id: 'api_1', + name: 'API 1', + type: 'rest', + version: 'v1', + basePath: '/api1', + endpoints: [{ id: 'e1', path: '/api1', responses: [] }], + metadata: { tags: ['customer', 'crm'] }, + }); + + registry.registerApi({ + id: 'api_2', + name: 'API 2', + type: 'rest', + version: 'v1', + basePath: '/api2', + endpoints: [{ id: 'e2', path: '/api2', responses: [] }], + metadata: { tags: ['order', 'sales'] }, + }); + + registry.registerApi({ + id: 'api_3', + name: 'API 3', + type: 'rest', + version: 'v1', + basePath: '/api3', + endpoints: [{ id: 'e3', path: '/api3', responses: [] }], + metadata: { tags: ['customer', 'analytics'] }, + }); + + // Should efficiently find APIs by tag + const customerApis = registry.findApis({ tags: ['customer'] }); + expect(customerApis.total).toBe(2); + expect(customerApis.apis.map(a => a.id).sort()).toEqual(['api_1', 'api_3']); + + // Should support multiple tags (ANY match) + const multiTagApis = registry.findApis({ tags: ['crm', 'sales'] }); + expect(multiTagApis.total).toBe(2); + }); + + it('should use indices for fast status-based lookups', () => { + registry.registerApi({ + id: 'active_api', + name: 'Active API', + type: 'rest', + version: 'v1', + basePath: '/active', + endpoints: [{ id: 'e1', path: '/active', responses: [] }], + metadata: { status: 'active' }, + }); + + registry.registerApi({ + id: 'beta_api', + name: 'Beta API', + type: 'rest', + version: 'v1', + basePath: '/beta', + endpoints: [{ id: 'e2', path: '/beta', responses: [] }], + metadata: { status: 'beta' }, + }); + + registry.registerApi({ + id: 'deprecated_api', + name: 'Deprecated API', + type: 'rest', + version: 'v1', + basePath: '/deprecated', + endpoints: [{ id: 'e3', path: '/deprecated', responses: [] }], + metadata: { status: 'deprecated' }, + }); + + // Should efficiently find by status + const activeApis = registry.findApis({ status: 'active' }); + expect(activeApis.total).toBe(1); + expect(activeApis.apis[0].id).toBe('active_api'); + + const betaApis = registry.findApis({ status: 'beta' }); + expect(betaApis.total).toBe(1); + }); + + it('should combine multiple indexed filters efficiently', () => { + registry.registerApi({ + id: 'rest_crm_active', + name: 'REST CRM Active', + type: 'rest', + version: 'v1', + basePath: '/crm', + endpoints: [{ id: 'e1', path: '/crm', responses: [] }], + metadata: { status: 'active', tags: ['crm', 'customer'] }, + }); + + registry.registerApi({ + id: 'rest_crm_beta', + name: 'REST CRM Beta', + type: 'rest', + version: 'v1', + basePath: '/crm-beta', + endpoints: [{ id: 'e2', path: '/crm-beta', responses: [] }], + metadata: { status: 'beta', tags: ['crm'] }, + }); + + registry.registerApi({ + id: 'graphql_crm_active', + name: 'GraphQL CRM Active', + type: 'graphql', + version: 'v1', + basePath: '/graphql', + endpoints: [{ id: 'e3', path: '/graphql', responses: [] }], + metadata: { status: 'active', tags: ['crm'] }, + }); + + // Combine type + status + tags filters + const result = registry.findApis({ + type: 'rest', + status: 'active', + tags: ['crm'], + }); + + expect(result.total).toBe(1); + expect(result.apis[0].id).toBe('rest_crm_active'); + }); + + it('should maintain indices when APIs are unregistered', () => { + registry.registerApi({ + id: 'temp_api', + name: 'Temporary API', + type: 'rest', + version: 'v1', + basePath: '/temp', + endpoints: [{ id: 'e1', path: '/temp', responses: [] }], + metadata: { status: 'beta', tags: ['temp', 'test'] }, + }); + + // Verify it's in indices + expect(registry.findApis({ type: 'rest' }).total).toBe(1); + expect(registry.findApis({ status: 'beta' }).total).toBe(1); + expect(registry.findApis({ tags: ['temp'] }).total).toBe(1); + + // Unregister + registry.unregisterApi('temp_api'); + + // Verify removed from indices + expect(registry.findApis({ type: 'rest' }).total).toBe(0); + expect(registry.findApis({ status: 'beta' }).total).toBe(0); + expect(registry.findApis({ tags: ['temp'] }).total).toBe(0); + }); + }); + + describe('Safety Guards', () => { + it('should allow clear() in non-production environment', () => { + const originalEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'test'; + + registry.registerApi({ + id: 'test_api', + name: 'Test API', + type: 'rest', + version: 'v1', + basePath: '/test', + endpoints: [{ id: 'e1', path: '/test', responses: [] }], + }); + + expect(registry.getStats().totalApis).toBe(1); + + // Should work without force flag in non-production + registry.clear(); + expect(registry.getStats().totalApis).toBe(0); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it('should prevent clear() in production without force flag', () => { + const originalEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + + registry.registerApi({ + id: 'prod_api', + name: 'Production API', + type: 'rest', + version: 'v1', + basePath: '/prod', + endpoints: [{ id: 'e1', path: '/prod', responses: [] }], + }); + + // Should throw error in production without force flag + expect(() => registry.clear()).toThrow( + 'Cannot clear registry in production environment without force flag' + ); + + // API should still exist + expect(registry.getStats().totalApis).toBe(1); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it('should allow clear() in production with force flag', () => { + const originalEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + + registry.registerApi({ + id: 'prod_api', + name: 'Production API', + type: 'rest', + version: 'v1', + basePath: '/prod', + endpoints: [{ id: 'e1', path: '/prod', responses: [] }], + }); + + expect(registry.getStats().totalApis).toBe(1); + + // Should work with force flag + registry.clear({ force: true }); + expect(registry.getStats().totalApis).toBe(0); + + // Verify logger warned about forced clear + expect(logger.warn).toHaveBeenCalledWith( + 'API registry forcefully cleared in production', + { force: true } + ); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it('should clear all indices when clear() is called', () => { + registry.registerApi({ + id: 'api_1', + name: 'API 1', + type: 'rest', + version: 'v1', + basePath: '/api1', + endpoints: [{ id: 'e1', path: '/api1', responses: [] }], + metadata: { status: 'active', tags: ['test'] }, + }); + + registry.clear(); + + // All lookups should return empty + expect(registry.findApis({ type: 'rest' }).total).toBe(0); + expect(registry.findApis({ status: 'active' }).total).toBe(0); + expect(registry.findApis({ tags: ['test'] }).total).toBe(0); + }); + }); }); diff --git a/packages/core/src/api-registry.ts b/packages/core/src/api-registry.ts index 007301a8c..02edc65b8 100644 --- a/packages/core/src/api-registry.ts +++ b/packages/core/src/api-registry.ts @@ -53,6 +53,12 @@ export class ApiRegistry { private apis: Map = new Map(); private endpoints: Map = new Map(); private routes: Map = new Map(); + + // Performance optimization: Auxiliary indices for O(1) lookups + private apisByType: Map> = new Map(); + private apisByTag: Map> = new Map(); + private apisByStatus: Map> = new Map(); + private conflictResolution: ConflictResolutionStrategy; private logger: Logger; private version: string; @@ -97,6 +103,9 @@ export class ApiRegistry { this.registerEndpoint(fullApi.id, endpoint); } + // Update auxiliary indices for performance optimization + this.updateIndices(fullApi); + this.updatedAt = new Date().toISOString(); this.logger.info(`API registered: ${fullApi.id}`, { api: fullApi.id, @@ -121,6 +130,9 @@ export class ApiRegistry { this.unregisterEndpoint(apiId, endpoint.id); } + // Remove from auxiliary indices + this.removeFromIndices(api); + // Remove the API this.apis.delete(apiId); this.updatedAt = new Date().toISOString(); @@ -293,6 +305,15 @@ export class ApiRegistry { /** * Generate a unique route key for conflict detection * + * NOTE: This implementation uses exact string matching for route conflict detection. + * It works well for static paths but has limitations with parameterized routes. + * For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts + * even though they are semantically identical parameterized patterns. Similarly, + * `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting. + * + * For more advanced conflict detection (e.g., path-to-regexp pattern matching), + * consider integrating with your routing library's conflict detection mechanism. + * * @param endpoint - Endpoint registration * @returns Route key (e.g., "GET:/api/v1/customers/:id") */ @@ -340,24 +361,84 @@ export class ApiRegistry { /** * Find APIs matching query criteria * + * Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status. + * * @param query - Discovery query parameters * @returns Matching APIs */ findApis(query: ApiDiscoveryQuery): ApiDiscoveryResponse { - let results = Array.from(this.apis.values()); + let resultIds: Set | undefined; - // Filter by type + // Use indices for performance-optimized filtering + // Start with the most restrictive filter to minimize subsequent filtering + + // Filter by type (using index for O(1) lookup) if (query.type) { - results = results.filter((api) => api.type === query.type); + const typeIds = this.apisByType.get(query.type); + if (!typeIds || typeIds.size === 0) { + return { apis: [], total: 0, filters: query }; + } + resultIds = new Set(typeIds); } - // Filter by status + // Filter by status (using index for O(1) lookup) if (query.status) { - results = results.filter( - (api) => api.metadata?.status === query.status - ); + const statusIds = this.apisByStatus.get(query.status); + if (!statusIds || statusIds.size === 0) { + return { apis: [], total: 0, filters: query }; + } + + if (resultIds) { + // Intersect with previous results + resultIds = new Set([...resultIds].filter(id => statusIds.has(id))); + } else { + resultIds = new Set(statusIds); + } + + if (resultIds.size === 0) { + return { apis: [], total: 0, filters: query }; + } } + // Filter by tags (using index for O(M) lookup where M is number of tags) + if (query.tags && query.tags.length > 0) { + const tagMatches = new Set(); + + for (const tag of query.tags) { + const tagIds = this.apisByTag.get(tag); + if (tagIds) { + tagIds.forEach(id => tagMatches.add(id)); + } + } + + if (tagMatches.size === 0) { + return { apis: [], total: 0, filters: query }; + } + + if (resultIds) { + // Intersect with previous results + resultIds = new Set([...resultIds].filter(id => tagMatches.has(id))); + } else { + resultIds = tagMatches; + } + + if (resultIds.size === 0) { + return { apis: [], total: 0, filters: query }; + } + } + + // Get the actual API objects + let results: ApiRegistryEntry[]; + if (resultIds) { + results = Array.from(resultIds) + .map(id => this.apis.get(id)) + .filter((api): api is ApiRegistryEntry => api !== undefined); + } else { + results = Array.from(this.apis.values()); + } + + // Apply remaining filters that don't have indices (less common filters) + // Filter by plugin source if (query.pluginSource) { results = results.filter( @@ -370,14 +451,6 @@ export class ApiRegistry { results = results.filter((api) => api.version === query.version); } - // Filter by tags (ANY match) - if (query.tags && query.tags.length > 0) { - results = results.filter((api) => { - const apiTags = api.metadata?.tags || []; - return query.tags!.some((tag) => apiTags.includes(tag)); - }); - } - // Search in name/description if (query.search) { const searchLower = query.search.toLowerCase(); @@ -482,14 +555,57 @@ export class ApiRegistry { /** * Clear all registered APIs - * Useful for testing or hot-reload scenarios + * + * **⚠️ SAFETY WARNING:** + * This method clears all registered APIs and should be used with caution. + * + * **Usage Restrictions:** + * - In production environments (NODE_ENV=production), a `force: true` parameter is required + * - Primarily intended for testing and development hot-reload scenarios + * + * @param options - Clear options + * @param options.force - Force clear in production environment (default: false) + * @throws Error if called in production without force flag + * + * @example Safe usage in tests + * ```typescript + * beforeEach(() => { + * registry.clear(); // OK in test environment + * }); + * ``` + * + * @example Usage in production (requires explicit force) + * ```typescript + * // In production, explicit force is required + * registry.clear({ force: true }); + * ``` */ - clear(): void { + clear(options: { force?: boolean } = {}): void { + const isProduction = this.isProductionEnvironment(); + + if (isProduction && !options.force) { + throw new Error( + '[ApiRegistry] Cannot clear registry in production environment without force flag. ' + + 'Use clear({ force: true }) if you really want to clear the registry.' + ); + } + this.apis.clear(); this.endpoints.clear(); this.routes.clear(); + + // Clear auxiliary indices + this.apisByType.clear(); + this.apisByTag.clear(); + this.apisByStatus.clear(); + this.updatedAt = new Date().toISOString(); - this.logger.info('API registry cleared'); + + if (isProduction) { + this.logger.warn('API registry forcefully cleared in production', { force: options.force }); + } else { + this.logger.info('API registry cleared'); + } } /** @@ -524,4 +640,97 @@ export class ApiRegistry { endpointsByApi, }; } + + /** + * Update auxiliary indices when an API is registered + * + * @param api - API entry to index + * @private + * @internal + */ + private updateIndices(api: ApiRegistryEntry): void { + // Index by type + this.ensureIndexSet(this.apisByType, api.type).add(api.id); + + // Index by status + const status = api.metadata?.status || 'active'; + this.ensureIndexSet(this.apisByStatus, status).add(api.id); + + // Index by tags + const tags = api.metadata?.tags || []; + for (const tag of tags) { + this.ensureIndexSet(this.apisByTag, tag).add(api.id); + } + } + + /** + * Remove API from auxiliary indices when unregistered + * + * @param api - API entry to remove from indices + * @private + * @internal + */ + private removeFromIndices(api: ApiRegistryEntry): void { + // Remove from type index + this.removeFromIndexSet(this.apisByType, api.type, api.id); + + // Remove from status index + const status = api.metadata?.status || 'active'; + this.removeFromIndexSet(this.apisByStatus, status, api.id); + + // Remove from tag indices + const tags = api.metadata?.tags || []; + for (const tag of tags) { + this.removeFromIndexSet(this.apisByTag, tag, api.id); + } + } + + /** + * Helper to ensure an index set exists and return it + * + * @param map - Index map + * @param key - Index key + * @returns The Set for this key (created if needed) + * @private + * @internal + */ + private ensureIndexSet(map: Map>, key: string): Set { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + return set; + } + + /** + * Helper to remove an ID from an index set and clean up empty sets + * + * @param map - Index map + * @param key - Index key + * @param id - API ID to remove + * @private + * @internal + */ + private removeFromIndexSet(map: Map>, key: string, id: string): void { + const set = map.get(key); + if (set) { + set.delete(id); + // Clean up empty sets to avoid memory leaks + if (set.size === 0) { + map.delete(key); + } + } + } + + /** + * Check if running in production environment + * + * @returns true if NODE_ENV is 'production' + * @private + * @internal + */ + private isProductionEnvironment(): boolean { + return process.env.NODE_ENV === 'production'; + } } diff --git a/packages/spec/src/api/registry.zod.ts b/packages/spec/src/api/registry.zod.ts index 4e0eba987..e097bee89 100644 --- a/packages/spec/src/api/registry.zod.ts +++ b/packages/spec/src/api/registry.zod.ts @@ -87,10 +87,22 @@ export type HttpStatusCode = z.infer; * is dynamically derived from the object definition, enabling automatic updates * when the object schema changes. * + * **IMPORTANT - Schema Resolution Responsibility:** + * The API Registry STORES these references as metadata but does NOT resolve them. + * Schema resolution (expanding references into actual JSON Schema) is performed by: + * - **API Gateway**: For runtime request/response validation + * - **OpenAPI Generator**: For Swagger/OpenAPI documentation + * - **GraphQL Schema Builder**: For GraphQL type generation + * - **Documentation Tools**: For developer documentation + * + * This separation allows the Registry to remain lightweight and focused on + * registration/discovery, while specialized tools handle schema transformation. + * * **Benefits:** * - Auto-updating API documentation when object schemas change * - Consistent type definitions across API and database * - Reduced duplication and maintenance + * - Registry remains protocol-agnostic and lightweight * * @example Reference Customer object * ```json