@@ -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
48197export 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