Skip to content

Commit 756f821

Browse files
authored
Merge pull request #622 from objectstack-ai/copilot/evaluate-api-spec-consistency
2 parents 8030ca6 + d93d1b5 commit 756f821

10 files changed

Lines changed: 196 additions & 174 deletions

File tree

content/docs/references/api/discovery.mdx

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ title: Discovery
33
description: Discovery protocol schemas
44
---
55

6-
API Capabilities Schema
6+
API Discovery Schema
77

8-
Defines what features are enabled on this ObjectOS instance.
8+
Defines the discovery response returned by the ObjectStack API discovery endpoint.
9+
The `services` map is the **single source of truth** for service availability.
910

1011
<Callout type="info">
1112
**Source:** `packages/spec/src/api/discovery.zod.ts`
@@ -14,36 +15,24 @@ Defines what features are enabled on this ObjectOS instance.
1415
## TypeScript Usage
1516

1617
```typescript
17-
import { ApiCapabilities, ApiRoutes, Discovery, ServiceInfo } from '@objectstack/spec/api';
18-
import type { ApiCapabilities, ApiRoutes, Discovery, ServiceInfo } from '@objectstack/spec/api';
18+
import { ApiRoutes, Discovery, ServiceInfo } from '@objectstack/spec/api';
19+
import type { ApiRoutes, Discovery, ServiceInfo } from '@objectstack/spec/api';
1920

2021
// Validate data
21-
const result = ApiCapabilities.parse(data);
22-
```
23-
24-
---
25-
26-
## ApiCapabilities
27-
28-
### Properties
29-
30-
| Property | Type | Required | Description |
31-
| :--- | :--- | :--- | :--- |
32-
| **graphql** | `boolean` || |
33-
| **search** | `boolean` || |
34-
| **websockets** | `boolean` || |
35-
| **files** | `boolean` || |
36-
| **analytics** | `boolean` || Is the Analytics/BI engine enabled? |
37-
| **ai** | `boolean` || Is the AI engine enabled? |
38-
| **workflow** | `boolean` || Is the Workflow engine enabled? |
39-
| **notifications** | `boolean` || Is the Notification service enabled? |
40-
| **i18n** | `boolean` || Is the i18n service enabled? |
22+
const result = Discovery.parse(data);
4123

