Skip to content

Commit 61429f5

Browse files
authored
Merge pull request #1005 from objectstack-ai/copilot/update-studio-api-console
2 parents 5ff953a + b029785 commit 61429f5

4 files changed

Lines changed: 321 additions & 18 deletions

File tree

apps/studio/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# @objectstack/studio
22

3+
## Unreleased
4+
5+
### Enhancements
6+
7+
- **API Console: Complete service endpoint discovery from `/api/v1/discovery`**
8+
- The API console now uses the discovery endpoint's `services` and `routes` maps to dynamically populate endpoints for all enabled services (AI, Workflow, Realtime, Notifications, Analytics, Automation, i18n, UI, Feed, Storage)
9+
- Previously, only System, Auth, Metadata, and Data CRUD endpoints were shown; AI and other service endpoints were missing
10+
- Added `SERVICE_ENDPOINT_CATALOG` — a well-known endpoint catalog aligned with `plugin-rest-api.zod.ts` route definitions
11+
- Added `buildServiceEndpoints()` helper for generating endpoint definitions from a service name and route prefix
12+
- Updated group sort order to include service groups between Auth and Metadata
13+
14+
### Fixes
15+
16+
- **Vercel deployment: Fix `@vercel/node@3` runtime error**
17+
- Removed the `functions.runtime` config from `vercel.json` — the `runtime` field is only for custom/community runtimes, not Node.js. Vercel auto-detects the pre-bundled `api/index.js` as a Node.js serverless function.
18+
319
## 3.3.1
420

521
### Patch Changes

apps/studio/src/hooks/use-api-discovery.ts

Lines changed: 184 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export interface EndpointGroup {
2121
endpoints: EndpointDef[];
2222
}
2323

24+
/** Shape of a single entry in the service endpoint catalog. */
25+
interface ServiceEndpointEntry {
26+
method: HttpMethod;
27+
/** Relative path appended to the service route prefix (e.g. '/chat'). */
28+
path: string;
29+
desc: string;
30+
bodyTemplate?: Record<string, unknown>;
31+
}
32+
2433
// ─── Static system endpoints ────────────────────────────────────────
2534

2635
/** Metadata types that should be excluded from the endpoint tree */
@@ -43,6 +52,146 @@ function buildAuthEndpoints(authBase: string): EndpointDef[] {
4352
];
4453
}
4554

