Skip to content

Commit 12bade7

Browse files
authored
Merge pull request #1064 from objectstack-ai/claude/fix-discovery-api-path
2 parents 00fc136 + d84fb63 commit 12bade7

9 files changed

Lines changed: 98 additions & 10 deletions

File tree

packages/adapters/express/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router {
167167
const subPath = '/' + (req.params as any).path;
168168
const method = req.method;
169169
const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? req.body : undefined;
170-
const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res });
170+
const result = await dispatcher.dispatch(method, subPath, body, req.query, { request: req, response: res }, prefix);
171171
return sendResult(result, res);
172172
} catch (err: any) {
173173
return errorResponse(err, res);

packages/adapters/fastify/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti
8080
return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) });
8181
});
8282

83+
fastify.get(`${prefix}/discovery`, async (_request: FastifyRequest, reply: FastifyReply) => {
84+
return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) });
85+
});
86+
8387
// --- .well-known ---
8488
fastify.get('/.well-known/objectstack', async (_request: FastifyRequest, reply: FastifyReply) => {
8589
return reply.redirect(prefix);
@@ -172,7 +176,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti
172176
const subPath = urlPath.substring(prefix.length);
173177
const method = request.method;
174178
const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? request.body : undefined;
175-
const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw });
179+
const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }, prefix);
176180
return sendResult(result, reply);
177181
} catch (err: any) {
178182
return errorResponse(err, reply);

packages/adapters/hono/src/hono.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ describe('createHonoApp', () => {
9797
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
9898
});
9999

100+
it('GET /api/discovery returns discovery info with correct prefix', async () => {
101+
const res = await app.request('/api/discovery');
102+
expect(res.status).toBe(200);
103+
const json = await res.json();
104+
expect(json.data).toBeDefined();
105+
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
106+
});
107+
100108
it('uses custom prefix for discovery', async () => {
101109
const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' });
102110
const res = await customApp.request('/v2');
@@ -105,6 +113,15 @@ describe('createHonoApp', () => {
105113
expect(json.data).toBeDefined();
106114
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2');
107115
});
116+
117+
it('uses custom prefix for /discovery route', async () => {
118+
const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' });
119+
const res = await customApp.request('/v2/discovery');
120+
expect(res.status).toBe(200);
121+
const json = await res.json();
122+
expect(json.data).toBeDefined();
123+
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2');
124+
});
108125
});
109126

110127
describe('.well-known Endpoint', () => {
@@ -277,6 +294,7 @@ describe('createHonoApp', () => {
277294
undefined,
278295
expect.any(Object),
279296
expect.objectContaining({ request: expect.anything() }),
297+
'/api',
280298
);
281299
});
282300

@@ -294,6 +312,7 @@ describe('createHonoApp', () => {
294312
body,
295313
expect.any(Object),
296314
expect.objectContaining({ request: expect.anything() }),
315+
'/api',
297316
);
298317
});
299318

@@ -306,6 +325,7 @@ describe('createHonoApp', () => {
306325
undefined,
307326
expect.any(Object),
308327
expect.objectContaining({ request: expect.anything() }),
328+
'/api',
309329
);
310330
});
311331

@@ -318,6 +338,7 @@ describe('createHonoApp', () => {
318338
undefined,
319339
expect.objectContaining({ package: 'com.acme.crm' }),
320340
expect.objectContaining({ request: expect.anything() }),
341+
'/api',
321342
);
322343
});
323344

@@ -330,6 +351,7 @@ describe('createHonoApp', () => {
330351
undefined,
331352
expect.any(Object),
332353
expect.objectContaining({ request: expect.anything() }),
354+
'/api',
333355
);
334356
});
335357

@@ -347,6 +369,7 @@ describe('createHonoApp', () => {
347369
body,
348370
expect.any(Object),
349371
expect.objectContaining({ request: expect.anything() }),
372+
'/api',
350373
);
351374
});
352375

@@ -364,6 +387,7 @@ describe('createHonoApp', () => {
364387
body,
365388
expect.any(Object),
366389
expect.objectContaining({ request: expect.anything() }),
390+
'/api',
367391
);
368392
});
369393

