Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
52 changes: 52 additions & 0 deletions packages/services/service-datasource/src/admin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function registerDatasourceAdminRoutes(
}
};

const externalService = (): any => {
try {
return ctx.getService<any>('external-datasource');
} catch {
return undefined;
}
};

const unavailable = (res: any) =>
res.status(503).json({ error: 'datasource_admin_unavailable' });

Expand Down Expand Up @@ -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<string, unknown>) ?? {};
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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading