-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathadmin-routes.ts
More file actions
192 lines (175 loc) · 7.02 KB
/
Copy pathadmin-routes.ts
File metadata and controls
192 lines (175 loc) · 7.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { PluginContext } from '@objectstack/core';
import type { IHttpServer } from '@objectstack/spec/contracts';
import { DRIVER_CATALOG } from './driver-catalog.js';
/**
* Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).
*
* Mounted under `/api/v1/datasources` and served by the `datasource-admin`
* service. Every route degrades gracefully
* (`503 datasource_admin_unavailable`) when the service is not wired in, and
* lifecycle/validation failures surface as `400` with the service's message.
*
* GET /datasources → listDatasources (provenance + health)
* POST /datasources/test → testConnection (no persistence)
* POST /datasources → createDatasource (origin: 'runtime')
* PATCH /datasources/:name → updateDatasource (runtime only)
* DELETE /datasources/:name → removeDatasource (runtime only)
*
* Request bodies carry the connection draft inline with an optional cleartext
* `secret` field; the route splits `secret` out so it never reaches the draft
* the service persists.
*/
export function registerDatasourceAdminRoutes(
server: IHttpServer,
ctx: PluginContext,
basePath = '/api/v1',
): void {
const root = `${basePath}/datasources`;
const adminService = (): any => {
try {
return ctx.getService<any>('datasource-admin');
} catch {
return undefined;
}
};
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' });
const badRequest = (res: any, err: unknown) =>
res.status(400).json({ error: 'datasource_admin_error', message: err instanceof Error ? err.message : String(err) });
/** Split an inline `{ secret, ...draft }` body into (draft, secret). */
const splitSecret = (body: any): { draft: any; secret: any } => {
const { secret, ...draft } = (body as Record<string, unknown>) ?? {};
// Accept either a bare string or a `{ value, namespace?, key? }` object.
const normalised =
secret == null
? undefined
: typeof secret === 'string'
? { value: secret }
: secret;
return { draft, secret: normalised };
};
// List all datasources with provenance + health.
server.get(root, async (_req: any, res: any) => {
const svc = adminService();
if (!svc?.listDatasources) return unavailable(res);
const datasources = await svc.listDatasources();
res.json({ datasources });
});
// Catalog of connection drivers + their JSON-Schema config (drives the
// Studio connection form). Static metadata — no service dependency, so it
// is always available even before any datasource-admin service is wired.
server.get(`${root}/drivers`, async (_req: any, res: any) => {
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.
// Read one datasource's full detail for the edit form (credential stripped;
// `config` is non-sensitive, plus a `hasSecret` flag). Registered after the
// static `/drivers` route so that literal segment is never captured as a name.
server.get(`${root}/:name`, async (req: any, res: any) => {
const svc = adminService();
if (!svc?.getDatasource) return unavailable(res);
try {
const datasource = await svc.getDatasource(req.params.name);
if (!datasource) return res.status(404).json({ error: 'not_found' });
res.json({ datasource });
} catch (err) {
badRequest(res, err);
}
});
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) => {
const svc = adminService();
if (!svc?.testConnection) return unavailable(res);
const { draft, secret } = splitSecret(req.body);
try {
const result = await svc.testConnection(draft, secret);
res.json({ result });
} catch (err) {
badRequest(res, err);
}
});
// Create a runtime datasource.
server.post(root, async (req: any, res: any) => {
const svc = adminService();
if (!svc?.createDatasource) return unavailable(res);
const { draft, secret } = splitSecret(req.body);
try {
const datasource = await svc.createDatasource(draft, secret);
res.status(201).json({ datasource });
} catch (err) {
badRequest(res, err);
}
});
// Patch a runtime datasource.
server.patch(`${root}/:name`, async (req: any, res: any) => {
const svc = adminService();
if (!svc?.updateDatasource) return unavailable(res);
const { draft, secret } = splitSecret(req.body);
try {
const datasource = await svc.updateDatasource(req.params.name, draft, secret);
res.json({ datasource });
} catch (err) {
badRequest(res, err);
}
});
// Remove a runtime datasource.
server.delete(`${root}/:name`, async (req: any, res: any) => {
const svc = adminService();
if (!svc?.removeDatasource) return unavailable(res);
try {
await svc.removeDatasource(req.params.name);
res.status(204).end();
} catch (err) {
badRequest(res, err);
}
});
}