Skip to content

Commit 486467e

Browse files
os-zhuangclaude
andcommitted
feat(datasource): add GET /datasources/:name detail for the Studio edit form
The datasource list endpoint returns only a summary (no `config`), so the Studio connection form had nothing to prefill when editing an existing datasource. Add `DatasourceAdminService.getDatasource(name)` + a `GET /api/v1/datasources/:name` route returning the credential-stripped detail: `config` (non-sensitive — credentials live in sys_secret, never in config), `origin`, and a `hasSecret` flag so the UI can render "leave blank to keep" without ever receiving the `credentialsRef` or any cleartext. 404 on unknown name; registered after the static `/drivers` route so that segment isn't captured as a name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 124959e commit 486467e

4 files changed

Lines changed: 90 additions & 0 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
7676
expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {});
7777
});
7878

79+
it('GET /api/v1/datasources/:name returns the credential-stripped detail (404 when unknown)', async () => {
80+
const getDatasource = vi.fn(async (name: string) =>
81+
name === 'demo_ext'
82+
? { name: 'demo_ext', driver: 'sqlite', schemaMode: 'managed', config: { filename: '/tmp/x.db' }, active: true, origin: 'runtime', hasSecret: false }
83+
: undefined,
84+
);
85+
const app = mount({ getDatasource });
86+
const ok = await app.fetch(json('/api/v1/datasources/demo_ext'));
87+
expect(ok.status).toBe(200);
88+
expect((await ok.json()).datasource.config.filename).toBe('/tmp/x.db');
89+
const missing = await app.fetch(json('/api/v1/datasources/nope'));
90+
expect(missing.status).toBe(404);
91+
expect(getDatasource).toHaveBeenCalledWith('demo_ext');
92+
});
93+
7994
it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => {
8095
const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 });
8196
const app = mount({ testConnection });

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,33 @@ describe('removeDatasource', () => {
286286
await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i);
287287
});
288288
});
289+
290+
describe('getDatasource', () => {
291+
it('returns config + hasSecret with the credentialsRef stripped', async () => {
292+
const { service } = makeHarness({
293+
seed: [
294+
{
295+
name: 'pg',
296+
driver: 'postgres',
297+
origin: 'runtime',
298+
config: { host: 'db', port: 5432, database: 'app' },
299+
external: { credentialsRef: 'sys_secret://datasource/pg#1' },
300+
},
301+
],
302+
});
303+
const ds = await service.getDatasource('pg');
304+
expect(ds).toMatchObject({ name: 'pg', driver: 'postgres', origin: 'runtime', hasSecret: true });
305+
expect(ds!.config).toEqual({ host: 'db', port: 5432, database: 'app' });
306+
// The opaque credential handle must never be returned.
307+
expect(JSON.stringify(ds)).not.toContain('sys_secret');
308+
expect(JSON.stringify(ds)).not.toContain('credentialsRef');
309+
});
310+
311+
it('reports hasSecret:false and returns undefined for unknown names', async () => {
312+
const { service } = makeHarness({
313+
seed: [{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: '/tmp/a.db' } }],
314+
});
315+
expect((await service.getDatasource('lite'))!.hasSecret).toBe(false);
316+
expect(await service.getDatasource('missing')).toBeUndefined();
317+
});
318+
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ export function registerDatasourceAdminRoutes(
9999
// `datasource` `test_connection` action). Distinct from `POST /datasources/test`
100100
// which probes an unsaved draft carried inline. Registered before the generic
101101
// `:name` mutation routes.
102+
// Read one datasource's full detail for the edit form (credential stripped;
103+
// `config` is non-sensitive, plus a `hasSecret` flag). Registered after the
104+
// static `/drivers` route so that literal segment is never captured as a name.
105+
server.get(`${root}/:name`, async (req: any, res: any) => {
106+
const svc = adminService();
107+
if (!svc?.getDatasource) return unavailable(res);
108+
try {
109+
const datasource = await svc.getDatasource(req.params.name);
110+
if (!datasource) return res.status(404).json({ error: 'not_found' });
111+
res.json({ datasource });
112+
} catch (err) {
113+
badRequest(res, err);
114+
}
115+
});
116+
102117
server.post(`${root}/:name/test`, async (req: any, res: any) => {
103118
const svc = externalService();
104119
if (!svc?.testConnection) return unavailable(res);

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ export class DatasourceAdminService implements IDatasourceAdminService {
130130
return summaries;
131131
}
132132

133+
/**
134+
* Read one datasource's full detail for editing, with the credential stripped.
135+
* Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
136+
* config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
137+
* keep" without ever receiving the `credentialsRef` or any cleartext. Returns
138+
* `undefined` when the name is unknown.
139+
*/
140+
async getDatasource(name: string): Promise<
141+
| (Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
142+
origin: 'code' | 'runtime';
143+
hasSecret: boolean;
144+
})
145+
| undefined
146+
> {
147+
const rec = await this.config.getDatasourceRecord(name);
148+
if (!rec) return undefined;
149+
const hasSecret = Boolean(rec.external?.credentialsRef);
150+
return {
151+
name: rec.name,
152+
label: rec.label,
153+
driver: rec.driver,
154+
schemaMode: rec.schemaMode ?? 'managed',
155+
config: rec.config ?? {},
156+
active: rec.active ?? true,
157+
origin: rec.origin === 'runtime' ? 'runtime' : 'code',
158+
hasSecret,
159+
...(rec.definedIn ? { definedIn: rec.definedIn } : {}),
160+
};
161+
}
162+
133163
async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
134164
if (!input?.driver) {
135165
return { ok: false, error: 'A driver is required to test a connection.' };

0 commit comments

Comments
 (0)