24+
// Derive capabilities from services
25+
const isGraphQLEnabled = data.services?.graphql?.enabled ?? false;
26+
const isAIEnabled = data.services?.ai?.enabled ?? false;
27+
```
4228

4329
---
4430

4531
## ApiRoutes
4632

33+
A convenience shortcut providing a flat endpoint map for client routing.
34+
These routes are derived from the `services` map.
35+
4736
### Properties
4837

4938
| Property | Type | Required | Description |
@@ -75,12 +64,13 @@ const result = ApiCapabilities.parse(data);
7564
| **name** | `string` || |
7665
| **version** | `string` || |
7766
| **environment** | `Enum<'production' \| 'sandbox' \| 'development'>` || |
78-
| **routes** | `Object` || |
79-
| **features** | `Object` || |
67+
| **routes** | `Object` || Convenience endpoint map derived from services |
8068
| **locale** | `Object` || |
81-
| **services** | `Record<string, Object>` | optional | Per-service availability map keyed by CoreServiceName |
69+
| **services** | `Record<string, ServiceInfo>` | | Per-service availability map keyed by CoreServiceName |
8270
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |
8371

72+
> **Note:** The `capabilities`/`features` field has been removed. Service availability
73+
> is now derived from the `services` map using `services[name].enabled`.
8474
8575
---
8676

examples/minimal-auth/src/test-discovery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function testDiscovery() {
3232
let discovery = await protocol.getDiscovery();
3333
console.log(' - Auth service enabled:', discovery.services?.auth?.enabled);
3434
console.log(' - Auth service status:', discovery.services?.auth?.status);
35-
console.log(' - Auth endpoint:', discovery.endpoints?.auth || 'undefined');
35+
console.log(' - Auth route:', discovery.routes?.auth || 'undefined');
3636
console.log('');
3737

3838
// 5. Register auth plugin
@@ -49,7 +49,7 @@ async function testDiscovery() {
4949
console.log(' - Auth service status:', discovery.services?.auth?.status);
5050
console.log(' - Auth service route:', discovery.services?.auth?.route);
5151
console.log(' - Auth service provider:', discovery.services?.auth?.provider);
52-
console.log(' - Auth endpoint:', discovery.endpoints?.auth);
52+
console.log(' - Auth route:', discovery.routes?.auth);
5353
console.log('');
5454

5555
// 7. Verify the results

packages/client/src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export class ObjectStackClient {
229229
this.logger.info('Connected to ObjectStack server', {
230230
version: data.version,
231231
apiName: data.apiName,
232-
capabilities: data.capabilities
232+
services: data.services
233233
});
234234

235235
return data as DiscoveryResult;
@@ -1288,9 +1288,8 @@ export class ObjectStackClient {
12881288
*/
12891289
private getRoute(type: 'data' | 'metadata' | 'ui' | 'auth' | 'analytics' | 'storage' | 'automation' | 'packages' | 'permissions' | 'realtime' | 'workflow' | 'views' | 'notifications' | 'ai' | 'i18n'): string {
12901290
// 1. Use discovered routes if available
1291-
// Note: Spec uses 'endpoints', mapped dynamically
1292-
if (this.discoveryInfo?.endpoints && (this.discoveryInfo.endpoints as any)[type]) {
1293-
return (this.discoveryInfo.endpoints as any)[type];
1291+
if (this.discoveryInfo?.routes && (this.discoveryInfo.routes as any)[type]) {
1292+
return (this.discoveryInfo.routes as any)[type];
12941293
}
12951294

12961295
// 2. Fallback to conventions

packages/client/tests/integration/01-discovery.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ describe('Discovery & Connection', () => {
2424

2525
expect(discovery.version).toBeDefined();
2626
expect(discovery.apiName).toBeDefined();
27-
expect(discovery.capabilities).toBeDefined();
28-
expect(discovery.endpoints).toBeDefined();
27+
expect(discovery.routes).toBeDefined();
28+
expect(discovery.services).toBeDefined();
2929
});
3030
});
3131

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
2222
expect(discovery.services.auth.enabled).toBe(false);
2323
expect(discovery.services.auth.status).toBe('unavailable');
2424
expect(discovery.services.auth.message).toContain('plugin-auth');
25-
expect(discovery.capabilities.workflow).toBe(false);
25+
// capabilities removed — derive from services
26+
expect(discovery.services.workflow).toBeDefined();
27+
expect(discovery.services.workflow.enabled).toBe(false);
2628
});
2729

2830
it('should return available auth service when auth is registered', async () => {
@@ -39,10 +41,10 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
3941
expect(discovery.services.auth.status).toBe('available');
4042
expect(discovery.services.auth.route).toBe('/api/v1/auth');
4143
expect(discovery.services.auth.provider).toBe('plugin-auth');
42-
expect(discovery.endpoints.auth).toBe('/api/v1/auth');
44+
expect(discovery.routes.auth).toBe('/api/v1/auth');
4345
});
4446

45-
it('should return available workflow when automation service is registered', async () => {
47+
it('should return available automation service when registered', async () => {
4648
const mockServices = new Map<string, any>();
4749
mockServices.set('automation', { /* mock automation service */ });
4850

@@ -53,7 +55,6 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
5355
expect(discovery.services.automation).toBeDefined();
5456
expect(discovery.services.automation.enabled).toBe(true);
5557
expect(discovery.services.automation.status).toBe('available');
56-
expect(discovery.capabilities.workflow).toBe(true);
5758
});
5859

5960
it('should return multiple available services when registered', async () => {
@@ -73,17 +74,15 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
7374
// Check realtime
7475
expect(discovery.services.realtime.enabled).toBe(true);
7576
expect(discovery.services.realtime.status).toBe('available');
76-
expect(discovery.capabilities.websockets).toBe(true);
7777

7878
// Check AI
7979
expect(discovery.services.ai.enabled).toBe(true);
8080
expect(discovery.services.ai.status).toBe('available');
81-
expect(discovery.capabilities.ai).toBe(true);
8281

83-
// Endpoints should include available services
84-
expect(discovery.endpoints.auth).toBe('/api/v1/auth');
85-
expect(discovery.endpoints.realtime).toBe('/api/v1/realtime');
86-
expect(discovery.endpoints.ai).toBe('/api/v1/ai');
82+
// Routes should include available services
83+
expect(discovery.routes.auth).toBe('/api/v1/auth');
84+
expect(discovery.routes.realtime).toBe('/api/v1/realtime');
85+
expect(discovery.routes.ai).toBe('/api/v1/ai');
8786
});
8887

8988
it('should always show core services as available', async () => {
@@ -98,12 +97,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
9897
expect(discovery.services.data.status).toBe('available');
9998
expect(discovery.services.analytics.enabled).toBe(true);
10099
expect(discovery.services.analytics.status).toBe('available');
101-
102-
// Core capabilities
103-
expect(discovery.capabilities.analytics).toBe(true);
104100
});
105101

106-
it('should map file-storage service to storage endpoint', async () => {
102+
it('should map file-storage service to storage route', async () => {
107103
const mockServices = new Map<string, any>();
108104
mockServices.set('file-storage', {});
109105

@@ -113,25 +109,45 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
113109

114110
expect(discovery.services['file-storage'].enabled).toBe(true);
115111
expect(discovery.services['file-storage'].status).toBe('available');
116-
expect(discovery.endpoints.storage).toBe('/api/v1/storage');
117-
expect(discovery.capabilities.files).toBe(true);
112+
expect(discovery.routes.storage).toBe('/api/v1/storage');
118113
});
119114

120-
it('should handle workflow capability from either automation or workflow service', async () => {
121-
// Test with workflow service
122-
const mockServicesWithWorkflow = new Map<string, any>();
123-
mockServicesWithWorkflow.set('workflow', {});
124-
125-
protocol = new ObjectStackProtocolImplementation(engine, () => mockServicesWithWorkflow);
126-
let discovery = await protocol.getDiscovery();
127-
expect(discovery.capabilities.workflow).toBe(true);
128-
129-
// Test with automation service
130-
const mockServicesWithAutomation = new Map<string, any>();
131-
mockServicesWithAutomation.set('automation', {});
132-
133-
protocol = new ObjectStackProtocolImplementation(engine, () => mockServicesWithAutomation);
134-
discovery = await protocol.getDiscovery();
135-
expect(discovery.capabilities.workflow).toBe(true);
115+
it('should use consistent /api/v1/ route prefix for all services', async () => {
116+
const mockServices = new Map<string, any>();
117+
mockServices.set('auth', {});
118+
mockServices.set('automation', {});
119+
mockServices.set('ai', {});
120+
121+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
122+
123+
const discovery = await protocol.getDiscovery();
124+
125+
// All routes should use consistent /api/v1/ prefix
126+
expect(discovery.routes.data).toBe('/api/v1/data');
127+
expect(discovery.routes.metadata).toBe('/api/v1/meta');
128+
expect(discovery.routes.auth).toBe('/api/v1/auth');
129+
expect(discovery.routes.automation).toBe('/api/v1/automation');
130+
expect(discovery.routes.ai).toBe('/api/v1/ai');
131+
expect(discovery.routes.analytics).toBe('/api/v1/analytics');
132+
133+
// Service routes should match the routes map
134+
expect(discovery.services.data.route).toBe('/api/v1/data');
135+
expect(discovery.services.metadata.route).toBe('/api/v1/meta');
136+
expect(discovery.services.auth.route).toBe('/api/v1/auth');
137+
expect(discovery.services.analytics.route).toBe('/api/v1/analytics');
138+
});
139+
140+
it('should not return capabilities field (removed — use services instead)', async () => {
141+
const mockServices = new Map<string, any>();
142+
mockServices.set('workflow', {});
143+
144+
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
145+
const discovery = await protocol.getDiscovery();
146+
147+
// capabilities field should no longer exist in the response
148+
const keys = Object.keys(discovery);
149+
expect(keys).not.toContain('capabilities');
150+
// Use services to check availability instead
151+
expect(discovery.services.workflow.enabled).toBe(true);
136152
});
137153
});

packages/objectql/src/protocol.ts

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,21 @@ function simpleHash(str: string): string {
3131
* Service Configuration for Discovery
3232
* Maps service names to their routes and plugin providers
3333
*/
34-
const SERVICE_CONFIG: Record<string, { route: string; plugin: string; capability?: string }> = {
34+
const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = {
3535
auth: { route: '/api/v1/auth', plugin: 'plugin-auth' },
36-
automation: { route: '/api/v1/automation', plugin: 'plugin-automation', capability: 'workflow' },
36+
automation: { route: '/api/v1/automation', plugin: 'plugin-automation' },
3737
cache: { route: '/api/v1/cache', plugin: 'plugin-redis' },
3838
queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' },
3939
job: { route: '/api/v1/jobs', plugin: 'job-scheduler' },
4040
ui: { route: '/api/v1/ui', plugin: 'ui-plugin' },
41-
workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow', capability: 'workflow' },
42-
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime', capability: 'websockets' },
43-
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications', capability: 'notifications' },
44-
ai: { route: '/api/v1/ai', plugin: 'plugin-ai', capability: 'ai' },
45-
i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n', capability: 'i18n' },
46-
graphql: { route: '/graphql', plugin: 'plugin-graphql', capability: 'graphql' },
47-
'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage', capability: 'files' },
48-
search: { route: '/api/v1/search', plugin: 'plugin-search', capability: 'search' },
41+
workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' },
42+
realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' },
43+
notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' },
44+
ai: { route: '/api/v1/ai', plugin: 'plugin-ai' },
45+
i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n' },
46+
graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST)
47+
'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' },
48+
search: { route: '/api/v1/search', plugin: 'plugin-search' },
4949
};
5050

5151
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
@@ -64,9 +64,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
6464
// Build dynamic service info with proper typing
6565
const services: Record<string, ServiceInfo> = {
6666
// --- Kernel-provided (objectql is an example kernel implementation) ---
67-
metadata: { enabled: true, status: 'degraded' as const, route: '/api/meta', provider: 'objectql', message: 'In-memory registry only; DB persistence not yet implemented' },
68-
data: { enabled: true, status: 'available' as const, route: '/api/data', provider: 'objectql' },
69-
analytics: { enabled: true, status: 'available' as const, route: '/api/analytics', provider: 'objectql' },
67+
metadata: { enabled: true, status: 'degraded' as const, route: '/api/v1/meta', provider: 'objectql', message: 'In-memory registry only; DB persistence not yet implemented' },
68+
data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' },
69+
analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' },
7070
};
7171

7272
// Check which services are actually registered
@@ -89,22 +89,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
8989
}
9090
}
9191

92-
// Build capabilities based on available services
93-
const capabilities = {
94-
graphql: registeredServices.has('graphql'),
95-
search: registeredServices.has('search'),
96-
websockets: registeredServices.has('realtime'),
97-
files: registeredServices.has('file-storage'),
98-
analytics: true, // Always available via objectql
99-
ai: registeredServices.has('ai'),
100-
workflow: registeredServices.has('workflow') || registeredServices.has('automation'),
101-
notifications: registeredServices.has('notification'),
102-
i18n: registeredServices.has('i18n'),
103-
};
104-
105-
// Build endpoints (only include available services)
106-
// Map service names to ApiRoutes keys
107-
const serviceToEndpointKey: Record<string, keyof ApiRoutes> = {
92+
// Build routes from services — a flat convenience map for client routing
93+
const serviceToRouteKey: Record<string, keyof ApiRoutes> = {
10894
auth: 'auth',
10995
automation: 'automation',
11096
ui: 'ui',
@@ -117,31 +103,30 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
117103
'file-storage': 'storage',
118104
};
119105

120-
const optionalEndpoints: Partial<ApiRoutes> = {
121-
analytics: '/api/analytics',
106+
const optionalRoutes: Partial<ApiRoutes> = {
107+
analytics: '/api/v1/analytics',
122108
};
123109

124110
// Add routes for available plugin services
125111
for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) {
126112
if (registeredServices.has(serviceName)) {
127-
const endpointKey = serviceToEndpointKey[serviceName];
128-
if (endpointKey) {
129-
optionalEndpoints[endpointKey] = config.route;
113+
const routeKey = serviceToRouteKey[serviceName];
114+
if (routeKey) {
115+
optionalRoutes[routeKey] = config.route;
130116
}
131117
}
132118
}
133119

134-
const endpoints: ApiRoutes = {
135-
data: '/api/data',
136-
metadata: '/api/meta',
137-
...optionalEndpoints,
120+
const routes: ApiRoutes = {
121+
data: '/api/v1/data',
122+
metadata: '/api/v1/meta',
123+
...optionalRoutes,
138124
};
139125

140126
return {
141127
version: '1.0',
142128
apiName: 'ObjectStack API',
143-
capabilities,
144-
endpoints,
129+
routes,
145130
services,
146131
};
147132
}

0 commit comments

Comments
 (0)