Skip to content

Commit 124959e

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(datasource): read-only introspection routes + by-name test for Studio sync (#2085)
* 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> * feat(datasource): add POST /datasources/:name/test for saved datasources The `datasource` `test_connection` action declares `POST /datasources/:id/test` (probe a saved datasource by name), but the admin routes only implemented `POST /datasources/test` (probe an unsaved inline draft). The action — and any UI that wires it (the Studio datasource manager) — hit a 404. Add a thin by-name route backed by a new `ExternalDatasourceService.testConnection(name)` that times a live introspect (driver connect + schema read) and returns `{ ok, latencyMs, tableCount }` or `{ ok:false, error }`. Reuses the same wired introspection pool as remote-tables/object-draft, so the secret is resolved through the existing path — the route never handles cleartext. Registered before the generic `:name` mutation routes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e16f2a8 commit 124959e

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,37 @@ 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+
79+
it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => {
80+
const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 });
81+
const app = mount({ testConnection });
82+
const res = await app.fetch(json('/api/v1/datasources/demo_ext/test', { method: 'POST', body: '{}' }));
83+
expect(res.status).toBe(200);
84+
expect(await res.json()).toEqual({ ok: true, latencyMs: 7, tableCount: 2 });
85+
expect(testConnection).toHaveBeenCalledWith('demo_ext');
86+
});
87+
5788
it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => {
5889
const testConnection = vi.fn().mockResolvedValue({ ok: true });
5990
const app = mount({ testConnection });

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

Lines changed: 52 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,50 @@ 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+
// Test a *saved* datasource by name with a live round-trip (backs the
99+
// `datasource` `test_connection` action). Distinct from `POST /datasources/test`
100+
// which probes an unsaved draft carried inline. Registered before the generic
101+
// `:name` mutation routes.
102+
server.post(`${root}/:name/test`, async (req: any, res: any) => {
103+
const svc = externalService();
104+
if (!svc?.testConnection) return unavailable(res);
105+
try {
106+
const result = await svc.testConnection(req.params.name);
107+
res.json(result);
108+
} catch (err) {
109+
badRequest(res, err);
110+
}
111+
});
112+
113+
server.post(`${root}/:name/object-draft`, async (req: any, res: any) => {
114+
const svc = externalService();
115+
if (!svc?.generateObjectDraft) return unavailable(res);
116+
const { table, ...opts } = (req.body as Record<string, unknown>) ?? {};
117+
if (!table) return badRequest(res, new Error('Body field "table" is required.'));
118+
try {
119+
const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
120+
res.json({ draft });
121+
} catch (err) {
122+
badRequest(res, err);
123+
}
124+
});
125+
74126
// Probe a connection without persisting anything. Registered before the
75127
// `:name` routes so the literal `test` segment is never captured as a name.
76128
server.post(`${root}/test`, async (req: any, res: any) => {

packages/services/service-datasource/src/external-datasource-service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,31 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
155155
return tables;
156156
}
157157

158+
/**
159+
* Probe a *saved* datasource by name with a live round-trip. Reuses the
160+
* introspection path (driver connect + schema read) as a cheap connectivity
161+
* check, so the secret is resolved through the same wired pool as the rest of
162+
* the introspection surface — the caller never handles cleartext. Returns a
163+
* structured result rather than throwing so the route can render ok/error
164+
* uniformly. This backs the `datasource` `test_connection` action
165+
* (`POST /datasources/:name/test`).
166+
*/
167+
async testConnection(
168+
datasource: string,
169+
): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> {
170+
const started = Date.now();
171+
try {
172+
const schema = await this.config.introspect(datasource);
173+
return {
174+
ok: true,
175+
latencyMs: Date.now() - started,
176+
tableCount: Object.keys(schema.tables).length,
177+
};
178+
} catch (err) {
179+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
180+
}
181+
}
182+
158183
async generateObjectDraft(
159184
datasource: string,
160185
remoteName: string,

0 commit comments

Comments
 (0)