55+
// ─── Service endpoint catalog ───────────────────────────────────────
56+
57+
/**
58+
* Well-known endpoints for each service, keyed by the service name
59+
* as it appears in the discovery response's `services` map.
60+
*
61+
* The `group` label used in the UI is derived from the map key.
62+
* Paths are relative to the service route prefix returned by discovery.
63+
*
64+
* This catalog is aligned with:
65+
* - packages/spec/src/api/plugin-rest-api.zod.ts (DEFAULT_*_ROUTES)
66+
* - packages/services/service-ai/src/routes/ai-routes.ts (buildAIRoutes)
67+
*/
68+
export const SERVICE_ENDPOINT_CATALOG: Record<string, { group: string; defaultRoute: string; endpoints: ServiceEndpointEntry[] }> = {
69+
ai: {
70+
group: 'AI',
71+
defaultRoute: '/api/v1/ai',
72+
endpoints: [
73+
{ method: 'POST', path: '/chat', desc: 'Chat completion', bodyTemplate: { messages: [{ role: 'user', content: '' }] } },
74+
{ method: 'POST', path: '/chat/stream', desc: 'Streaming chat (SSE)', bodyTemplate: { messages: [{ role: 'user', content: '' }] } },
75+
{ method: 'POST', path: '/complete', desc: 'Text completion', bodyTemplate: { prompt: '' } },
76+
{ method: 'GET', path: '/models', desc: 'List available models' },
77+
{ method: 'POST', path: '/nlq', desc: 'Natural language query', bodyTemplate: { query: '' } },
78+
{ method: 'POST', path: '/suggest', desc: 'AI-powered suggestions', bodyTemplate: { object: '', field: '', context: {} } },
79+
{ method: 'POST', path: '/insights', desc: 'AI-generated insights', bodyTemplate: { object: '', recordIds: [] } },
80+
{ method: 'POST', path: '/conversations', desc: 'Create conversation', bodyTemplate: {} },
81+
{ method: 'GET', path: '/conversations', desc: 'List conversations' },
82+
{ method: 'POST', path: '/conversations/:id/messages', desc: 'Add message to conversation', bodyTemplate: { role: 'user', content: '' } },
83+
{ method: 'DELETE', path: '/conversations/:id', desc: 'Delete conversation' },
84+
],
85+
},
86+
workflow: {
87+
group: 'Workflow',
88+
defaultRoute: '/api/v1/workflow',
89+
endpoints: [
90+
{ method: 'GET', path: '/:object/config', desc: 'Get workflow configuration' },
91+
{ method: 'GET', path: '/:object/:recordId/state', desc: 'Get workflow state' },
92+
{ method: 'POST', path: '/:object/:recordId/transition', desc: 'Execute workflow transition', bodyTemplate: { targetState: '' } },
93+
{ method: 'POST', path: '/:object/:recordId/approve', desc: 'Approve workflow step', bodyTemplate: { comment: '' } },
94+
{ method: 'POST', path: '/:object/:recordId/reject', desc: 'Reject workflow step', bodyTemplate: { comment: '' } },
95+
],
96+
},
97+
realtime: {
98+
group: 'Realtime',
99+
defaultRoute: '/api/v1/realtime',
100+
endpoints: [
101+
{ method: 'POST', path: '/connect', desc: 'Establish realtime connection', bodyTemplate: { transport: 'websocket' } },
102+
{ method: 'POST', path: '/disconnect', desc: 'Close realtime connection', bodyTemplate: { connectionId: '' } },
103+
{ method: 'POST', path: '/subscribe', desc: 'Subscribe to channel', bodyTemplate: { channel: '' } },
104+
{ method: 'POST', path: '/unsubscribe', desc: 'Unsubscribe from channel', bodyTemplate: { channel: '' } },
105+
{ method: 'PUT', path: '/presence/:channel', desc: 'Set presence state', bodyTemplate: { status: 'online' } },
106+
{ method: 'GET', path: '/presence/:channel', desc: 'Get channel presence' },
107+
],
108+
},
109+
notification: {
110+
group: 'Notifications',
111+
defaultRoute: '/api/v1/notifications',
112+
endpoints: [
113+
{ method: 'GET', path: '', desc: 'List notifications' }, // empty path → hits the base route prefix
114+
{ method: 'POST', path: '/devices', desc: 'Register device for push', bodyTemplate: { token: '', platform: 'web' } },
115+
{ method: 'DELETE', path: '/devices/:deviceId', desc: 'Unregister device' },
116+
{ method: 'GET', path: '/preferences', desc: 'Get notification preferences' },
117+
{ method: 'PATCH', path: '/preferences', desc: 'Update notification preferences', bodyTemplate: { email: true, push: true } },
118+
{ method: 'POST', path: '/read', desc: 'Mark notifications as read', bodyTemplate: { ids: [] } },
119+
{ method: 'POST', path: '/read/all', desc: 'Mark all as read' },
120+
],
121+
},
122+
analytics: {
123+
group: 'Analytics',
124+
defaultRoute: '/api/v1/analytics',
125+
endpoints: [
126+
{ method: 'POST', path: '/query', desc: 'Execute analytics query', bodyTemplate: { measures: [], dimensions: [] } },
127+
{ method: 'GET', path: '/meta', desc: 'Get analytics metadata' },
128+
],
129+
},
130+
automation: {
131+
group: 'Automation',
132+
defaultRoute: '/api/v1/automation',
133+
endpoints: [
134+
{ method: 'POST', path: '/trigger', desc: 'Trigger automation', bodyTemplate: { name: '', params: {} } },
135+
],
136+
},
137+
i18n: {
138+
group: 'i18n',
139+
defaultRoute: '/api/v1/i18n',
140+
endpoints: [
141+
{ method: 'GET', path: '/locales', desc: 'Get available locales' },
142+
{ method: 'GET', path: '/translations/:locale', desc: 'Get translations for locale' },
143+
{ method: 'GET', path: '/labels/:object/:locale', desc: 'Get translated field labels' },
144+
],
145+
},
146+
ui: {
147+
group: 'UI',
148+
defaultRoute: '/api/v1/ui',
149+
endpoints: [
150+
{ method: 'GET', path: '/views', desc: 'List views' },
151+
{ method: 'GET', path: '/views/:id', desc: 'Get view by ID' },
152+
{ method: 'POST', path: '/views', desc: 'Create view', bodyTemplate: { name: '', object: '', type: 'list' } },
153+
{ method: 'PATCH', path: '/views/:id', desc: 'Update view', bodyTemplate: { name: '' } },
154+
{ method: 'DELETE', path: '/views/:id', desc: 'Delete view' },
155+
],
156+
},
157+
feed: {
158+
group: 'Feed',
159+
defaultRoute: '/api/v1/feed',
160+
endpoints: [
161+
{ method: 'GET', path: '/:object/:recordId', desc: 'Get feed items' },
162+
{ method: 'POST', path: '/:object/:recordId', desc: 'Post feed item', bodyTemplate: { body: '' } },
163+
],
164+
},
165+
storage: {
166+
group: 'Storage',
167+
defaultRoute: '/api/v1/storage',
168+
endpoints: [
169+
{ method: 'POST', path: '/upload', desc: 'Upload file (multipart/form-data)' },
170+
{ method: 'GET', path: '/:fileId', desc: 'Download file' },
171+
{ method: 'DELETE', path: '/:fileId', desc: 'Delete file' },
172+
],
173+
},
174+
};
175+
176+
/**
177+
* Build endpoint definitions for a discovered service.
178+
*
179+
* @param serviceName Key in the discovery `services` map (e.g. 'ai')
180+
* @param routePrefix Base route path (e.g. '/api/v1/ai')
181+
*/
182+
export function buildServiceEndpoints(serviceName: string, routePrefix: string): EndpointDef[] {
183+
const catalog = SERVICE_ENDPOINT_CATALOG[serviceName];
184+
if (!catalog) return [];
185+
186+
return catalog.endpoints.map(ep => ({
187+
method: ep.method,
188+
path: `${routePrefix}${ep.path}`,
189+
desc: ep.desc,
190+
group: catalog.group,
191+
...(ep.bodyTemplate ? { bodyTemplate: ep.bodyTemplate } : {}),
192+
}));
193+
}
194+
46195
// ─── Hook ───────────────────────────────────────────────────────────
47196

