diff --git a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts index bef2045582..1d2c4e3615 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts @@ -54,6 +54,37 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { expect(sqlite!.configSchema?.properties?.filename?.type).toBe('string'); }); + it('GET /api/v1/datasources/:name/remote-tables lists remote tables', async () => { + const listRemoteTables = vi.fn().mockResolvedValue([{ name: 'customers', columnCount: 4 }]); + const app = mount({ listRemoteTables }); + const res = await app.fetch(json('/api/v1/datasources/demo_ext/remote-tables')); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ tables: [{ name: 'customers', columnCount: 4 }] }); + expect(listRemoteTables).toHaveBeenCalledWith('demo_ext'); + }); + + it('POST /api/v1/datasources/:name/object-draft generates a draft (400 without table)', async () => { + const generateObjectDraft = vi.fn().mockResolvedValue({ name: 'customers', definition: { fields: { id: {} } } }); + const app = mount({ generateObjectDraft }); + + const missing = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({}) })); + expect(missing.status).toBe(400); + + const ok = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) })); + expect(ok.status).toBe(200); + expect((await ok.json()).draft.name).toBe('customers'); + expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {}); + }); + + it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => { + const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 }); + const app = mount({ testConnection }); + const res = await app.fetch(json('/api/v1/datasources/demo_ext/test', { method: 'POST', body: '{}' })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, latencyMs: 7, tableCount: 2 }); + expect(testConnection).toHaveBeenCalledWith('demo_ext'); + }); + it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => { const testConnection = vi.fn().mockResolvedValue({ ok: true }); const app = mount({ testConnection }); diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index f39225d75b..105971e891 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -37,6 +37,14 @@ export function registerDatasourceAdminRoutes( } }; + const externalService = (): any => { + try { + return ctx.getService('external-datasource'); + } catch { + return undefined; + } + }; + const unavailable = (res: any) => res.status(503).json({ error: 'datasource_admin_unavailable' }); @@ -71,6 +79,50 @@ export function registerDatasourceAdminRoutes( res.json({ drivers: DRIVER_CATALOG }); }); + // Read-only schema introspection for the Studio "sync objects" flow. + // `GET /datasources/:name/remote-tables` lists the datasource's remote tables; + // `POST /datasources/:name/object-draft` generates an ObjectStack object + // definition draft for one table (introspect + type-map, no persistence — + // the caller creates the object through the normal metadata channel). + server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.listRemoteTables) return unavailable(res); + try { + const tables = await svc.listRemoteTables(req.params.name); + res.json({ tables }); + } catch (err) { + badRequest(res, err); + } + }); + + // Test a *saved* datasource by name with a live round-trip (backs the + // `datasource` `test_connection` action). Distinct from `POST /datasources/test` + // which probes an unsaved draft carried inline. Registered before the generic + // `:name` mutation routes. + server.post(`${root}/:name/test`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.testConnection) return unavailable(res); + try { + const result = await svc.testConnection(req.params.name); + res.json(result); + } catch (err) { + badRequest(res, err); + } + }); + + server.post(`${root}/:name/object-draft`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.generateObjectDraft) return unavailable(res); + const { table, ...opts } = (req.body as Record) ?? {}; + if (!table) return badRequest(res, new Error('Body field "table" is required.')); + try { + const draft = await svc.generateObjectDraft(req.params.name, String(table), opts); + res.json({ draft }); + } catch (err) { + badRequest(res, err); + } + }); + // Probe a connection without persisting anything. Registered before the // `:name` routes so the literal `test` segment is never captured as a name. server.post(`${root}/test`, async (req: any, res: any) => { diff --git a/packages/services/service-datasource/src/external-datasource-service.ts b/packages/services/service-datasource/src/external-datasource-service.ts index 98a1bfa203..36260eb2bf 100644 --- a/packages/services/service-datasource/src/external-datasource-service.ts +++ b/packages/services/service-datasource/src/external-datasource-service.ts @@ -155,6 +155,31 @@ export class ExternalDatasourceService implements IExternalDatasourceService { return tables; } + /** + * Probe a *saved* datasource by name with a live round-trip. Reuses the + * introspection path (driver connect + schema read) as a cheap connectivity + * check, so the secret is resolved through the same wired pool as the rest of + * the introspection surface — the caller never handles cleartext. Returns a + * structured result rather than throwing so the route can render ok/error + * uniformly. This backs the `datasource` `test_connection` action + * (`POST /datasources/:name/test`). + */ + async testConnection( + datasource: string, + ): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> { + const started = Date.now(); + try { + const schema = await this.config.introspect(datasource); + return { + ok: true, + latencyMs: Date.now() - started, + tableCount: Object.keys(schema.tables).length, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + async generateObjectDraft( datasource: string, remoteName: string,