Skip to content

Commit 9ccd466

Browse files
authored
Merge pull request #486 from objectstack-ai/copilot/add-api-registry-service
2 parents 2c444bf + 362a4f3 commit 9ccd466

4 files changed

Lines changed: 543 additions & 19 deletions

File tree

packages/core/examples/api-registry-example.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,16 @@ async function example5_DynamicSchemas() {
477477
statusCode: 200,
478478
description: 'Customer retrieved',
479479
// Dynamic schema linked to ObjectQL
480+
//
481+
// IMPORTANT: The API Registry stores this ObjectQL reference as-is.
482+
// The actual schema resolution (expanding the reference into a full JSON Schema)
483+
// is performed by downstream tools:
484+
// - API Gateway: For runtime request/response validation
485+
// - OpenAPI/Swagger Generator: For API documentation generation
486+
// - GraphQL Schema Builder: For GraphQL type generation
487+
//
488+
// The Registry's responsibility is to STORE the reference metadata,
489+
// not to resolve or transform it.
480490
schema: {
481491
$ref: {
482492
objectId: 'customer', // References ObjectQL object
@@ -506,10 +516,12 @@ async function example5_DynamicSchemas() {
506516

507517
if (endpoint?.responses?.[0]?.schema && '$ref' in endpoint.responses[0].schema) {
508518
const ref = endpoint.responses[0].schema.$ref;
509-
console.log('\n Schema Reference:');
519+
console.log('\n Schema Reference (stored as metadata):');
510520
console.log(` Object: ${ref.objectId}`);
511521
console.log(` Excluded Fields: ${ref.excludeFields?.join(', ')}`);
512522
console.log(` Included Related: ${ref.includeRelated?.join(', ')}`);
523+
console.log('\n ℹ️ Note: Schema resolution is handled by gateway/documentation tools,');
524+
console.log(' not by the API Registry itself.');
513525
}
514526

515527
await kernel.shutdown();

packages/core/src/api-registry.test.ts

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,4 +795,295 @@ describe('ApiRegistry', () => {
795795
expect(result.total).toBe(1);
796796
});
797797
});
798+
799+
describe('Performance Optimizations', () => {
800+
it('should use indices for fast type-based lookups', () => {
801+
// Register multiple APIs with different types
802+
registry.registerApi({
803+
id: 'rest_api_1',
804+
name: 'REST API 1',
805+
type: 'rest',
806+
version: 'v1',
807+
basePath: '/api/rest1',
808+
endpoints: [{ id: 'e1', path: '/api/rest1', responses: [] }],
809+
});
810+
811+
registry.registerApi({
812+
id: 'rest_api_2',
813+
name: 'REST API 2',
814+
type: 'rest',
815+
version: 'v1',
816+
basePath: '/api/rest2',
817+
endpoints: [{ id: 'e2', path: '/api/rest2', responses: [] }],
818+
});
819+
820+
registry.registerApi({
821+
id: 'graphql_api',
822+
name: 'GraphQL API',
823+
type: 'graphql',
824+
version: 'v1',
825+
basePath: '/graphql',
826+
endpoints: [{ id: 'e3', path: '/graphql', responses: [] }],
827+
});
828+
829+
// Should efficiently find all REST APIs
830+
const restApis = registry.findApis({ type: 'rest' });
831+
expect(restApis.total).toBe(2);
832+
expect(restApis.apis.every(api => api.type === 'rest')).toBe(true);
833+
834+
// Should efficiently find GraphQL APIs
835+
const graphqlApis = registry.findApis({ type: 'graphql' });
836+
expect(graphqlApis.total).toBe(1);
837+
expect(graphqlApis.apis[0].id).toBe('graphql_api');
838+
});
839+
840+
it('should use indices for fast tag-based lookups', () => {
841+
registry.registerApi({
842+
id: 'api_1',
843+
name: 'API 1',
844+
type: 'rest',
845+
version: 'v1',
846+
basePath: '/api1',
847+
endpoints: [{ id: 'e1', path: '/api1', responses: [] }],
848+
metadata: { tags: ['customer', 'crm'] },
849+
});
850+
851+
registry.registerApi({
852+
id: 'api_2',
853+
name: 'API 2',
854+
type: 'rest',
855+
version: 'v1',
856+
basePath: '/api2',
857+
endpoints: [{ id: 'e2', path: '/api2', responses: [] }],
858+
metadata: { tags: ['order', 'sales'] },
859+
});
860+
861+
registry.registerApi({
862+
id: 'api_3',
863+
name: 'API 3',
864+
type: 'rest',
865+
version: 'v1',
866+
basePath: '/api3',
867+
endpoints: [{ id: 'e3', path: '/api3', responses: [] }],
868+
metadata: { tags: ['customer', 'analytics'] },
869+
});
870+
871+
// Should efficiently find APIs by tag
872+
const customerApis = registry.findApis({ tags: ['customer'] });
873+
expect(customerApis.total).toBe(2);
874+
expect(customerApis.apis.map(a => a.id).sort()).toEqual(['api_1', 'api_3']);
875+
876+
// Should support multiple tags (ANY match)
877+
const multiTagApis = registry.findApis({ tags: ['crm', 'sales'] });
878+
expect(multiTagApis.total).toBe(2);
879+
});
880+
881+
it('should use indices for fast status-based lookups', () => {
882+
registry.registerApi({
883+
id: 'active_api',
884+
name: 'Active API',
885+
type: 'rest',
886+
version: 'v1',
887+
basePath: '/active',
888+
endpoints: [{ id: 'e1', path: '/active', responses: [] }],
889+
metadata: { status: 'active' },
890+
});
891+
892+
registry.registerApi({
893+
id: 'beta_api',
894+
name: 'Beta API',
895+
type: 'rest',
896+
version: 'v1',
897+
basePath: '/beta',
898+
endpoints: [{ id: 'e2', path: '/beta', responses: [] }],
899+
metadata: { status: 'beta' },
900+
});
901+
902+
registry.registerApi({
903+
id: 'deprecated_api',
904+
name: 'Deprecated API',
905+
type: 'rest',
906+
version: 'v1',
907+
basePath: '/deprecated',
908+
endpoints: [{ id: 'e3', path: '/deprecated', responses: [] }],
909+
metadata: { status: 'deprecated' },
910+
});
911+
912+
// Should efficiently find by status
913+
const activeApis = registry.findApis({ status: 'active' });
914+
expect(activeApis.total).toBe(1);
915+
expect(activeApis.apis[0].id).toBe('active_api');
916+
917+
const betaApis = registry.findApis({ status: 'beta' });
918+
expect(betaApis.total).toBe(1);
919+
});
920+
921+
it('should combine multiple indexed filters efficiently', () => {
922+
registry.registerApi({
923+
id: 'rest_crm_active',
924+
name: 'REST CRM Active',
925+
type: 'rest',
926+
version: 'v1',
927+
basePath: '/crm',
928+
endpoints: [{ id: 'e1', path: '/crm', responses: [] }],
929+
metadata: { status: 'active', tags: ['crm', 'customer'] },
930+
});
931+
932+
registry.registerApi({
933+
id: 'rest_crm_beta',
934+
name: 'REST CRM Beta',
935+
type: 'rest',
936+
version: 'v1',
937+
basePath: '/crm-beta',
938+
endpoints: [{ id: 'e2', path: '/crm-beta', responses: [] }],
939+
metadata: { status: 'beta', tags: ['crm'] },
940+
});
941+
942+
registry.registerApi({
943+
id: 'graphql_crm_active',
944+
name: 'GraphQL CRM Active',
945+
type: 'graphql',
946+
version: 'v1',
947+
basePath: '/graphql',
948+
endpoints: [{ id: 'e3', path: '/graphql', responses: [] }],
949+
metadata: { status: 'active', tags: ['crm'] },
950+
});
951+
952+
// Combine type + status + tags filters
953+
const result = registry.findApis({
954+
type: 'rest',
955+
status: 'active',
956+
tags: ['crm'],
957+
});
958+
959+
expect(result.total).toBe(1);
960+
expect(result.apis[0].id).toBe('rest_crm_active');
961+
});
962+
963+
it('should maintain indices when APIs are unregistered', () => {
964+
registry.registerApi({
965+
id: 'temp_api',
966+
name: 'Temporary API',
967+
type: 'rest',
968+
version: 'v1',
969+
basePath: '/temp',
970+
endpoints: [{ id: 'e1', path: '/temp', responses: [] }],
971+
metadata: { status: 'beta', tags: ['temp', 'test'] },
972+
});
973+
974+
// Verify it's in indices
975+
expect(registry.findApis({ type: 'rest' }).total).toBe(1);
976+
expect(registry.findApis({ status: 'beta' }).total).toBe(1);
977+
expect(registry.findApis({ tags: ['temp'] }).total).toBe(1);
978+
979+
// Unregister
980+
registry.unregisterApi('temp_api');
981+
982+
// Verify removed from indices
983+
expect(registry.findApis({ type: 'rest' }).total).toBe(0);
984+
expect(registry.findApis({ status: 'beta' }).total).toBe(0);
985+
expect(registry.findApis({ tags: ['temp'] }).total).toBe(0);
986+
});
987+
});
988+
989+
describe('Safety Guards', () => {
990+
it('should allow clear() in non-production environment', () => {
991+
const originalEnv = process.env.NODE_ENV;
992+
try {
993+
process.env.NODE_ENV = 'test';
994+
995+
registry.registerApi({
996+
id: 'test_api',
997+
name: 'Test API',
998+
type: 'rest',
999+
version: 'v1',
1000+
basePath: '/test',
1001+
endpoints: [{ id: 'e1', path: '/test', responses: [] }],
1002+
});
1003+
1004+
expect(registry.getStats().totalApis).toBe(1);
1005+
1006+
// Should work without force flag in non-production
1007+
registry.clear();
1008+
expect(registry.getStats().totalApis).toBe(0);
1009+
} finally {
1010+
process.env.NODE_ENV = originalEnv;
1011+
}
1012+
});
1013+
1014+
it('should prevent clear() in production without force flag', () => {
1015+
const originalEnv = process.env.NODE_ENV;
1016+
try {
1017+
process.env.NODE_ENV = 'production';
1018+
1019+
registry.registerApi({
1020+
id: 'prod_api',
1021+
name: 'Production API',
1022+
type: 'rest',
1023+
version: 'v1',
1024+
basePath: '/prod',
1025+
endpoints: [{ id: 'e1', path: '/prod', responses: [] }],
1026+
});
1027+
1028+
// Should throw error in production without force flag
1029+
expect(() => registry.clear()).toThrow(
1030+
'Cannot clear registry in production environment without force flag'
1031+
);
1032+
1033+
// API should still exist
1034+
expect(registry.getStats().totalApis).toBe(1);
1035+
} finally {
1036+
process.env.NODE_ENV = originalEnv;
1037+
}
1038+
});
1039+
1040+
it('should allow clear() in production with force flag', () => {
1041+
const originalEnv = process.env.NODE_ENV;
1042+
try {
1043+
process.env.NODE_ENV = 'production';
1044+
1045+
registry.registerApi({
1046+
id: 'prod_api',
1047+
name: 'Production API',
1048+
type: 'rest',
1049+
version: 'v1',
1050+
basePath: '/prod',
1051+
endpoints: [{ id: 'e1', path: '/prod', responses: [] }],
1052+
});
1053+
1054+
expect(registry.getStats().totalApis).toBe(1);
1055+
1056+
// Should work with force flag
1057+
registry.clear({ force: true });
1058+
expect(registry.getStats().totalApis).toBe(0);
1059+
1060+
// Verify logger warned about forced clear
1061+
expect(logger.warn).toHaveBeenCalledWith(
1062+
'API registry forcefully cleared in production',
1063+
{ force: true }
1064+
);
1065+
} finally {
1066+
process.env.NODE_ENV = originalEnv;
1067+
}
1068+
});
1069+
1070+
it('should clear all indices when clear() is called', () => {
1071+
registry.registerApi({
1072+
id: 'api_1',
1073+
name: 'API 1',
1074+
type: 'rest',
1075+
version: 'v1',
1076+
basePath: '/api1',
1077+
endpoints: [{ id: 'e1', path: '/api1', responses: [] }],
1078+
metadata: { status: 'active', tags: ['test'] },
1079+
});
1080+
1081+
registry.clear();
1082+
1083+
// All lookups should return empty
1084+
expect(registry.findApis({ type: 'rest' }).total).toBe(0);
1085+
expect(registry.findApis({ status: 'active' }).total).toBe(0);
1086+
expect(registry.findApis({ tags: ['test'] }).total).toBe(0);
1087+
});
1088+
});
7981089
});

0 commit comments

Comments
 (0)