From ad1075606adc7cedc08b1d6dacaebc7d4029796c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:06:28 +0000 Subject: [PATCH 1/5] Initial plan From 404c906f50b5baa8a5455902ba4e7361f3cec91a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:12:06 +0000 Subject: [PATCH 2/5] fix: register packages and other missing routes in createHonoApp, fix meta query param passthrough Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/adapters/hono/src/hono.test.ts | 192 ++++++++++++++++++++++++ packages/adapters/hono/src/index.ts | 128 +++++++++++++--- 2 files changed, 303 insertions(+), 17 deletions(-) diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 0f21de325a..eb4fa8506b 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -11,6 +11,11 @@ const mockDispatcher = { handleMetadata: vi.fn().mockResolvedValue({ handled: true, response: { body: { objects: [] }, status: 200 } }), handleData: vi.fn().mockResolvedValue({ handled: true, response: { body: { records: [] }, status: 200 } }), handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), + handlePackages: vi.fn().mockResolvedValue({ handled: true, response: { body: { packages: [], total: 0 }, status: 200 } }), + handleAnalytics: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), + handleAutomation: vi.fn().mockResolvedValue({ handled: true, response: { body: { flows: [] }, status: 200 } }), + handleI18n: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), + handleUi: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), }; vi.mock('@objectstack/runtime', () => { @@ -279,6 +284,7 @@ describe('createHonoApp', () => { expect.objectContaining({ request: expect.anything() }), 'GET', undefined, + expect.any(Object), ); }); @@ -295,6 +301,7 @@ describe('createHonoApp', () => { expect.objectContaining({ request: expect.anything() }), 'PUT', body, + expect.any(Object), ); }); @@ -306,6 +313,19 @@ describe('createHonoApp', () => { expect.objectContaining({ request: expect.anything() }), 'GET', undefined, + expect.any(Object), + ); + }); + + it('forwards query parameters to handleMetadata', async () => { + const res = await app.request('/api/meta/objects?package=com.acme.crm'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith( + '/objects', + expect.objectContaining({ request: expect.anything() }), + 'GET', + undefined, + expect.objectContaining({ package: 'com.acme.crm' }), ); }); }); @@ -381,6 +401,178 @@ describe('createHonoApp', () => { }); }); + describe('Packages Endpoint', () => { + it('GET /api/packages calls handlePackages', async () => { + const res = await app.request('/api/packages'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.packages).toBeDefined(); + expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( + '', + 'GET', + {}, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('GET /api/packages/:id calls handlePackages with sub-path', async () => { + const res = await app.request('/api/packages/com.acme.crm'); + expect(res.status).toBe(200); + expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( + '/com.acme.crm', + 'GET', + {}, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('POST /api/packages parses JSON body', async () => { + const body = { manifest: { name: 'test-pkg' } }; + const res = await app.request('/api/packages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( + '', + 'POST', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('forwards query parameters to handlePackages', async () => { + const res = await app.request('/api/packages?status=active'); + expect(res.status).toBe(200); + expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( + '', + 'GET', + {}, + expect.objectContaining({ status: 'active' }), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('returns error on handlePackages exception', async () => { + mockDispatcher.handlePackages.mockRejectedValueOnce(new Error('Service unavailable')); + const res = await app.request('/api/packages'); + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.success).toBe(false); + expect(json.error.message).toBe('Service unavailable'); + }); + }); + + describe('Analytics Endpoint', () => { + it('GET /api/analytics calls handleAnalytics', async () => { + const res = await app.request('/api/analytics'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleAnalytics).toHaveBeenCalledWith( + '', + 'GET', + undefined, + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('POST /api/analytics/query parses JSON body', async () => { + const body = { metric: 'page_views' }; + const res = await app.request('/api/analytics/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.handleAnalytics).toHaveBeenCalledWith( + '/query', + 'POST', + body, + expect.objectContaining({ request: expect.anything() }), + ); + }); + }); + + describe('Automation Endpoint', () => { + it('GET /api/automation calls handleAutomation', async () => { + const res = await app.request('/api/automation'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleAutomation).toHaveBeenCalledWith( + '', + 'GET', + undefined, + expect.objectContaining({ request: expect.anything() }), + expect.any(Object), + ); + }); + + it('POST /api/automation parses JSON body', async () => { + const body = { name: 'test_flow', type: 'autolaunched' }; + const res = await app.request('/api/automation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.handleAutomation).toHaveBeenCalledWith( + '', + 'POST', + body, + expect.objectContaining({ request: expect.anything() }), + expect.any(Object), + ); + }); + }); + + describe('i18n Endpoint', () => { + it('GET /api/i18n calls handleI18n', async () => { + const res = await app.request('/api/i18n'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleI18n).toHaveBeenCalledWith( + '', + 'GET', + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('GET /api/i18n/labels/account forwards query params', async () => { + const res = await app.request('/api/i18n/labels/account?locale=zh-CN'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleI18n).toHaveBeenCalledWith( + '/labels/account', + 'GET', + expect.objectContaining({ locale: 'zh-CN' }), + expect.objectContaining({ request: expect.anything() }), + ); + }); + }); + + describe('UI Endpoint', () => { + it('GET /api/ui/view/account calls handleUi', async () => { + const res = await app.request('/api/ui/view/account'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleUi).toHaveBeenCalledWith( + '/view/account', + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + + it('GET /api/ui/view/account?type=list forwards query params', async () => { + const res = await app.request('/api/ui/view/account?type=list'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleUi).toHaveBeenCalledWith( + '/view/account', + expect.objectContaining({ type: 'list' }), + expect.objectContaining({ request: expect.anything() }), + ); + }); + }); + describe('Error Handling', () => { it('returns 500 with default message on generic error', async () => { mockDispatcher.handleData.mockRejectedValueOnce(new Error()); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 7fbe74301c..ccfc924bb9 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -131,7 +131,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { }); // --- Metadata --- - app.all(`${prefix}/meta/*`, async (c) => { + const metaHandler = async (c: any) => { try { const subPath = c.req.path.substring(`${prefix}/meta`.length); const method = c.req.method; @@ -141,27 +141,18 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { body = await c.req.json().catch(() => ({})); } - const result = await dispatcher.handleMetadata(subPath, { request: c.req.raw }, method, body); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }); + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - // Also handle /meta with no trailing path - app.all(`${prefix}/meta`, async (c) => { - try { - const method = c.req.method; - let body: any = undefined; - if (method === 'PUT' || method === 'POST') { - body = await c.req.json().catch(() => ({})); - } - const result = await dispatcher.handleMetadata('', { request: c.req.raw }, method, body); + const result = await dispatcher.handleMetadata(subPath, { request: c.req.raw }, method, body, queryParams); return toResponse(c, result); } catch (err: any) { return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); } - }); + }; + app.all(`${prefix}/meta/*`, metaHandler); + app.all(`${prefix}/meta`, metaHandler); // --- Data --- app.all(`${prefix}/data/*`, async (c) => { @@ -204,5 +195,108 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } }); + // --- Packages --- + const packagesHandler = async (c: any) => { + try { + const subPath = c.req.path.substring(`${prefix}/packages`.length); + const method = c.req.method; + + let body: any = {}; + if (method === 'POST' || method === 'PATCH') { + body = await c.req.json().catch(() => ({})); + } + + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + + const result = await dispatcher.handlePackages(subPath, method, body, queryParams, { request: c.req.raw }); + return toResponse(c, result); + } catch (err: any) { + return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); + } + }; + app.all(`${prefix}/packages/*`, packagesHandler); + app.all(`${prefix}/packages`, packagesHandler); + + // --- Analytics --- + const analyticsHandler = async (c: any) => { + try { + const subPath = c.req.path.substring(`${prefix}/analytics`.length); + const method = c.req.method; + + let body: any = undefined; + if (method === 'POST') { + body = await c.req.json().catch(() => ({})); + } + + const result = await dispatcher.handleAnalytics(subPath, method, body, { request: c.req.raw }); + return toResponse(c, result); + } catch (err: any) { + return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); + } + }; + app.all(`${prefix}/analytics/*`, analyticsHandler); + app.all(`${prefix}/analytics`, analyticsHandler); + + // --- Automation --- + const automationHandler = async (c: any) => { + try { + const subPath = c.req.path.substring(`${prefix}/automation`.length); + const method = c.req.method; + + let body: any = undefined; + if (method === 'POST' || method === 'PUT') { + body = await c.req.json().catch(() => ({})); + } + + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + + const result = await dispatcher.handleAutomation(subPath, method, body, { request: c.req.raw }, queryParams); + return toResponse(c, result); + } catch (err: any) { + return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); + } + }; + app.all(`${prefix}/automation/*`, automationHandler); + app.all(`${prefix}/automation`, automationHandler); + + // --- i18n --- + const i18nHandler = async (c: any) => { + try { + const subPath = c.req.path.substring(`${prefix}/i18n`.length); + const method = c.req.method; + + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + + const result = await dispatcher.handleI18n(subPath, method, queryParams, { request: c.req.raw }); + return toResponse(c, result); + } catch (err: any) { + return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); + } + }; + app.all(`${prefix}/i18n/*`, i18nHandler); + app.all(`${prefix}/i18n`, i18nHandler); + + // --- UI --- + app.all(`${prefix}/ui/*`, async (c) => { + try { + const subPath = c.req.path.substring(`${prefix}/ui`.length); + + const queryParams: Record = {}; + const url = new URL(c.req.url); + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + + const result = await dispatcher.handleUi(subPath, queryParams, { request: c.req.raw }); + return toResponse(c, result); + } catch (err: any) { + return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); + } + }); + return app; } From ebadf1e953626522527147948339634daeb38804 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:15:49 +0000 Subject: [PATCH 3/5] refactor: extract uiHandler for consistency, add UI base path test Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/adapters/hono/CHANGELOG.md | 8 ++++++++ packages/adapters/hono/src/hono.test.ts | 10 ++++++++++ packages/adapters/hono/src/index.ts | 6 ++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 0a550a0b80..d989751898 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/hono +## 3.2.8 + +### Patch Changes + +- fix: register all standard API routes in `createHonoApp()` — resolves 404 errors for `/api/v1/packages`, `/api/v1/analytics`, `/api/v1/automation`, `/api/v1/i18n`, and `/api/v1/ui` routes after Vercel deployment +- fix: forward query parameters to `handleMetadata()` so package filtering and locale queries work correctly +- Added comprehensive tests for all newly registered route handlers + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index eb4fa8506b..7d1683d77c 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -562,6 +562,16 @@ describe('createHonoApp', () => { ); }); + it('GET /api/ui calls handleUi with empty sub-path', async () => { + const res = await app.request('/api/ui'); + expect(res.status).toBe(200); + expect(mockDispatcher.handleUi).toHaveBeenCalledWith( + '', + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + ); + }); + it('GET /api/ui/view/account?type=list forwards query params', async () => { const res = await app.request('/api/ui/view/account?type=list'); expect(res.status).toBe(200); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index ccfc924bb9..f18ecf79b6 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -283,7 +283,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { app.all(`${prefix}/i18n`, i18nHandler); // --- UI --- - app.all(`${prefix}/ui/*`, async (c) => { + const uiHandler = async (c: any) => { try { const subPath = c.req.path.substring(`${prefix}/ui`.length); @@ -296,7 +296,9 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } catch (err: any) { return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); } - }); + }; + app.all(`${prefix}/ui/*`, uiHandler); + app.all(`${prefix}/ui`, uiHandler); return app; } From eb4d43548c56286e6ff6831da76b7a7c8636c6f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:33:56 +0000 Subject: [PATCH 4/5] refactor: unified catch-all dispatch pattern across all 7 adapters (Hono, Express, Fastify, Next.js, Nuxt, SvelteKit, NestJS) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/adapters/express/src/index.ts | 55 ++---- packages/adapters/fastify/src/index.ts | 60 +++--- packages/adapters/hono/src/hono.test.ts | 225 +++++++---------------- packages/adapters/hono/src/index.ts | 162 +++------------- packages/adapters/nestjs/src/index.ts | 26 ++- packages/adapters/nextjs/src/index.ts | 58 +++--- packages/adapters/nuxt/src/index.ts | 71 +++---- packages/adapters/sveltekit/src/index.ts | 61 +++--- 8 files changed, 215 insertions(+), 503 deletions(-) diff --git a/packages/adapters/express/src/index.ts b/packages/adapters/express/src/index.ts index 010a96ac00..4b4c5b551e 100644 --- a/packages/adapters/express/src/index.ts +++ b/packages/adapters/express/src/index.ts @@ -17,7 +17,12 @@ interface AuthService { /** * Creates an Express Router with all ObjectStack route dispatchers. - * Provides Auth, GraphQL, Metadata, Data, and Storage routes. + * + * Only routes that need framework-specific handling (auth service, storage + * file upload, GraphQL raw result, discovery wrapper) are registered explicitly. + * All other routes are handled by a catch-all that delegates to + * `HttpDispatcher.dispatch()`, making the adapter automatically support + * new routes added to the dispatcher. * * @example * ```ts @@ -70,12 +75,14 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { }); }; + // ─── Explicit routes (framework-specific handling required) ──────────────── + // --- Discovery --- router.get('/', async (_req: Request, res: Response) => { res.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); }); - // --- Auth --- + // --- Auth (needs auth service integration) --- router.all('/auth/{*path}', async (req: Request, res: Response) => { try { const path = (req.params as any).path; @@ -129,7 +136,7 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { } }); - // --- GraphQL --- + // --- GraphQL (returns raw result, not HttpDispatcherResult) --- router.post('/graphql', async (req: Request, res: Response) => { try { const result = await dispatcher.handleGraphQL(req.body, { request: req }); @@ -139,50 +146,28 @@ export function createExpressRouter(options: ExpressAdapterOptions): Router { } }); - // --- Metadata --- - router.all('/meta/{*path}', async (req: Request, res: Response) => { - try { - const subPath = '/' + (req.params as any).path; - const method = req.method; - const body = (method === 'PUT' || method === 'POST') ? req.body : undefined; - const result = await dispatcher.handleMetadata(subPath, { request: req }, method, body); - return sendResult(result, res); - } catch (err: any) { - return errorResponse(err, res); - } - }); - - router.all('/meta', async (req: Request, res: Response) => { - try { - const method = req.method; - const body = (method === 'PUT' || method === 'POST') ? req.body : undefined; - const result = await dispatcher.handleMetadata('', { request: req }, method, body); - return sendResult(result, res); - } catch (err: any) { - return errorResponse(err, res); - } - }); - - // --- Data --- - router.all('/data/{*path}', async (req: Request, res: Response) => { + // --- Storage (needs file upload handling) --- + router.all('/storage/{*path}', async (req: Request, res: Response) => { try { const subPath = '/' + (req.params as any).path; const method = req.method; - const body = (method === 'POST' || method === 'PATCH') ? req.body : {}; - const result = await dispatcher.handleData(subPath, method, body, req.query, { request: req }); + const file = (req as any).file || (req as any).files?.file; + const result = await dispatcher.handleStorage(subPath, method, file, { request: req }); return sendResult(result, res); } catch (err: any) { return errorResponse(err, res); } }); - // --- Storage --- - router.all('/storage/{*path}', async (req: Request, res: Response) => { + // ─── Catch-all: delegate to dispatcher.dispatch() ───────────────────────── + // Handles meta, data, packages, analytics, automation, i18n, ui, openapi, + // custom API endpoints, and any future routes added to HttpDispatcher. + router.all('/{*path}', async (req: Request, res: Response) => { try { const subPath = '/' + (req.params as any).path; const method = req.method; - const file = (req as any).file || (req as any).files?.file; - const result = await dispatcher.handleStorage(subPath, method, file, { request: req }); + 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 }); 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 84188db7b9..cdeeb1abab 100644 --- a/packages/adapters/fastify/src/index.ts +++ b/packages/adapters/fastify/src/index.ts @@ -17,7 +17,12 @@ interface AuthService { /** * Registers ObjectStack routes as a Fastify plugin. - * Provides Auth, GraphQL, Metadata, Data, and Storage routes. + * + * Only routes that need framework-specific handling (auth service, storage + * file upload, GraphQL raw result, discovery wrapper) are registered explicitly. + * All other routes are handled by a catch-all that delegates to + * `HttpDispatcher.dispatch()`, making the adapter automatically support + * new routes added to the dispatcher. * * @example * ```ts @@ -68,6 +73,8 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti }); }; + // ─── Explicit routes (framework-specific handling required) ──────────────── + // --- Discovery --- fastify.get(`${prefix}`, async (_request: FastifyRequest, reply: FastifyReply) => { return reply.send({ data: await dispatcher.getDiscoveryInfo(prefix) }); @@ -78,7 +85,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti return reply.redirect(prefix); }); - // --- Auth --- + // --- Auth (needs auth service integration) --- fastify.all(`${prefix}/auth/*`, async (request: FastifyRequest, reply: FastifyReply) => { try { const path = request.url.substring(`${prefix}/auth/`.length).split('?')[0]; @@ -132,7 +139,7 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti } }); - // --- GraphQL --- + // --- GraphQL (returns raw result, not HttpDispatcherResult) --- fastify.post(`${prefix}/graphql`, async (request: FastifyRequest, reply: FastifyReply) => { try { const result = await dispatcher.handleGraphQL(request.body as any, { request: request.raw }); @@ -142,53 +149,30 @@ export async function objectStackPlugin(fastify: FastifyInstance, options: Fasti } }); - // --- Metadata --- - fastify.all(`${prefix}/meta/*`, async (request: FastifyRequest, reply: FastifyReply) => { - try { - const urlPath = request.url.split('?')[0]; - const subPath = urlPath.substring(`${prefix}/meta`.length); - const method = request.method; - const body = (method === 'PUT' || method === 'POST') ? request.body : undefined; - const result = await dispatcher.handleMetadata(subPath, { request: request.raw }, method, body); - return sendResult(result, reply); - } catch (err: any) { - return errorResponse(err, reply); - } - }); - - fastify.all(`${prefix}/meta`, async (request: FastifyRequest, reply: FastifyReply) => { - try { - const method = request.method; - const body = (method === 'PUT' || method === 'POST') ? request.body : undefined; - const result = await dispatcher.handleMetadata('', { request: request.raw }, method, body); - return sendResult(result, reply); - } catch (err: any) { - return errorResponse(err, reply); - } - }); - - // --- Data --- - fastify.all(`${prefix}/data/*`, async (request: FastifyRequest, reply: FastifyReply) => { + // --- Storage (needs file upload handling) --- + fastify.all(`${prefix}/storage/*`, async (request: FastifyRequest, reply: FastifyReply) => { try { const urlPath = request.url.split('?')[0]; - const subPath = urlPath.substring(`${prefix}/data`.length); + const subPath = urlPath.substring(`${prefix}/storage`.length); const method = request.method; - const body = (method === 'POST' || method === 'PATCH') ? request.body : {}; - const result = await dispatcher.handleData(subPath, method, body, request.query, { request: request.raw }); + const file = (request as any).file || (request.body as any)?.file; + const result = await dispatcher.handleStorage(subPath, method, file, { request: request.raw }); return sendResult(result, reply); } catch (err: any) { return errorResponse(err, reply); } }); - // --- Storage --- - fastify.all(`${prefix}/storage/*`, async (request: FastifyRequest, reply: FastifyReply) => { + // ─── Catch-all: delegate to dispatcher.dispatch() ───────────────────────── + // Handles meta, data, packages, analytics, automation, i18n, ui, openapi, + // custom API endpoints, and any future routes added to HttpDispatcher. + fastify.all(`${prefix}/*`, async (request: FastifyRequest, reply: FastifyReply) => { try { const urlPath = request.url.split('?')[0]; - const subPath = urlPath.substring(`${prefix}/storage`.length); + const subPath = urlPath.substring(prefix.length); const method = request.method; - const file = (request as any).file || (request.body as any)?.file; - const result = await dispatcher.handleStorage(subPath, method, file, { request: request.raw }); + const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? request.body : undefined; + const result = await dispatcher.dispatch(method, subPath, body, request.query, { request: request.raw }); 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 7d1683d77c..29a71223ff 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -8,14 +8,8 @@ const mockDispatcher = { getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }), handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }), handleGraphQL: vi.fn().mockResolvedValue({ data: {} }), - handleMetadata: vi.fn().mockResolvedValue({ handled: true, response: { body: { objects: [] }, status: 200 } }), - handleData: vi.fn().mockResolvedValue({ handled: true, response: { body: { records: [] }, status: 200 } }), handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), - handlePackages: vi.fn().mockResolvedValue({ handled: true, response: { body: { packages: [], total: 0 }, status: 200 } }), - handleAnalytics: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), - handleAutomation: vi.fn().mockResolvedValue({ handled: true, response: { body: { flows: [] }, status: 200 } }), - handleI18n: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), - handleUi: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }), + dispatch: vi.fn().mockResolvedValue({ handled: true, response: { body: { success: true }, status: 200 } }), }; vi.mock('@objectstack/runtime', () => { @@ -273,22 +267,20 @@ describe('createHonoApp', () => { }); }); - describe('Metadata Endpoint', () => { - it('GET /api/meta/objects calls handleMetadata', async () => { + describe('Catch-all Dispatch', () => { + it('GET /api/meta/objects delegates to dispatch()', async () => { const res = await app.request('/api/meta/objects'); expect(res.status).toBe(200); - const json = await res.json(); - expect(json.objects).toBeDefined(); - expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith( - '/objects', - expect.objectContaining({ request: expect.anything() }), + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/meta/objects', undefined, expect.any(Object), + expect.objectContaining({ request: expect.anything() }), ); }); - it('PUT /api/meta/objects parses JSON body', async () => { + it('PUT /api/meta/objects parses JSON body via dispatch()', async () => { const body = { name: 'test_object' }; const res = await app.request('/api/meta/objects', { method: 'PUT', @@ -296,50 +288,46 @@ describe('createHonoApp', () => { body: JSON.stringify(body), }); expect(res.status).toBe(200); - expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith( - '/objects', - expect.objectContaining({ request: expect.anything() }), + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'PUT', + '/meta/objects', body, expect.any(Object), + expect.objectContaining({ request: expect.anything() }), ); }); it('GET /api/meta with no trailing path', async () => { const res = await app.request('/api/meta'); expect(res.status).toBe(200); - expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith( - '', - expect.objectContaining({ request: expect.anything() }), + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/meta', undefined, expect.any(Object), + expect.objectContaining({ request: expect.anything() }), ); }); - it('forwards query parameters to handleMetadata', async () => { + it('forwards query parameters through dispatch()', async () => { const res = await app.request('/api/meta/objects?package=com.acme.crm'); expect(res.status).toBe(200); - expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith( - '/objects', - expect.objectContaining({ request: expect.anything() }), + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/meta/objects', undefined, expect.objectContaining({ package: 'com.acme.crm' }), + expect.objectContaining({ request: expect.anything() }), ); }); - }); - describe('Data Endpoint', () => { - it('GET /api/data/account calls handleData', async () => { + it('GET /api/data/account delegates to dispatch()', async () => { const res = await app.request('/api/data/account'); expect(res.status).toBe(200); - const json = await res.json(); - expect(json.records).toBeDefined(); - expect(mockDispatcher.handleData).toHaveBeenCalledWith( - '/account', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', - {}, + '/data/account', + undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); @@ -353,9 +341,9 @@ describe('createHonoApp', () => { body: JSON.stringify(body), }); expect(res.status).toBe(200); - expect(mockDispatcher.handleData).toHaveBeenCalledWith( - '/account', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'POST', + '/data/account', body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), @@ -370,59 +358,42 @@ describe('createHonoApp', () => { body: JSON.stringify(body), }); expect(res.status).toBe(200); - expect(mockDispatcher.handleData).toHaveBeenCalledWith( - '/account', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'PATCH', + '/data/account', body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - it('returns 404 when result is not handled', async () => { - mockDispatcher.handleData.mockResolvedValueOnce({ handled: false }); + it('returns 404 when dispatch result is not handled', async () => { + mockDispatcher.dispatch.mockResolvedValueOnce({ handled: false }); const res = await app.request('/api/data/missing'); expect(res.status).toBe(404); const json = await res.json(); expect(json.success).toBe(false); }); - }); - describe('Storage Endpoint', () => { - it('GET /api/storage/files calls handleStorage', async () => { - const res = await app.request('/api/storage/files'); - expect(res.status).toBe(200); - expect(mockDispatcher.handleStorage).toHaveBeenCalledWith( - '/files', - 'GET', - undefined, - expect.objectContaining({ request: expect.anything() }), - ); - }); - }); - - describe('Packages Endpoint', () => { - it('GET /api/packages calls handlePackages', async () => { + it('GET /api/packages delegates to dispatch()', async () => { const res = await app.request('/api/packages'); expect(res.status).toBe(200); - const json = await res.json(); - expect(json.packages).toBeDefined(); - expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', - {}, + '/packages', + undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - it('GET /api/packages/:id calls handlePackages with sub-path', async () => { + it('GET /api/packages/:id delegates to dispatch()', async () => { const res = await app.request('/api/packages/com.acme.crm'); expect(res.status).toBe(200); - expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( - '/com.acme.crm', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', - {}, + '/packages/com.acme.crm', + undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); @@ -436,156 +407,88 @@ describe('createHonoApp', () => { body: JSON.stringify(body), }); expect(res.status).toBe(200); - expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'POST', + '/packages', body, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - it('forwards query parameters to handlePackages', async () => { + it('GET /api/packages?status=active forwards query params', async () => { const res = await app.request('/api/packages?status=active'); expect(res.status).toBe(200); - expect(mockDispatcher.handlePackages).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', - {}, + '/packages', + undefined, expect.objectContaining({ status: 'active' }), expect.objectContaining({ request: expect.anything() }), ); }); - it('returns error on handlePackages exception', async () => { - mockDispatcher.handlePackages.mockRejectedValueOnce(new Error('Service unavailable')); + it('returns error on dispatch exception', async () => { + mockDispatcher.dispatch.mockRejectedValueOnce(new Error('Service unavailable')); const res = await app.request('/api/packages'); expect(res.status).toBe(500); const json = await res.json(); expect(json.success).toBe(false); expect(json.error.message).toBe('Service unavailable'); }); - }); - describe('Analytics Endpoint', () => { - it('GET /api/analytics calls handleAnalytics', async () => { + it('GET /api/analytics delegates to dispatch()', async () => { const res = await app.request('/api/analytics'); expect(res.status).toBe(200); - expect(mockDispatcher.handleAnalytics).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/analytics', undefined, + expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - it('POST /api/analytics/query parses JSON body', async () => { - const body = { metric: 'page_views' }; - const res = await app.request('/api/analytics/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - expect(res.status).toBe(200); - expect(mockDispatcher.handleAnalytics).toHaveBeenCalledWith( - '/query', - 'POST', - body, - expect.objectContaining({ request: expect.anything() }), - ); - }); - }); - - describe('Automation Endpoint', () => { - it('GET /api/automation calls handleAutomation', async () => { + it('GET /api/automation delegates to dispatch()', async () => { const res = await app.request('/api/automation'); expect(res.status).toBe(200); - expect(mockDispatcher.handleAutomation).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/automation', undefined, - expect.objectContaining({ request: expect.anything() }), expect.any(Object), - ); - }); - - it('POST /api/automation parses JSON body', async () => { - const body = { name: 'test_flow', type: 'autolaunched' }; - const res = await app.request('/api/automation', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - expect(res.status).toBe(200); - expect(mockDispatcher.handleAutomation).toHaveBeenCalledWith( - '', - 'POST', - body, expect.objectContaining({ request: expect.anything() }), - expect.any(Object), ); }); - }); - describe('i18n Endpoint', () => { - it('GET /api/i18n calls handleI18n', async () => { + it('GET /api/i18n delegates to dispatch()', async () => { const res = await app.request('/api/i18n'); expect(res.status).toBe(200); - expect(mockDispatcher.handleI18n).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', + '/i18n', + undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - it('GET /api/i18n/labels/account forwards query params', async () => { - const res = await app.request('/api/i18n/labels/account?locale=zh-CN'); - expect(res.status).toBe(200); - expect(mockDispatcher.handleI18n).toHaveBeenCalledWith( - '/labels/account', - 'GET', - expect.objectContaining({ locale: 'zh-CN' }), - expect.objectContaining({ request: expect.anything() }), - ); - }); - }); - - describe('UI Endpoint', () => { - it('GET /api/ui/view/account calls handleUi', async () => { + it('GET /api/ui/view/account delegates to dispatch()', async () => { const res = await app.request('/api/ui/view/account'); expect(res.status).toBe(200); - expect(mockDispatcher.handleUi).toHaveBeenCalledWith( - '/view/account', - expect.any(Object), - expect.objectContaining({ request: expect.anything() }), - ); - }); - - it('GET /api/ui calls handleUi with empty sub-path', async () => { - const res = await app.request('/api/ui'); - expect(res.status).toBe(200); - expect(mockDispatcher.handleUi).toHaveBeenCalledWith( - '', + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'GET', + '/ui/view/account', + undefined, expect.any(Object), expect.objectContaining({ request: expect.anything() }), ); }); - - it('GET /api/ui/view/account?type=list forwards query params', async () => { - const res = await app.request('/api/ui/view/account?type=list'); - expect(res.status).toBe(200); - expect(mockDispatcher.handleUi).toHaveBeenCalledWith( - '/view/account', - expect.objectContaining({ type: 'list' }), - expect.objectContaining({ request: expect.anything() }), - ); - }); }); describe('Error Handling', () => { it('returns 500 with default message on generic error', async () => { - mockDispatcher.handleData.mockRejectedValueOnce(new Error()); + mockDispatcher.dispatch.mockRejectedValueOnce(new Error()); const res = await app.request('/api/data/account'); expect(res.status).toBe(500); const json = await res.json(); @@ -593,7 +496,7 @@ describe('createHonoApp', () => { }); it('uses custom statusCode from error', async () => { - mockDispatcher.handleData.mockRejectedValueOnce( + mockDispatcher.dispatch.mockRejectedValueOnce( Object.assign(new Error('Forbidden'), { statusCode: 403 }), ); const res = await app.request('/api/data/account'); @@ -605,7 +508,7 @@ describe('createHonoApp', () => { describe('toResponse', () => { it('handles redirect result', async () => { - mockDispatcher.handleData.mockResolvedValueOnce({ + mockDispatcher.dispatch.mockResolvedValueOnce({ handled: true, result: { type: 'redirect', url: 'https://example.com' }, }); @@ -615,7 +518,7 @@ describe('createHonoApp', () => { }); it('handles generic result objects with 200 status', async () => { - mockDispatcher.handleData.mockResolvedValueOnce({ + mockDispatcher.dispatch.mockResolvedValueOnce({ handled: true, result: { foo: 'bar' }, }); @@ -626,7 +529,7 @@ describe('createHonoApp', () => { }); it('sets custom headers from response', async () => { - mockDispatcher.handleData.mockResolvedValueOnce({ + mockDispatcher.dispatch.mockResolvedValueOnce({ handled: true, response: { status: 201, body: { id: 1 }, headers: { 'X-Custom': 'yes' } }, }); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index f18ecf79b6..5cb79a6e82 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -27,8 +27,15 @@ export function objectStackMiddleware(kernel: ObjectKernel) { /** * Creates a full-featured Hono app with all ObjectStack route dispatchers. - * Provides Auth, GraphQL, Metadata, Data, and Storage routes matching - * Next.js/NestJS adapter completeness. + * + * Only routes that need framework-specific handling (auth service, storage + * formData, GraphQL raw result, discovery wrapper) are registered explicitly. + * All other routes (meta, data, packages, analytics, automation, i18n, ui, + * openapi, custom endpoints, and any future routes) are handled by a + * catch-all that delegates to `HttpDispatcher.dispatch()`. + * + * This means new routes added to `HttpDispatcher` automatically work in + * every adapter without any adapter-side code changes. * * @example * ```ts @@ -71,6 +78,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { return errorJson(c, 'Not Found', 404); }; + // ─── Explicit routes (framework-specific handling required) ──────────────── + // --- Discovery --- app.get(`${prefix}`, async (c) => { return c.json({ data: await dispatcher.getDiscoveryInfo(prefix) }); @@ -81,7 +90,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { return c.redirect(prefix); }); - // --- Auth --- + // --- Auth (needs auth service integration) --- app.all(`${prefix}/auth/*`, async (c) => { try { const path = c.req.path.substring(`${prefix}/auth/`.length); @@ -119,7 +128,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } }); - // --- GraphQL --- + // --- GraphQL (returns raw result, not HttpDispatcherResult) --- app.post(`${prefix}/graphql`, async (c) => { try { const body = await c.req.json(); @@ -130,53 +139,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } }); - // --- Metadata --- - const metaHandler = async (c: any) => { - try { - const subPath = c.req.path.substring(`${prefix}/meta`.length); - const method = c.req.method; - - let body: any = undefined; - if (method === 'PUT' || method === 'POST') { - body = await c.req.json().catch(() => ({})); - } - - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handleMetadata(subPath, { request: c.req.raw }, method, body, queryParams); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }; - app.all(`${prefix}/meta/*`, metaHandler); - app.all(`${prefix}/meta`, metaHandler); - - // --- Data --- - app.all(`${prefix}/data/*`, async (c) => { - try { - const subPath = c.req.path.substring(`${prefix}/data`.length); - const method = c.req.method; - - let body: any = {}; - if (method === 'POST' || method === 'PATCH') { - body = await c.req.json().catch(() => ({})); - } - - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handleData(subPath, method, body, queryParams, { request: c.req.raw }); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }); - - // --- Storage --- + // --- Storage (needs formData parsing) --- app.all(`${prefix}/storage/*`, async (c) => { try { const subPath = c.req.path.substring(`${prefix}/storage`.length); @@ -195,110 +158,29 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } }); - // --- Packages --- - const packagesHandler = async (c: any) => { - try { - const subPath = c.req.path.substring(`${prefix}/packages`.length); - const method = c.req.method; - - let body: any = {}; - if (method === 'POST' || method === 'PATCH') { - body = await c.req.json().catch(() => ({})); - } - - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handlePackages(subPath, method, body, queryParams, { request: c.req.raw }); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }; - app.all(`${prefix}/packages/*`, packagesHandler); - app.all(`${prefix}/packages`, packagesHandler); - - // --- Analytics --- - const analyticsHandler = async (c: any) => { + // ─── Catch-all: delegate to dispatcher.dispatch() ───────────────────────── + // Handles meta, data, packages, analytics, automation, i18n, ui, openapi, + // custom API endpoints, and any future routes added to HttpDispatcher. + app.all(`${prefix}/*`, async (c) => { try { - const subPath = c.req.path.substring(`${prefix}/analytics`.length); + const subPath = c.req.path.substring(prefix.length); const method = c.req.method; let body: any = undefined; - if (method === 'POST') { + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { body = await c.req.json().catch(() => ({})); } - const result = await dispatcher.handleAnalytics(subPath, method, body, { request: c.req.raw }); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }; - app.all(`${prefix}/analytics/*`, analyticsHandler); - app.all(`${prefix}/analytics`, analyticsHandler); - - // --- Automation --- - const automationHandler = async (c: any) => { - try { - const subPath = c.req.path.substring(`${prefix}/automation`.length); - const method = c.req.method; - - let body: any = undefined; - if (method === 'POST' || method === 'PUT') { - body = await c.req.json().catch(() => ({})); - } - - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handleAutomation(subPath, method, body, { request: c.req.raw }, queryParams); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }; - app.all(`${prefix}/automation/*`, automationHandler); - app.all(`${prefix}/automation`, automationHandler); - - // --- i18n --- - const i18nHandler = async (c: any) => { - try { - const subPath = c.req.path.substring(`${prefix}/i18n`.length); - const method = c.req.method; - const queryParams: Record = {}; const url = new URL(c.req.url); url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - const result = await dispatcher.handleI18n(subPath, method, queryParams, { request: c.req.raw }); + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }); return toResponse(c, result); } catch (err: any) { return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); } - }; - app.all(`${prefix}/i18n/*`, i18nHandler); - app.all(`${prefix}/i18n`, i18nHandler); - - // --- UI --- - const uiHandler = async (c: any) => { - try { - const subPath = c.req.path.substring(`${prefix}/ui`.length); - - const queryParams: Record = {}; - const url = new URL(c.req.url); - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handleUi(subPath, queryParams, { request: c.req.raw }); - return toResponse(c, result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }; - app.all(`${prefix}/ui/*`, uiHandler); - app.all(`${prefix}/ui`, uiHandler); + }); return app; } diff --git a/packages/adapters/nestjs/src/index.ts b/packages/adapters/nestjs/src/index.ts index 0e05dd2091..7a65772b61 100644 --- a/packages/adapters/nestjs/src/index.ts +++ b/packages/adapters/nestjs/src/index.ts @@ -167,7 +167,7 @@ export class ObjectStackController { // Metadata @All('meta*') - async metadata(@Req() req: any, @Res() res: any, @Body() body?: any) { + async metadata(@Req() req: any, @Res() res: any, @Body() body?: any, @Query() query?: any) { try { // /api/meta/objects -> objects let path = req.params[0] || ''; @@ -175,10 +175,7 @@ export class ObjectStackController { path = req.url.split('/meta')[1].split('?')[0]; } - // Use injected body or fallback to req.body - const payload = body || req.body; - - const result = await this.service.dispatcher.handleMetadata(path, { request: req }, req.method, payload); + const result = await this.service.dispatcher.dispatch(req.method, '/meta' + path, body || req.body, query, { request: req }); return this.normalizeResponse(result, res); } catch (err) { return this.handleError(err, res); @@ -194,7 +191,7 @@ export class ObjectStackController { path = req.url.substring(req.url.indexOf('/data') + 5).split('?')[0]; } - const result = await this.service.dispatcher.handleData(path, req.method, body, query, { request: req }); + const result = await this.service.dispatcher.dispatch(req.method, '/data' + path, body, query, { request: req }); return this.normalizeResponse(result, res); } catch (err) { return this.handleError(err, res); @@ -219,6 +216,23 @@ export class ObjectStackController { return this.handleError(err, res); } } + + // Catch-all: delegate remaining routes to dispatcher.dispatch() + // Handles packages, analytics, automation, i18n, ui, openapi, + // custom API endpoints, and any future routes. + @All('*') + async catchAll(@Req() req: any, @Res() res: any, @Body() body: any, @Query() query: any) { + try { + // Extract the sub-path after /api + const path = req.url.split('/api')[1]?.split('?')[0] || ''; + const method = req.method; + const payload = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? body : undefined; + const result = await this.service.dispatcher.dispatch(method, path, payload, query, { request: req }); + return this.normalizeResponse(result, res); + } catch (err) { + return this.handleError(err, res); + } + } } // --- Discovery Controller --- diff --git a/packages/adapters/nextjs/src/index.ts b/packages/adapters/nextjs/src/index.ts index 8d4b2f90ef..9f7a6f6fd0 100644 --- a/packages/adapters/nextjs/src/index.ts +++ b/packages/adapters/nextjs/src/index.ts @@ -18,6 +18,9 @@ interface AuthService { /** * Creates a route handler for Next.js App Router * Handles /api/[...objectstack] pattern + * + * Only auth, GraphQL, storage, and discovery need explicit handling. + * All other routes delegate to `HttpDispatcher.dispatch()` automatically. */ export function createRouteHandler(options: NextAdapterOptions) { const dispatcher = new HttpDispatcher(options.kernel); @@ -66,7 +69,7 @@ export function createRouteHandler(options: NextAdapterOptions) { try { const rawRequest = req; - // --- 1. Auth --- + // --- 1. Auth (needs auth service integration) --- if (segments[0] === 'auth') { // Try AuthPlugin service first (prefer async to support factory-based services) let authService: AuthService | null = null; @@ -97,44 +100,14 @@ export function createRouteHandler(options: NextAdapterOptions) { return toResponse(result); } - // --- 2. GraphQL --- + // --- 2. GraphQL (returns raw result, not HttpDispatcherResult) --- if (segments[0] === 'graphql' && method === 'POST') { const body = await req.json(); const result = await dispatcher.handleGraphQL(body as any, { request: rawRequest } as any); return NextResponse.json(result); } - // --- 3. Metadata --- - if (segments[0] === 'meta') { - const subPath = segments.slice(1).join('/'); - - let body: any = undefined; - if (method === 'PUT' || method === 'POST') { - body = await req.json().catch(() => ({})); - } - - const result = await dispatcher.handleMetadata(subPath, { request: rawRequest }, method, body); - return toResponse(result); - } - - // --- 4. Data --- - if (segments[0] === 'data') { - const subPath = segments.slice(1).join('/'); - let body: any = {}; - if (method === 'POST' || method === 'PATCH') { - body = await req.json().catch(() => ({})); - } - - // Extract query params - const url = new URL(req.url); - const queryParams: Record = {}; - url.searchParams.forEach((val, key) => queryParams[key] = val); - - const result = await dispatcher.handleData(subPath, method, body, queryParams, { request: rawRequest } as any); - return toResponse(result); - } - - // --- 5. Storage --- + // --- 3. Storage (needs formData parsing) --- if (segments[0] === 'storage') { const subPath = segments.slice(1).join('/'); @@ -147,8 +120,23 @@ export function createRouteHandler(options: NextAdapterOptions) { const result = await dispatcher.handleStorage(subPath, method, file, { request: rawRequest }); return toResponse(result); } - - return error('Not Found', 404); + + // --- 4. Catch-all: delegate to dispatcher.dispatch() --- + // Handles meta, data, packages, analytics, automation, i18n, ui, + // openapi, custom API endpoints, and any future routes. + const path = '/' + segments.join('/'); + + let body: any = undefined; + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + body = await req.json().catch(() => ({})); + } + + const url = new URL(req.url); + const queryParams: Record = {}; + url.searchParams.forEach((val, key) => queryParams[key] = val); + + const result = await dispatcher.dispatch(method, path, body, queryParams, { request: rawRequest }); + return toResponse(result); } catch (err: any) { return error(err.message || 'Internal Server Error', err.statusCode || 500); diff --git a/packages/adapters/nuxt/src/index.ts b/packages/adapters/nuxt/src/index.ts index 14a3f4733b..287a656f13 100644 --- a/packages/adapters/nuxt/src/index.ts +++ b/packages/adapters/nuxt/src/index.ts @@ -29,6 +29,12 @@ interface AuthService { * Creates an h3 router with all ObjectStack route dispatchers. * Designed for use in Nuxt server routes or standalone h3 apps. * + * Only routes that need framework-specific handling (auth service, storage + * file upload, GraphQL raw result, discovery wrapper) are registered explicitly. + * All other routes are handled by a catch-all that delegates to + * `HttpDispatcher.dispatch()`, making the adapter automatically support + * new routes added to the dispatcher. + * * @example * ```ts * // server/api/[...].ts @@ -79,6 +85,8 @@ export function createH3Router(options: NuxtAdapterOptions): Router { return errorJson(event, 'Not Found', 404); }; + // ─── Explicit routes (framework-specific handling required) ──────────────── + // --- Discovery --- router.get( `${prefix}`, @@ -95,7 +103,7 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); - // --- Auth --- + // --- Auth (needs auth service integration) --- router.use( `${prefix}/auth/**`, defineEventHandler(async (event) => { @@ -157,7 +165,7 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); - // --- GraphQL --- + // --- GraphQL (returns raw result, not HttpDispatcherResult) --- router.post( `${prefix}/graphql`, defineEventHandler(async (event) => { @@ -171,34 +179,16 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); - // --- Metadata --- + // --- Storage (needs multipart form parsing) --- router.use( - `${prefix}/meta/**`, + `${prefix}/storage/**`, defineEventHandler(async (event) => { try { const urlPath = (event.path || event.node.req.url || '').split('?')[0]; - const subPath = urlPath.substring(`${prefix}/meta`.length); - const method = event.method; - const body = (method === 'PUT' || method === 'POST') - ? await readBody(event) - : undefined; - const result = await dispatcher.handleMetadata(subPath, { request: event.node.req }, method, body); - return toResponse(event, result); - } catch (err: any) { - return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500); - } - }), - ); - - router.use( - `${prefix}/meta`, - defineEventHandler(async (event) => { - try { + const subPath = urlPath.substring(`${prefix}/storage`.length); const method = event.method; - const body = (method === 'PUT' || method === 'POST') - ? await readBody(event) - : undefined; - const result = await dispatcher.handleMetadata('', { request: event.node.req }, method, body); + const file = undefined; // File upload requires multipart parsing (e.g., formidable) + const result = await dispatcher.handleStorage(subPath, method, file, { request: event.node.req }); return toResponse(event, result); } catch (err: any) { return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500); @@ -206,36 +196,21 @@ export function createH3Router(options: NuxtAdapterOptions): Router { }), ); - // --- Data --- + // ─── Catch-all: delegate to dispatcher.dispatch() ───────────────────────── + // Handles meta, data, packages, analytics, automation, i18n, ui, openapi, + // custom API endpoints, and any future routes added to HttpDispatcher. router.use( - `${prefix}/data/**`, + `${prefix}/**`, defineEventHandler(async (event) => { try { const urlPath = (event.path || event.node.req.url || '').split('?')[0]; - const subPath = urlPath.substring(`${prefix}/data`.length); + const subPath = urlPath.substring(prefix.length); const method = event.method; - const body = (method === 'POST' || method === 'PATCH') + const body = (method === 'POST' || method === 'PUT' || method === 'PATCH') ? await readBody(event) - : {}; + : undefined; const query = getQuery(event); - const result = await dispatcher.handleData(subPath, method, body, query, { request: event.node.req }); - return toResponse(event, result); - } catch (err: any) { - return errorJson(event, err.message || 'Internal Server Error', err.statusCode || 500); - } - }), - ); - - // --- Storage --- - router.use( - `${prefix}/storage/**`, - defineEventHandler(async (event) => { - try { - const urlPath = (event.path || event.node.req.url || '').split('?')[0]; - const subPath = urlPath.substring(`${prefix}/storage`.length); - const method = event.method; - const file = undefined; // File upload requires multipart parsing (e.g., formidable) - const result = await dispatcher.handleStorage(subPath, method, file, { request: event.node.req }); + const result = await dispatcher.dispatch(method, subPath, body, query, { request: event.node.req }); 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 ab5817a4e0..578b871a78 100644 --- a/packages/adapters/sveltekit/src/index.ts +++ b/packages/adapters/sveltekit/src/index.ts @@ -27,6 +27,9 @@ interface RequestEvent { * Creates a SvelteKit request handler for ObjectStack API routes. * Use in a catch-all `+server.ts` route like `src/routes/api/[...path]/+server.ts`. * + * Only auth, GraphQL, storage, and discovery need explicit handling. + * All other routes delegate to `HttpDispatcher.dispatch()` automatically. + * * @example * ```ts * // src/routes/api/[...path]/+server.ts @@ -104,7 +107,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { } try { - // --- Auth --- + // --- Auth (needs auth service integration) --- if (segments[0] === 'auth') { const subPath = segments.slice(1).join('/'); @@ -133,7 +136,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { return toResponse(result); } - // --- GraphQL --- + // --- GraphQL (returns raw result, not HttpDispatcherResult) --- if (segments[0] === 'graphql' && method === 'POST') { const body = await request.json() as { query: string; variables?: any }; const result = await dispatcher.handleGraphQL(body, { request }); @@ -143,43 +146,7 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { }); } - // --- Metadata --- - if (segments[0] === 'meta') { - const subPath = segments.slice(1).join('/'); - let body: any = undefined; - if (method === 'PUT' || method === 'POST') { - body = await request.json().catch(() => ({})); - } - const result = await dispatcher.handleMetadata( - subPath ? `/${subPath}` : '', - { request }, - method, - body, - ); - return toResponse(result); - } - - // --- Data --- - if (segments[0] === 'data') { - const subPath = segments.slice(1).join('/'); - let body: any = {}; - if (method === 'POST' || method === 'PATCH') { - body = await request.json().catch(() => ({})); - } - const queryParams: Record = {}; - url.searchParams.forEach((val, key) => { queryParams[key] = val; }); - - const result = await dispatcher.handleData( - subPath ? `/${subPath}` : '', - method, - body, - queryParams, - { request }, - ); - return toResponse(result); - } - - // --- Storage --- + // --- Storage (needs formData parsing) --- if (segments[0] === 'storage') { const subPath = segments.slice(1).join('/'); let file: any = undefined; @@ -196,7 +163,21 @@ export function createRequestHandler(options: SvelteKitAdapterOptions) { return toResponse(result); } - return errorJson('Not Found', 404); + // --- Catch-all: delegate to dispatcher.dispatch() --- + // Handles meta, data, packages, analytics, automation, i18n, ui, + // openapi, custom API endpoints, and any future routes. + const subPath = path || ''; + + let body: any = undefined; + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + body = await request.json().catch(() => ({})); + } + + const queryParams: Record = {}; + url.searchParams.forEach((val, key) => { queryParams[key] = val; }); + + const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request }); + return toResponse(result); } catch (err: any) { return errorJson(err.message || 'Internal Server Error', err.statusCode || 500); } From 408f23aa223fd9b263298d580a6395de600656c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:37:59 +0000 Subject: [PATCH 5/5] docs: update CHANGELOG.md for all 7 adapters with unified catch-all dispatch pattern Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/adapters/express/CHANGELOG.md | 7 +++++++ packages/adapters/fastify/CHANGELOG.md | 7 +++++++ packages/adapters/hono/CHANGELOG.md | 7 ++++--- packages/adapters/nestjs/CHANGELOG.md | 8 ++++++++ packages/adapters/nextjs/CHANGELOG.md | 7 +++++++ packages/adapters/nuxt/CHANGELOG.md | 7 +++++++ packages/adapters/sveltekit/CHANGELOG.md | 7 +++++++ 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/adapters/express/CHANGELOG.md b/packages/adapters/express/CHANGELOG.md index 747aa907e0..4af98c2b63 100644 --- a/packages/adapters/express/CHANGELOG.md +++ b/packages/adapters/express/CHANGELOG.md @@ -1,5 +1,12 @@ # @objectstack/express +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `createExpressRouter()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (file upload), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/fastify/CHANGELOG.md b/packages/adapters/fastify/CHANGELOG.md index d44e8fc839..ad0d528f68 100644 --- a/packages/adapters/fastify/CHANGELOG.md +++ b/packages/adapters/fastify/CHANGELOG.md @@ -1,5 +1,12 @@ # @objectstack/fastify +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `objectStackPlugin()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (file upload), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index d989751898..8d64a1bcec 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -4,9 +4,10 @@ ### Patch Changes -- fix: register all standard API routes in `createHonoApp()` — resolves 404 errors for `/api/v1/packages`, `/api/v1/analytics`, `/api/v1/automation`, `/api/v1/i18n`, and `/api/v1/ui` routes after Vercel deployment -- fix: forward query parameters to `handleMetadata()` so package filtering and locale queries work correctly -- Added comprehensive tests for all newly registered route handlers +- fix: unified catch-all dispatch pattern — `createHonoApp()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- fix: resolves 404 errors for `/api/v1/meta` and `/api/v1/packages` after Vercel deployment +- Only auth (service check), storage (formData), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes +- Added comprehensive tests for the catch-all dispatch pattern ## 3.2.7 diff --git a/packages/adapters/nestjs/CHANGELOG.md b/packages/adapters/nestjs/CHANGELOG.md index 258cef49ff..494a835fba 100644 --- a/packages/adapters/nestjs/CHANGELOG.md +++ b/packages/adapters/nestjs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/nestjs +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `ObjectStackController` now delegates non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (file upload), GraphQL (raw result), and discovery remain as explicit routes +- Meta and data routes now use `dispatch()` for consistent query/body handling + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/nextjs/CHANGELOG.md b/packages/adapters/nextjs/CHANGELOG.md index c554c21024..89b887bf14 100644 --- a/packages/adapters/nextjs/CHANGELOG.md +++ b/packages/adapters/nextjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @objectstack/nextjs +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `createRouteHandler()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (formData), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/nuxt/CHANGELOG.md b/packages/adapters/nuxt/CHANGELOG.md index 5348da9853..9e94cb6a6a 100644 --- a/packages/adapters/nuxt/CHANGELOG.md +++ b/packages/adapters/nuxt/CHANGELOG.md @@ -1,5 +1,12 @@ # @objectstack/nuxt +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `createH3Router()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (multipart), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes + ## 3.2.7 ### Patch Changes diff --git a/packages/adapters/sveltekit/CHANGELOG.md b/packages/adapters/sveltekit/CHANGELOG.md index a7b7e9d4ca..bf903ffcd6 100644 --- a/packages/adapters/sveltekit/CHANGELOG.md +++ b/packages/adapters/sveltekit/CHANGELOG.md @@ -1,5 +1,12 @@ # @objectstack/sveltekit +## 3.2.8 + +### Patch Changes + +- fix: unified catch-all dispatch pattern — `createRequestHandler()` now delegates all non-framework-specific routes to `HttpDispatcher.dispatch()`, automatically supporting packages, analytics, automation, i18n, ui, openapi, custom endpoints, and any future routes +- Only auth (service check), storage (formData), GraphQL (raw result), and discovery (response wrapper) remain as explicit routes + ## 3.2.7 ### Patch Changes