@@ -384,6 +408,7 @@ describe('createHonoApp', () => {
384408
undefined,
385409
expect.any(Object),
386410
expect.objectContaining({ request: expect.anything() }),
411+
'/api',
387412
);
388413
});
389414

@@ -396,6 +421,7 @@ describe('createHonoApp', () => {
396421
undefined,
397422
expect.any(Object),
398423
expect.objectContaining({ request: expect.anything() }),
424+
'/api',
399425
);
400426
});
401427

@@ -413,6 +439,7 @@ describe('createHonoApp', () => {
413439
body,
414440
expect.any(Object),
415441
expect.objectContaining({ request: expect.anything() }),
442+
'/api',
416443
);
417444
});
418445

@@ -425,6 +452,7 @@ describe('createHonoApp', () => {
425452
undefined,
426453
expect.objectContaining({ status: 'active' }),
427454
expect.objectContaining({ request: expect.anything() }),
455+
'/api',
428456
);
429457
});
430458

@@ -446,6 +474,7 @@ describe('createHonoApp', () => {
446474
undefined,
447475
expect.any(Object),
448476
expect.objectContaining({ request: expect.anything() }),
477+
'/api',
449478
);
450479
});
451480

@@ -458,6 +487,7 @@ describe('createHonoApp', () => {
458487
undefined,
459488
expect.any(Object),
460489
expect.objectContaining({ request: expect.anything() }),
490+
'/api',
461491
);
462492
});
463493

@@ -470,6 +500,7 @@ describe('createHonoApp', () => {
470500
undefined,
471501
expect.any(Object),
472502
expect.objectContaining({ request: expect.anything() }),
503+
'/api',
473504
);
474505
});
475506

@@ -482,6 +513,7 @@ describe('createHonoApp', () => {
482513
undefined,
483514
expect.any(Object),
484515
expect.objectContaining({ request: expect.anything() }),
516+
'/api',
485517
);
486518
});
487519
});
@@ -571,6 +603,7 @@ describe('createHonoApp', () => {
571603
undefined,
572604
expect.any(Object),
573605
expect.objectContaining({ request: expect.anything() }),
606+
'/api/v1',
574607
);
575608
});
576609

@@ -585,6 +618,7 @@ describe('createHonoApp', () => {
585618
undefined,
586619
expect.any(Object),
587620
expect.objectContaining({ request: expect.anything() }),
621+
'/api/v1',
588622
);
589623
});
590624

@@ -598,6 +632,16 @@ describe('createHonoApp', () => {
598632
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
599633
});
600634

635+
it('routes /api/v1/discovery through outer→inner delegation with correct prefix', async () => {
636+
const outerApp = createVercelApp();
637+
638+
const res = await outerApp.request('/api/v1/discovery');
639+
expect(res.status).toBe(200);
640+
const json = await res.json();
641+
expect(json.data).toBeDefined();
642+
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
643+
});
644+
601645
it('routes /api/v1/data/account through outer→inner delegation', async () => {
602646
const outerApp = createVercelApp();
603647

@@ -609,6 +653,7 @@ describe('createHonoApp', () => {
609653
undefined,
610654
expect.any(Object),
611655
expect.objectContaining({ request: expect.anything() }),
656+
'/api/v1',
612657
);
613658
});
614659

packages/adapters/hono/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
112112
return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
113113
});
114114

115+
app.get(`${prefix}/discovery`, async (c) => {
116+
return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
117+
});
118+
115119
// --- .well-known ---
116120
app.get('/.well-known/objectstack', (c) => {
117121
return c.redirect(prefix);
@@ -202,7 +206,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
202206
const url = new URL(c.req.url);
203207
url.searchParams.forEach((val, key) => { queryParams[key] = val; });
204208

205-
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw });
209+
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix);
206210
return toResponse(c, result);
207211
} catch (err: any) {
208212
return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500);

packages/adapters/nextjs/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export function createRouteHandler(options: NextAdapterOptions) {
6666
return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') });
6767
}
6868