48197
export function useApiDiscovery() {
@@ -57,20 +206,41 @@ export function useApiDiscovery() {
57206
setError(null);
58207

59208
try {
60-
// 1. Resolve auth base path from discovery
209+
// 1. Fetch discovery response — the source of truth for available services
61210
let authBase = '/api/v1/auth';
211+
let discoveredServices: Record<string, { enabled: boolean; route?: string }> = {};
212+
let discoveredRoutes: Record<string, string> = {};
213+
62214
try {
63215
const discRes = await fetch('/api/v1/discovery');
64216
if (discRes.ok) {
65217
const discData = await discRes.json();
66-
const routes = discData?.data?.routes ?? discData?.routes;
67-
if (routes?.auth) authBase = routes.auth;
218+
const data = discData?.data ?? discData;
219+
discoveredRoutes = data?.routes ?? {};
220+
discoveredServices = data?.services ?? {};
221+
if (discoveredRoutes.auth) authBase = discoveredRoutes.auth;
68222
}
69223
} catch {
70-
// Keep default /api/v1/auth
224+
// Keep defaults — discovery may not be available
225+
}
226+
227+
// 2. Build service endpoints from discovery
228+
const serviceEndpoints: EndpointDef[] = [];
229+
for (const [serviceName, catalog] of Object.entries(SERVICE_ENDPOINT_CATALOG)) {
230+
const serviceInfo = discoveredServices[serviceName];
231+
const isEnabled = serviceInfo?.enabled ?? false;
232+
233+
// Use route from discovery services, discovery routes map, or catalog default
234+
const routePrefix = serviceInfo?.route
235+
?? discoveredRoutes[serviceName]
236+
?? catalog.defaultRoute;
237+
238+
if (isEnabled) {
239+
serviceEndpoints.push(...buildServiceEndpoints(serviceName, routePrefix));
240+
}
71241
}
72242

73-
// 2. Fetch metadata types
243+
// 3. Fetch metadata types
74244
let metaTypes: string[] = [];
75245
try {
76246
const typesResult = await client.meta.getTypes();
@@ -83,7 +253,7 @@ export function useApiDiscovery() {
83253
// Meta types may not be available
84254
}
85255

86-
// 3. Fetch object names from metadata
256+
// 4. Fetch object names from metadata
87257
let objectNames: string[] = [];
88258
try {
89259
const objectType = metaTypes.includes('objects') ? 'objects' : metaTypes.includes('object') ? 'object' : null;
@@ -99,7 +269,7 @@ export function useApiDiscovery() {
99269
// Objects may not be available
100270
}
101271

102-
// 4. Build dynamic data endpoints for each object
272+
// 5. Build dynamic data endpoints for each object
103273
const dataEndpoints: EndpointDef[] = objectNames.flatMap(name => [
104274
{ method: 'GET' as HttpMethod, path: `/api/v1/data/${name}`, desc: `List ${name}`, group: `Data: ${name}` },
105275
{ method: 'POST' as HttpMethod, path: `/api/v1/data/${name}`, desc: `Create ${name}`, group: `Data: ${name}`, bodyTemplate: { name: 'example' } },
@@ -108,7 +278,7 @@ export function useApiDiscovery() {
108278
{ method: 'DELETE' as HttpMethod, path: `/api/v1/data/${name}/:id`, desc: `Delete ${name}`, group: `Data: ${name}` },
109279
]);
110280

111-
// 5. Build metadata endpoints for each type
281+
// 6. Build metadata endpoints for each type
112282
const metaEndpoints: EndpointDef[] = metaTypes
113283
.filter(t => !EXCLUDED_META_TYPES.includes(t))
114284
.map(type => ({
@@ -118,33 +288,34 @@ export function useApiDiscovery() {
118288
group: 'Metadata',
119289
}));
120290

121-
// 6. Build per-object schema endpoints
291+
// 7. Build per-object schema endpoints
122292
const schemaEndpoints: EndpointDef[] = objectNames.map(name => ({
123293
method: 'GET' as HttpMethod,
124294
path: `/api/v1/meta/object/${name}`,
125295
desc: `${name} schema`,
126296
group: 'Metadata',
127297
}));
128298

129-
// 7. Combine all endpoints
299+
// 8. Combine all endpoints
130300
const all = [
131301
...SYSTEM_ENDPOINTS,
132302
...buildAuthEndpoints(authBase),
303+
...serviceEndpoints,
133304
...metaEndpoints,
134305
...schemaEndpoints,
135306
...dataEndpoints,
136307
];
137308

138-
// 8. Group endpoints
309+
// 9. Group endpoints
139310
const groupMap = new Map<string, EndpointDef[]>();
140311
for (const ep of all) {
141312
const existing = groupMap.get(ep.group) || [];
142313
existing.push(ep);
143314
groupMap.set(ep.group, existing);
144315
}
145316

146-
// Sort groups: System, Auth, Metadata first, then Data groups alphabetically
147-
const GROUP_SORT_ORDER = ['System', 'Auth', 'Metadata'];
317+
// Sort groups: System, Auth, service groups, Metadata, then Data groups alphabetically
318+
const GROUP_SORT_ORDER = ['System', 'Auth', 'AI', 'Workflow', 'Realtime', 'Notifications', 'Analytics', 'Automation', 'i18n', 'UI', 'Feed', 'Storage', 'Metadata'];
148319
const grouped = Array.from(groupMap.entries())
149320
.sort(([a], [b]) => {
150321
const aIdx = GROUP_SORT_ORDER.indexOf(a);

0 commit comments

Comments
 (0)