Skip to content

Commit 480fe0d

Browse files
os-zhuangclaude
andcommitted
feat(datasource): read-only introspection routes for Studio sync
Expose the external-datasource introspection service over REST so Studio can list a datasource's remote tables and preview the object definition they would become: - GET /api/v1/datasources/:name/remote-tables → listRemoteTables() - POST /api/v1/datasources/:name/object-draft → generateObjectDraft(table) Both are read-only (introspect + type-map; no persistence) — the caller creates the object through the normal metadata channel. Degrade to 503 when the external-datasource service isn't wired; 400 when the table is missing. Verified live against a seeded SQLite datasource (customers/orders → remote-tables lists both; object-draft yields a typed object definition). Tests: remote-tables list + object-draft (200 + 400-without-table). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 713d405 commit 480fe0d

2 files changed

Lines changed: 59 additions & 0 deletions

File tree

packages/services/service-datasource/src/__tests__/admin-routes.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
5454
expect(sqlite!.configSchema?.properties?.filename?.type).toBe('string');
5555
});
5656

57+
it('GET /api/v1/datasources/:name/remote-tables lists remote tables', async () => {
58+
const listRemoteTables = vi.fn().mockResolvedValue([{ name: 'customers', columnCount: 4 }]);
59+
const app = mount({ listRemoteTables });
60+
const res = await app.fetch(json('/api/v1/datasources/demo_ext/remote-tables'));
61+
expect(res.status).toBe(200);
62+
expect(await res.json()).toEqual({ tables: [{ name: 'customers', columnCount: 4 }] });
63+
expect(listRemoteTables).toHaveBeenCalledWith('demo_ext');
64+
});
65+
66+
it('POST /api/v1/datasources/:name/object-draft generates a draft (400 without table)', async () => {
67+
const generateObjectDraft = vi.fn().mockResolvedValue({ name: 'customers', definition: { fields: { id: {} } } });
68+
const app = mount({ generateObjectDraft });
69+
70+
const missing = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({}) }));
71+
expect(missing.status).toBe(400);
72+
73+
const ok = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) }));
74+
expect(ok.status).toBe(200);
75+
expect((await ok.json()).draft.name).toBe('customers');
76+
expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {});
77+
});
78+
5779
it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => {
5880
const testConnection = vi.fn().mockResolvedValue({ ok: true });
5981
const app = mount({ testConnection });

packages/services/service-datasource/src/admin-routes.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export function registerDatasourceAdminRoutes(
3737
}
3838
};
3939

40+
const externalService = (): any => {
41+
try {
42+
return ctx.getService<any>('external-datasource');
43+
} catch {
44+
return undefined;
45+
}
46+
};
47+
4048
const unavailable = (res: any) =>
4149
res.status(503).json({ error: 'datasource_admin_unavailable' });
4250

@@ -71,6 +79,35 @@ export function registerDatasourceAdminRoutes(
7179
res.json({ drivers: DRIVER_CATALOG });
7280
});
7381

82+
// Read-only schema introspection for the Studio "sync objects" flow.
83+
// `GET /datasources/:name/remote-tables` lists the datasource's remote tables;
84+
// `POST /datasources/:name/object-draft` generates an ObjectStack object
85+
// definition draft for one table (introspect + type-map, no persistence —
86+
// the caller creates the object through the normal metadata channel).
87+
server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => {
88+
const svc = externalService();
89+
if (!svc?.listRemoteTables) return unavailable(res);
90+
try {
91+
const tables = await svc.listRemoteTables(req.params.name);
92+
res.json({ tables });
93+
} catch (err) {
94+
badRequest(res, err);
95+
}
96+
});
97+
98+
server.post(`${root}/:name/object-draft`, async (req: any, res: any) => {
99+
const svc = externalService();
100+
if (!svc?.generateObjectDraft) return unavailable(res);
101+
const { table, ...opts } = (req.body as Record<string, unknown>) ?? {};
102+
if (!table) return badRequest(res, new Error('Body field "table" is required.'));
103+
try {
104+
const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
105+
res.json({ draft });
106+
} catch (err) {
107+
badRequest(res, err);
108+
}
109+
});
110+
74111
// Probe a connection without persisting anything. Registered before the
75112
// `:name` routes so the literal `test` segment is never captured as a name.
76113
server.post(`${root}/test`, async (req: any, res: any) => {

0 commit comments

Comments
 (0)