69+
if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') {
70+
return NextResponse.json({ data: await dispatcher.getDiscoveryInfo(options.prefix || '/api') });
71+
}
72+
6973
try {
7074
const rawRequest = req;
7175

@@ -135,7 +139,7 @@ export function createRouteHandler(options: NextAdapterOptions) {
135139
const queryParams: Record<string, any> = {};
136140
url.searchParams.forEach((val, key) => queryParams[key] = val);
137141

138-
const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest });
142+
const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }, options.prefix || '/api');
139143
return toResponse(result);
140144

141145
} catch (err: any) {

packages/adapters/nuxt/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ export function createH3Router(options: NuxtAdapterOptions): Router {
9595
}),
9696
);
9797

98+
router.get(
99+
`${prefix}/discovery`,
100+
defineEventHandler(async () => {
101+
return { data: await dispatcher.getDiscoveryInfo(prefix) };
102+
}),
103+
);
104+
98105
// --- .well-known ---
99106
router.get(
100107
'/.well-known/objectstack',
@@ -210,7 +217,7 @@ export function createH3Router(options: NuxtAdapterOptions): Router {
210217
? await readBody(event)
211218
: undefined;
212219
const query = getQuery(event);
213-
const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req });
220+
const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }, prefix);
214221
return toResponse(event, result);
215222
} catch (err: any) {
216223
return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500);

packages/adapters/sveltekit/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) {
106106
});
107107
}
108108

109+
if (segments.length === 1 && segments[0] === 'discovery' && method === 'GET') {
110+
return new Response(JSON.stringify({ data: await dispatcher.getDiscoveryInfo(prefix) }), {
111+
status: 200,
112+
headers: { 'Content-Type': 'application/json' },
113+
});
114+
}
115+
109116
try {
110117
// --- Auth (needs auth service integration) ---
111118
if (segments[0] === 'auth') {
@@ -176,7 +183,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) {
176183
const queryParams: Record<string, any> = {};
177184
url.searchParams.forEach((val, key) => { queryParams[key] = val; });
178185

179-
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request });
186+
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }, prefix);
180187
return toResponse(result);
181188
} catch (err: any) {
182189
return errorJson(err.message || 'Internal Server Error', err.statusCode || 500);

packages/plugins/plugin-msw/src/msw-plugin.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,23 @@ export class MSWPlugin implements Plugin {
228228
})
229229
);
230230

231+
// Explicit /discovery endpoint — must be registered before catch-all
232+
// so dispatch() is not called with an empty prefix.
233+
this.handlers.push(
234+
http.get(`*${baseUrl}`, async () => {
235+
if (this.dispatcher) {
236+
return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) });
237+
}
238+
return HttpResponse.json({ data: { version: 'v1', url: baseUrl } });
239+
}),
240+
http.get(`*${baseUrl}/discovery`, async () => {
241+
if (this.dispatcher) {
242+
return HttpResponse.json({ data: await this.dispatcher.getDiscoveryInfo(baseUrl) });
243+
}
244+
return HttpResponse.json({ data: { version: 'v1', url: baseUrl } });
245+
})
246+
);
247+
231248
if (this.dispatcher) {
232249
const dispatcher = this.dispatcher;
233250

@@ -271,7 +288,8 @@ export class MSWPlugin implements Plugin {
271288
path,
272289
body,
273290
query,
274-
{ request }
291+
{ request },
292+
baseUrl
275293
);
276294

277295
if (result.handled) {

packages/runtime/src/http-dispatcher.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,15 +1317,14 @@ export class HttpDispatcher {
13171317
* Main Dispatcher Entry Point
13181318
* Routes the request to the appropriate handler based on path and precedence
13191319
*/
1320-
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1320+
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
13211321
const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
13221322

13231323
// 0. Discovery Endpoint (GET /discovery or GET /)
13241324
// Standard route: /discovery (protocol-compliant)
13251325
// Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
13261326
if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
1327-
// We use '' as prefix since we are internal dispatcher
1328-
const info = await this.getDiscoveryInfo('');
1327+
const info = await this.getDiscoveryInfo(prefix ?? '');
13291328
return {
13301329
handled: true,
13311330
response: this.success(info)

0 commit comments

Comments
 (0)