From 060bca4811e6316813d1c55b91a04c4678c9ca23 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:06:53 +0000 Subject: [PATCH 1/2] Fix discovery API path generation to respect prefix parameter - Updated ObjectQL protocol getDiscovery() to accept optional prefix parameter - Modified SERVICE_CONFIG to use relative paths instead of hardcoded /api/v1 prefix - Updated RestServer to pass basePath to protocol.getDiscovery() - Updated GetDiscoveryRequestSchema to include optional prefix field - This fixes the issue where discovery returned hardcoded /api/v1 paths on Vercel Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/be36951d-a538-45c9-a17f-126fdc152c53 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/objectql/src/protocol.ts | 58 ++++++++++++++------------- packages/rest/src/rest-server.ts | 11 +++-- packages/spec/src/api/protocol.zod.ts | 7 +++- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 76407929b..ff3a61f66 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -31,23 +31,23 @@ function simpleHash(str: string): string { /** * Service Configuration for Discovery - * Maps service names to their routes and plugin providers + * Maps service names to their relative paths (without prefix) and plugin providers */ -const SERVICE_CONFIG: Record = { - auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, - automation: { route: '/api/v1/automation', plugin: 'plugin-automation' }, - cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, - queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' }, - job: { route: '/api/v1/jobs', plugin: 'job-scheduler' }, - ui: { route: '/api/v1/ui', plugin: 'ui-plugin' }, - workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' }, - realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' }, - notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' }, - ai: { route: '/api/v1/ai', plugin: 'plugin-ai' }, - i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' }, - graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) - 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' }, - search: { route: '/api/v1/search', plugin: 'plugin-search' }, +const SERVICE_CONFIG: Record = { + auth: { path: '/auth', plugin: 'plugin-auth' }, + automation: { path: '/automation', plugin: 'plugin-automation' }, + cache: { path: '/cache', plugin: 'plugin-redis' }, + queue: { path: '/queue', plugin: 'plugin-bullmq' }, + job: { path: '/jobs', plugin: 'job-scheduler' }, + ui: { path: '/ui', plugin: 'ui-plugin' }, + workflow: { path: '/workflow', plugin: 'plugin-workflow' }, + realtime: { path: '/realtime', plugin: 'plugin-realtime' }, + notification: { path: '/notifications', plugin: 'plugin-notifications' }, + ai: { path: '/ai', plugin: 'plugin-ai' }, + i18n: { path: '/i18n', plugin: 'service-i18n' }, + graphql: { path: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) + 'file-storage': { path: '/storage', plugin: 'plugin-storage' }, + search: { path: '/search', plugin: 'plugin-search' }, }; export class ObjectStackProtocolImplementation implements ObjectStackProtocol { @@ -69,26 +69,29 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return svc; } - async getDiscovery() { + async getDiscovery(options?: { prefix?: string }) { + const prefix = options?.prefix || '/api/v1'; + // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); - + // Build dynamic service info with proper typing const services: Record = { // --- Kernel-provided (objectql is an example kernel implementation) --- - metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' }, - data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' }, - analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' }, + metadata: { enabled: true, status: 'available' as const, route: `${prefix}/meta`, provider: 'objectql' }, + data: { enabled: true, status: 'available' as const, route: `${prefix}/data`, provider: 'objectql' }, + analytics: { enabled: true, status: 'available' as const, route: `${prefix}/analytics`, provider: 'objectql' }, }; // Check which services are actually registered for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) { if (registeredServices.has(serviceName)) { // Service is registered and available + const route = config.path === '/graphql' ? '/graphql' : `${prefix}${config.path}`; services[serviceName] = { enabled: true, status: 'available' as const, - route: config.route, + route, provider: config.plugin, }; } else { @@ -116,7 +119,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; const optionalRoutes: Partial = { - analytics: '/api/v1/analytics', + analytics: `${prefix}/analytics`, }; // Add routes for available plugin services @@ -124,7 +127,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { if (registeredServices.has(serviceName)) { const routeKey = serviceToRouteKey[serviceName]; if (routeKey) { - optionalRoutes[routeKey] = config.route; + const route = config.path === '/graphql' ? '/graphql' : `${prefix}${config.path}`; + optionalRoutes[routeKey] = route; } } } @@ -134,7 +138,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { services['feed'] = { enabled: true, status: 'available' as const, - route: '/api/v1/data', + route: `${prefix}/data`, provider: 'service-feed', }; } else { @@ -146,8 +150,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } const routes: ApiRoutes = { - data: '/api/v1/data', - metadata: '/api/v1/meta', + data: `${prefix}/data`, + metadata: `${prefix}/meta`, ...optionalRoutes, }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4ae11a701..9b373b19d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -218,17 +218,20 @@ export class RestServer { private registerDiscoveryEndpoints(basePath: string): void { const discoveryHandler = async (_req: any, res: any) => { try { - const discovery = await this.protocol.getDiscovery(); - + const discovery = await this.protocol.getDiscovery({ prefix: basePath }); + // Override discovery information with actual server configuration discovery.version = this.config.api.version; - + + // The protocol now returns routes with the correct prefix, + // but we still override them here to ensure consistency with + // the RestServer configuration (in case of custom prefix overrides) if (discovery.routes) { // Ensure routes match the actual mounted paths if (this.config.api.enableCrud) { discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`; } - + if (this.config.api.enableMetadata) { discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`; } diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index ea90f4117..66468cc39 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -112,9 +112,12 @@ export const AutomationTriggerResponseSchema = z.object({ /** * Get API Discovery Request - * No parameters needed + * Optional prefix parameter to override the default API path prefix */ -export const GetDiscoveryRequestSchema = z.object({}); +export const GetDiscoveryRequestSchema = z.object({ + /** Optional API path prefix (e.g., '/api/v1'). Defaults to '/api/v1' if not provided. */ + prefix: z.string().optional(), +}).optional(); /** * Get API Discovery Response From d84fb63e361c3980c17a0b6d652e504f3a179fbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:02:47 +0000 Subject: [PATCH 2/2] Fix discovery endpoint returning routes without /api/v1 prefix on Vercel Root cause: all adapters registered discovery only at GET prefix (root), not at GET prefix/discovery. When frontend fetched /api/v1/discovery, the catch-all stripped the prefix and called dispatch('/discovery') which called getDiscoveryInfo('') with empty prefix, returning /data instead of /api/v1/data. Locally this was hidden because MSW mode doesn't register real services, so the wrong routes were never used. On Vercel, all services are registered, breaking the API console. Fixes: - http-dispatcher: add optional prefix param to dispatch() so discovery handler uses the correct prefix instead of empty string - hono adapter: add explicit GET prefix/discovery route (Vercel fix) - fastify, nuxt adapters: add explicit GET prefix/discovery route - sveltekit, nextjs adapters: handle 'discovery' segment before catch-all - express adapter: pass prefix to dispatch() (already had explicit route) - MSW plugin: add explicit baseUrl/discovery handler + pass baseUrl to dispatch() - Revert wrong protocol.ts/rest-server.ts/spec changes from previous commit - Update hono test assertions for new 6th prefix parameter Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/ae9fa3e0-4bf7-45e8-b4ee-4816ad2f6917 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/adapters/express/src/index.ts | 2 +- packages/adapters/fastify/src/index.ts | 6 +- packages/adapters/hono/src/hono.test.ts | 45 ++++++++++++++ packages/adapters/hono/src/index.ts | 6 +- packages/adapters/nextjs/src/index.ts | 6 +- packages/adapters/nuxt/src/index.ts | 9 ++- packages/adapters/sveltekit/src/index.ts | 9 ++- packages/objectql/src/protocol.ts | 58 +++++++++---------- packages/plugins/plugin-msw/src/msw-plugin.ts | 20 ++++++- packages/rest/src/rest-server.ts | 11 ++-- packages/runtime/src/http-dispatcher.ts | 5 +- packages/spec/src/api/protocol.zod.ts | 7 +-- 12 files changed, 131 insertions(+), 53 deletions(-) diff --git a/packages/adapters/express/src/index.ts b/packages/adapters/express/src/index.ts index 1064d1e8c..afc5d4605 100644 --- a/packages/adapters/express/src/index.ts +++ b/packages/adapters/express/src/index.ts @@ -167,7 +167,7 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { const subPath = '/' + (req.params as any).path; const method = req.method; const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? req.body : undefined; - const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res }); + const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res }, prefix); return sendResult(result, res); } catch (err: any) { return errorResponse(err, res); diff --git a/packages/adapters/fastify/src/index.ts b/packages/adapters/fastify/src/index.ts index 3c1a6d4be..246555169 100644 --- a/packages/adapters/fastify/src/index.ts +++ b/packages/adapters/fastify/src/index.ts @@ -80,6 +80,10 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); + fastify.get(`${prefix}/discovery`, async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); + }); + // --- .well-known --- fastify.get('/.well-known/objectstack', async (_request: FastifyRequest, reply: FastifyReply) => { return reply.redirect(prefix); @@ -172,7 +176,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti const subPath = urlPath.substring(prefix.length); const method = request.method; const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? request.body : undefined; - const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }); + const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }, prefix); return sendResult(result, reply); } catch (err: any) { return errorResponse(err, reply); diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 5310df096..57161f9ad 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -97,6 +97,14 @@ describe('createHonoApp', () => { expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api'); }); + it('GET /api/discovery returns discovery info with correct prefix', async () => { + const res = await app.request('/api/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api'); + }); + it('uses custom prefix for discovery', async () => { const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' }); const res = await customApp.request('/v2'); @@ -105,6 +113,15 @@ describe('createHonoApp', () => { expect(json.data).toBeDefined(); expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2'); }); + + it('uses custom prefix for /discovery route', async () => { + const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' }); + const res = await customApp.request('/v2/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2'); + }); }); describe('.well-known Endpoint', () => { @@ -277,6 +294,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -294,6 +312,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -306,6 +325,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -318,6 +338,7 @@ describe('createHonoApp', () => { undefined, expect.objectContaining({ package: 'com.acme.crm' }), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -330,6 +351,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -347,6 +369,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -364,6 +387,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -384,6 +408,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -396,6 +421,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -413,6 +439,7 @@ describe('createHonoApp', () => { body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -425,6 +452,7 @@ describe('createHonoApp', () => { undefined, expect.objectContaining({ status: 'active' }), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -446,6 +474,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -458,6 +487,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -470,6 +500,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); @@ -482,6 +513,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api', ); }); }); @@ -571,6 +603,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); @@ -585,6 +618,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); @@ -598,6 +632,16 @@ describe('createHonoApp', () => { expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1'); }); + it('routes /api/v1/discovery through outer→inner delegation with correct prefix', async () => { + const outerApp = createVercelApp(); + + const res = await outerApp.request('/api/v1/discovery'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1'); + }); + it('routes /api/v1/data/account through outer→inner delegation', async () => { const outerApp = createVercelApp(); @@ -609,6 +653,7 @@ describe('createHonoApp', () => { undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), + '/api/v1', ); }); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 3c5736e37..1ce0fbdd4 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -112,6 +112,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); + app.get(`${prefix}/discovery`, async (c) => { + return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); + }); + // --- .well-known --- app.get('/.well-known/objectstack', (c) => { return c.redirect(prefix); @@ -202,7 +206,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { const url = new URL(c.req.url); url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }); + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix); return toResponse(c, result); } catch (err: any) { return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/adapters/nextjs/src/index.ts b/packages/adapters/nextjs/src/index.ts index 9f7a6f6fd..5403d0296 100644 --- a/packages/adapters/nextjs/src/index.ts +++ b/packages/adapters/nextjs/src/index.ts @@ -66,6 +66,10 @@ export function createRouteHandler(options: NextAdapterOptions) { return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') }); } + if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') { + return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') }); + } + try { const rawRequest = req; @@ -135,7 +139,7 @@ export function createRouteHandler(options: NextAdapterOptions) { const queryParams: Record = {}; url.searchParams.forEach((val, key) => queryParams[key] = val); - const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }); + const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }, options.prefix || '/api'); return toResponse(result); } catch (err: any) { diff --git a/packages/adapters/nuxt/src/index.ts b/packages/adapters/nuxt/src/index.ts index 108cea599..f7ac09e8c 100644 --- a/packages/adapters/nuxt/src/index.ts +++ b/packages/adapters/nuxt/src/index.ts @@ -95,6 +95,13 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); + router.get( + `${prefix}/discovery`, + defineEventHandler(async () => { + return { data: await dispatcher.getDiscoveryInfo(prefix) }; + }), + ); + // --- .well-known --- router.get( '/.well-known/objectstack', @@ -210,7 +217,7 @@ export function createH3Router(options: NuxtAdapterOptions): Router { ? await readBody(event) : undefined; const query = getQuery(event); - const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }); + const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }, prefix); return toResponse(event, result); } catch (err: any) { return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/adapters/sveltekit/src/index.ts b/packages/adapters/sveltekit/src/index.ts index 578b871a7..e2efcf64b 100644 --- a/packages/adapters/sveltekit/src/index.ts +++ b/packages/adapters/sveltekit/src/index.ts @@ -106,6 +106,13 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { }); } + if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') { + return new Response(JSON.stringify({ data: await dispatcher.getDiscoveryInfo(prefix) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + try { // --- Auth (needs auth service integration) --- if (segments[0] === 'auth') { @@ -176,7 +183,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { const queryParams: Record = {}; url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }); + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }, prefix); return toResponse(result); } catch (err: any) { return errorJson(err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index ff3a61f66..76407929b 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -31,23 +31,23 @@ function simpleHash(str: string): string { /** * Service Configuration for Discovery - * Maps service names to their relative paths (without prefix) and plugin providers + * Maps service names to their routes and plugin providers */ -const SERVICE_CONFIG: Record = { - auth: { path: '/auth', plugin: 'plugin-auth' }, - automation: { path: '/automation', plugin: 'plugin-automation' }, - cache: { path: '/cache', plugin: 'plugin-redis' }, - queue: { path: '/queue', plugin: 'plugin-bullmq' }, - job: { path: '/jobs', plugin: 'job-scheduler' }, - ui: { path: '/ui', plugin: 'ui-plugin' }, - workflow: { path: '/workflow', plugin: 'plugin-workflow' }, - realtime: { path: '/realtime', plugin: 'plugin-realtime' }, - notification: { path: '/notifications', plugin: 'plugin-notifications' }, - ai: { path: '/ai', plugin: 'plugin-ai' }, - i18n: { path: '/i18n', plugin: 'service-i18n' }, - graphql: { path: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) - 'file-storage': { path: '/storage', plugin: 'plugin-storage' }, - search: { path: '/search', plugin: 'plugin-search' }, +const SERVICE_CONFIG: Record = { + auth: { route: '/api/v1/auth', plugin: 'plugin-auth' }, + automation: { route: '/api/v1/automation', plugin: 'plugin-automation' }, + cache: { route: '/api/v1/cache', plugin: 'plugin-redis' }, + queue: { route: '/api/v1/queue', plugin: 'plugin-bullmq' }, + job: { route: '/api/v1/jobs', plugin: 'job-scheduler' }, + ui: { route: '/api/v1/ui', plugin: 'ui-plugin' }, + workflow: { route: '/api/v1/workflow', plugin: 'plugin-workflow' }, + realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' }, + notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' }, + ai: { route: '/api/v1/ai', plugin: 'plugin-ai' }, + i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' }, + graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) + 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' }, + search: { route: '/api/v1/search', plugin: 'plugin-search' }, }; export class ObjectStackProtocolImplementation implements ObjectStackProtocol { @@ -69,29 +69,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return svc; } - async getDiscovery(options?: { prefix?: string }) { - const prefix = options?.prefix || '/api/v1'; - + async getDiscovery() { // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); - + // Build dynamic service info with proper typing const services: Record = { // --- Kernel-provided (objectql is an example kernel implementation) --- - metadata: { enabled: true, status: 'available' as const, route: `${prefix}/meta`, provider: 'objectql' }, - data: { enabled: true, status: 'available' as const, route: `${prefix}/data`, provider: 'objectql' }, - analytics: { enabled: true, status: 'available' as const, route: `${prefix}/analytics`, provider: 'objectql' }, + metadata: { enabled: true, status: 'available' as const, route: '/api/v1/meta', provider: 'objectql' }, + data: { enabled: true, status: 'available' as const, route: '/api/v1/data', provider: 'objectql' }, + analytics: { enabled: true, status: 'available' as const, route: '/api/v1/analytics', provider: 'objectql' }, }; // Check which services are actually registered for (const [serviceName, config] of Object.entries(SERVICE_CONFIG)) { if (registeredServices.has(serviceName)) { // Service is registered and available - const route = config.path === '/graphql' ? '/graphql' : `${prefix}${config.path}`; services[serviceName] = { enabled: true, status: 'available' as const, - route, + route: config.route, provider: config.plugin, }; } else { @@ -119,7 +116,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; const optionalRoutes: Partial = { - analytics: `${prefix}/analytics`, + analytics: '/api/v1/analytics', }; // Add routes for available plugin services @@ -127,8 +124,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { if (registeredServices.has(serviceName)) { const routeKey = serviceToRouteKey[serviceName]; if (routeKey) { - const route = config.path === '/graphql' ? '/graphql' : `${prefix}${config.path}`; - optionalRoutes[routeKey] = route; + optionalRoutes[routeKey] = config.route; } } } @@ -138,7 +134,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { services['feed'] = { enabled: true, status: 'available' as const, - route: `${prefix}/data`, + route: '/api/v1/data', provider: 'service-feed', }; } else { @@ -150,8 +146,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } const routes: ApiRoutes = { - data: `${prefix}/data`, - metadata: `${prefix}/meta`, + data: '/api/v1/data', + metadata: '/api/v1/meta', ...optionalRoutes, }; diff --git a/packages/plugins/plugin-msw/src/msw-plugin.ts b/packages/plugins/plugin-msw/src/msw-plugin.ts index 3393e43c9..be480f560 100644 --- a/packages/plugins/plugin-msw/src/msw-plugin.ts +++ b/packages/plugins/plugin-msw/src/msw-plugin.ts @@ -228,6 +228,23 @@ export class MSWPlugin implements Plugin { }) ); + // Explicit /discovery endpoint — must be registered before catch-all + // so dispatch() is not called with an empty prefix. + this.handlers.push( + http.get(`*${baseUrl}`, async () => { + if (this.dispatcher) { + return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) }); + } + return HttpResponse.json({ data: { version: 'v1', url: baseUrl } }); + }), + http.get(`*${baseUrl}/discovery`, async () => { + if (this.dispatcher) { + return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) }); + } + return HttpResponse.json({ data: { version: 'v1', url: baseUrl } }); + }) + ); + if (this.dispatcher) { const dispatcher = this.dispatcher; @@ -271,7 +288,8 @@ export class MSWPlugin implements Plugin { path, body, query, - { request } + { request }, + baseUrl ); if (result.handled) { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9b373b19d..4ae11a701 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -218,20 +218,17 @@ export class RestServer { private registerDiscoveryEndpoints(basePath: string): void { const discoveryHandler = async (_req: any, res: any) => { try { - const discovery = await this.protocol.getDiscovery({ prefix: basePath }); - + const discovery = await this.protocol.getDiscovery(); + // Override discovery information with actual server configuration discovery.version = this.config.api.version; - - // The protocol now returns routes with the correct prefix, - // but we still override them here to ensure consistency with - // the RestServer configuration (in case of custom prefix overrides) + if (discovery.routes) { // Ensure routes match the actual mounted paths if (this.config.api.enableCrud) { discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`; } - + if (this.config.api.enableMetadata) { discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`; } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 814389603..b410057c2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1317,15 +1317,14 @@ export class HttpDispatcher { * Main Dispatcher Entry Point * Routes the request to the appropriate handler based on path and precedence */ - async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise { + async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths // 0. Discovery Endpoint (GET /discovery or GET /) // Standard route: /discovery (protocol-compliant) // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') { - // We use '' as prefix since we are internal dispatcher - const info = await this.getDiscoveryInfo(''); + const info = await this.getDiscoveryInfo(prefix ?? ''); return { handled: true, response: this.success(info) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 66468cc39..ea90f4117 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -112,12 +112,9 @@ export const AutomationTriggerResponseSchema = z.object({ /** * Get API Discovery Request - * Optional prefix parameter to override the default API path prefix + * No parameters needed */ -export const GetDiscoveryRequestSchema = z.object({ - /** Optional API path prefix (e.g., '/api/v1'). Defaults to '/api/v1' if not provided. */ - prefix: z.string().optional(), -}).optional(); +export const GetDiscoveryRequestSchema = z.object({}); /** * Get API Discovery Response