sap-ai-dev-toolkit 0.9.9918 → 0.9.9921
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/sap-integration-architect.agent.md +32 -18
- package/.github/agents/sap-integration-developer.agent.md +28 -12
- package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
- package/.github/skills/sap-integration-architect/SKILL.md +49 -25
- package/README.md +72 -15
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +97 -57
- package/package.json +1 -1
- package/scripts/check-live-integration.mjs +151 -0
- package/scripts/install-local.mjs +11 -4
- package/scripts/run-live-integration.mjs +55 -63
- package/src/apim-bundle.mjs +416 -13
- package/src/apim-cleanup-tools.mjs +145 -0
- package/src/apim-content.mjs +157 -0
- package/src/apim-delete-tools.mjs +139 -0
- package/src/apim-documentation.mjs +162 -0
- package/src/apim-inspection.mjs +141 -0
- package/src/apim-monitoring.mjs +140 -0
- package/src/apim-policy-steps.mjs +31 -0
- package/src/apim-runtime.mjs +262 -0
- package/src/apim-tools.mjs +202 -109
- package/src/cf-integration.mjs +46 -7
- package/src/cpi-async-monitoring.mjs +177 -0
- package/src/cpi-content.mjs +92 -0
- package/src/cpi-monitoring.mjs +225 -0
- package/src/cpi-operational.mjs +129 -0
- package/src/cpi-release.mjs +273 -0
- package/src/cpi-security.mjs +48 -0
- package/src/cpi-tools.mjs +184 -319
- package/src/devhub-retirement.mjs +345 -0
- package/src/devhub-tools.mjs +177 -0
- package/src/edge-monitoring.mjs +101 -0
- package/src/iflow-bundle.mjs +295 -7
- package/src/integration-alerts.mjs +248 -0
- package/src/integration-analysis.mjs +488 -0
- package/src/integration-capabilities.mjs +181 -0
- package/src/integration-collections.mjs +203 -0
- package/src/integration-common.mjs +140 -35
- package/src/integration-config.mjs +20 -14
- package/src/integration-credentials.mjs +16 -10
- package/src/integration-http.mjs +255 -67
- package/src/integration-messaging.mjs +118 -0
- package/src/integration-optional-config.mjs +122 -0
- package/src/integration-optional-tools.mjs +153 -0
- package/src/integration-release-store.mjs +410 -0
- package/src/integration-server.mjs +34 -16
- package/src/integration-state.mjs +31 -0
- package/src/integration-tools.mjs +44 -8
- package/src/integration-transport.mjs +184 -0
- package/src/integration-validation.mjs +26 -0
- package/src/integration-verify.mjs +143 -62
- package/src/open-connectors-tools.mjs +143 -0
- package/src/setup.mjs +86 -52
- package/tools.md +193 -26
package/src/apim-tools.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// MCP tools for SAP API Management (APIM) over the API portal Management.svc / Transport.svc APIs.
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { attachPolicies, buildApiProxyBundle, bundleDocumentation, bundlePolicyNames, patchBundleDocumentation, patchProxyBundle } from './apim-bundle.mjs';
|
|
5
|
+
import { DOCUMENTATION_METHODS, MAX_DOCUMENTATION_BYTES, parseApiDocumentation, summarizeApiDocumentation } from './apim-documentation.mjs';
|
|
4
6
|
import {
|
|
5
7
|
CREATE_ANNOTATIONS,
|
|
6
8
|
OVERWRITE_ANNOTATIONS,
|
|
@@ -12,18 +14,21 @@ import {
|
|
|
12
14
|
key,
|
|
13
15
|
list,
|
|
14
16
|
pick,
|
|
17
|
+
redactSecrets,
|
|
15
18
|
registerODataReader,
|
|
16
19
|
requireConfirmation,
|
|
17
20
|
secretValue,
|
|
18
|
-
sleep,
|
|
19
21
|
top,
|
|
20
22
|
withoutSecrets
|
|
21
23
|
} from './integration-common.mjs';
|
|
22
24
|
import { IntegrationError, odataKey } from './integration-http.mjs';
|
|
23
25
|
import { readBundle, writeBundle } from './iflow-bundle.mjs';
|
|
26
|
+
import { registerApimDeleteTools } from './apim-delete-tools.mjs';
|
|
27
|
+
import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
|
|
28
|
+
import { APIM_BASE, exportApiProxyBundle, importApiProxyBundle, readApiProxy } from './apim-content.mjs';
|
|
29
|
+
import { registerApimMonitoringTools } from './apim-monitoring.mjs';
|
|
30
|
+
import { registerApimPolicyStepTools } from './apim-policy-steps.mjs';
|
|
24
31
|
|
|
25
|
-
const APIM_BASE = '/apiportal/api/1.0/Management.svc';
|
|
26
|
-
const APIM_TRANSPORT = '/apiportal/api/1.0/Transport.svc/APIProxies';
|
|
27
32
|
const RESERVED_PROVIDER_NAMES = new Set(['DEST_CI', 'APIMGMT_PORTAL', 'ContentCatalog', 'ON_PREM', 'TC']);
|
|
28
33
|
|
|
29
34
|
const apimName = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,99}$/);
|
|
@@ -148,22 +153,54 @@ function withoutValues(error, values) {
|
|
|
148
153
|
return new IntegrationError(message);
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
function requireNamedMetadata(value, name, kind) {
|
|
157
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.name !== name) {
|
|
158
|
+
throw new IntegrationError(`${kind} ${name} returned malformed metadata; response withheld`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Management metadata is not an authoring/source surface. Keep operational URL
|
|
164
|
+
// paths, but never return embedded credentials or request query/fragment values.
|
|
165
|
+
function safeMetadata(value) {
|
|
166
|
+
if (Array.isArray(value)) return value.map(safeMetadata);
|
|
167
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, entry]) => [name, safeMetadata(entry)]));
|
|
168
|
+
if (typeof value === 'string' && /^https?:\/\//i.test(value)) {
|
|
169
|
+
try {
|
|
170
|
+
const url = new URL(value);
|
|
171
|
+
url.username = ''; url.password = ''; url.search = ''; url.hash = '';
|
|
172
|
+
return url.href;
|
|
173
|
+
} catch { return null; }
|
|
174
|
+
}
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
151
180
|
/** Register apim_* tools for one APIM client. */
|
|
152
181
|
export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
153
182
|
const apim = (method, path, options) => client.request(method, `${APIM_BASE}${path}`, options);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
183
|
+
const collections = createCollectionPager(client, { root: APIM_BASE, request: apim });
|
|
184
|
+
registerApimDeleteTools(register, { request: apim, readBackDelayMs });
|
|
185
|
+
registerApimMonitoringTools(register, client);
|
|
186
|
+
registerApimPolicyStepTools(register, client, { readBackDelayMs });
|
|
187
|
+
|
|
188
|
+
async function exists(collection, name) {
|
|
189
|
+
// Name-only collection reads avoid resolving provider destinations or proxy details.
|
|
190
|
+
const matches = list(await apim('GET', collection, {
|
|
191
|
+
query: { $filter: `name eq ${odataKey(name)}`, $select: 'name', $top: 1 }
|
|
192
|
+
}));
|
|
193
|
+
return matches.some(entry => entry.name === name);
|
|
158
194
|
}
|
|
159
195
|
|
|
160
196
|
async function getProvider(name) {
|
|
161
197
|
const { status, data } = await apim('GET', `/APIProviders(${key(name)})`, { expect: [200, 404], withStatus: true });
|
|
162
198
|
if (status === 404) return null;
|
|
199
|
+
const provider = requireNamedMetadata(entity(data), name, 'API provider');
|
|
163
200
|
// $expand=additionalProperties returns an empty list on live tenants; the navigation link is reliable.
|
|
164
201
|
const additional = list(await apim('GET', `/APIProviders(${key(name)})/additionalProperties`))
|
|
165
202
|
.map(entry => pick(entry, ['name', 'value']));
|
|
166
|
-
return withoutSecrets({ ...
|
|
203
|
+
return safeMetadata(withoutSecrets(redactSecrets({ ...provider, additionalProperties: additional })));
|
|
167
204
|
}
|
|
168
205
|
|
|
169
206
|
async function writeSapClient(name, sapClient) {
|
|
@@ -173,48 +210,9 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
173
210
|
});
|
|
174
211
|
}
|
|
175
212
|
|
|
176
|
-
function runtimeUrl(proxy, defaultHost) {
|
|
177
|
-
const candidates = [proxy?.publishUrl, ...list(proxy?.deploymentInfo).map(entry => entry?.publishUrl), proxy?.deploymentInfo?.publishUrl];
|
|
178
|
-
const published = candidates.find(value => typeof value === 'string' && /^https?:\/\/\S+$/.test(value));
|
|
179
|
-
if (published) return published;
|
|
180
|
-
const endpoint = list(proxy?.proxyEndPoints)[0];
|
|
181
|
-
const host = list(endpoint?.virtualhosts)[0] ?? defaultHost;
|
|
182
|
-
if (!endpoint || !host?.virtual_host) return null;
|
|
183
|
-
const port = Number(host.virtual_port);
|
|
184
|
-
const portText = !port || port === 443 || port === 80 ? '' : `:${port}`;
|
|
185
|
-
return `${host.isSSL ? 'https' : 'http'}://${host.virtual_host}${portText}${endpoint.base_path ?? ''}`;
|
|
186
|
-
}
|
|
187
213
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
query: { $expand: 'proxyEndPoints,targetEndPoints,proxyEndPoints/virtualhosts,deploymentInfo' },
|
|
191
|
-
expect: [200, 404],
|
|
192
|
-
withStatus: true
|
|
193
|
-
});
|
|
194
|
-
if (status === 404) return null;
|
|
195
|
-
let defaultHost = null;
|
|
196
|
-
if (!list(list(data?.proxyEndPoints)[0]?.virtualhosts).length) {
|
|
197
|
-
defaultHost = list(await apim('GET', '/VirtualHosts', { query: { $filter: 'isDefault eq true' } }))[0] ?? null;
|
|
198
|
-
}
|
|
199
|
-
const url = runtimeUrl(data, defaultHost);
|
|
200
|
-
return { ...withoutSecrets(entity(data)), runtime_url: url };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async function exportBundle(name) {
|
|
204
|
-
const { status, data } = await client.request('GET', APIM_TRANSPORT, {
|
|
205
|
-
query: { name },
|
|
206
|
-
accept: 'application/zip, application/octet-stream',
|
|
207
|
-
responseType: 'bytes',
|
|
208
|
-
expect: [200, 404],
|
|
209
|
-
withStatus: true
|
|
210
|
-
});
|
|
211
|
-
if (status === 404) return null;
|
|
212
|
-
try {
|
|
213
|
-
return readBundle(data);
|
|
214
|
-
} catch {
|
|
215
|
-
throw new IntegrationError(`API proxy ${name} export is not a readable zip archive`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
214
|
+
const getProxy = name => readApiProxy(client, name);
|
|
215
|
+
const exportBundle = name => exportApiProxyBundle(client, name);
|
|
218
216
|
|
|
219
217
|
async function requireBundle(name, missingHint = '') {
|
|
220
218
|
const files = await exportBundle(name);
|
|
@@ -222,27 +220,10 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
222
220
|
return files;
|
|
223
221
|
}
|
|
224
222
|
|
|
225
|
-
|
|
226
|
-
async function importBundle(name, bytes) {
|
|
227
|
-
await client.request('POST', APIM_TRANSPORT, {
|
|
228
|
-
body: Buffer.from(bytes).toString('base64'),
|
|
229
|
-
accept: 'application/zip',
|
|
230
|
-
responseType: 'text',
|
|
231
|
-
expect: [200, 201]
|
|
232
|
-
});
|
|
233
|
-
let proxy = null;
|
|
234
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
235
|
-
if (attempt) await sleep(readBackDelayMs);
|
|
236
|
-
proxy = await getProxy(name);
|
|
237
|
-
if (proxy) break;
|
|
238
|
-
}
|
|
239
|
-
const result = { name, runtime_url: proxy?.runtime_url ?? null, state: proxy?.state ?? null };
|
|
240
|
-
if (!proxy) result.note = 'Proxy imported; APIM read-back is delayed — call apim_get_api_proxy shortly.';
|
|
241
|
-
return result;
|
|
242
|
-
}
|
|
223
|
+
const importBundle = (name, bytes) => importApiProxyBundle(client, name, bytes, { readBackDelayMs });
|
|
243
224
|
|
|
244
225
|
async function requireProvider(target) {
|
|
245
|
-
if (target.provider && !(await exists(
|
|
226
|
+
if (target.provider && !(await exists('/APIProviders', target.provider))) {
|
|
246
227
|
throw new IntegrationError(`API provider ${target.provider} does not exist; create it with apim_create_api_provider`);
|
|
247
228
|
}
|
|
248
229
|
return target.provider ? { provider: target.provider, relativePath: target.relative_path } : { url: target.url };
|
|
@@ -251,13 +232,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
251
232
|
async function getProduct(name) {
|
|
252
233
|
const { status, data } = await apim('GET', `/APIProducts(${key(name)})`, { query: { $expand: 'apiProxies' }, expect: [200, 404], withStatus: true });
|
|
253
234
|
if (status === 404) return null;
|
|
254
|
-
const { apiProxies, ...product } = entity(data);
|
|
255
|
-
return { ...product, api_proxies: list(apiProxies).map(proxy => proxy?.name).filter(Boolean) };
|
|
235
|
+
const { apiProxies, ...product } = requireNamedMetadata(entity(data), name, 'API product');
|
|
236
|
+
return { ...pick(product, PRODUCT_PUT_FIELDS), api_proxies: list(apiProxies).map(proxy => proxy?.name).filter(Boolean) };
|
|
256
237
|
}
|
|
257
238
|
|
|
258
239
|
async function getKeyValueMap(name) {
|
|
259
240
|
const { status, data } = await apim('GET', `/KeyMapEntries(${key(name)})`, { query: { $expand: 'keyMapEntryValues' }, expect: [200, 404], withStatus: true });
|
|
260
|
-
return status === 404 ? null : entity(data);
|
|
241
|
+
return status === 404 ? null : requireNamedMetadata(entity(data), name, 'Key value map');
|
|
261
242
|
}
|
|
262
243
|
|
|
263
244
|
registerODataReader(register, {
|
|
@@ -265,6 +246,8 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
265
246
|
title: 'Read APIM OData API',
|
|
266
247
|
service: 'API Management Management.svc',
|
|
267
248
|
request: apim,
|
|
249
|
+
client,
|
|
250
|
+
root: APIM_BASE,
|
|
268
251
|
examples: '/Applications, /Developers, /CertificateStoreReferences, /APIResources with $filter'
|
|
269
252
|
});
|
|
270
253
|
|
|
@@ -272,11 +255,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
272
255
|
|
|
273
256
|
register('apim_list_api_providers', 'List API providers',
|
|
274
257
|
'List API Management API providers (connection metadata only; credential fields are never selected).',
|
|
275
|
-
{ top: top() },
|
|
258
|
+
{ top: top(), cursor: cursorSchema },
|
|
276
259
|
READ_ONLY_ANNOTATIONS,
|
|
277
|
-
async
|
|
278
|
-
|
|
279
|
-
|
|
260
|
+
async args => collections.page({
|
|
261
|
+
...args, path: '/APIProviders', orderBy: 'name asc', identity: provider => provider.name,
|
|
262
|
+
query: { $select: 'name,title,description,destType,host,port,useSSL,isOnPremise,cloudConnectorLocation,pathPrefix' },
|
|
263
|
+
project: provider => pick(provider, ['name', 'title', 'description', 'destType', 'host', 'port', 'useSSL', 'isOnPremise', 'cloudConnectorLocation', 'pathPrefix'])
|
|
264
|
+
}));
|
|
280
265
|
|
|
281
266
|
register('apim_get_api_provider', 'Get API provider',
|
|
282
267
|
'Read one API provider; credential properties are removed before returning.',
|
|
@@ -334,11 +319,19 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
334
319
|
if (args.path_prefix) body.pathPrefix = args.path_prefix;
|
|
335
320
|
await apim('POST', '/APIProviders', { json: body, expect: [201] });
|
|
336
321
|
if (args.sap_client) await writeSapClient(args.name, args.sap_client);
|
|
337
|
-
|
|
322
|
+
const result = {
|
|
338
323
|
created: true,
|
|
339
|
-
|
|
340
|
-
|
|
324
|
+
name: args.name,
|
|
325
|
+
provider: null,
|
|
326
|
+
note: 'Provider created. APIM reads may lag; re-read with apim_get_api_provider if provider is null. Do not repeat creation.'
|
|
341
327
|
};
|
|
328
|
+
try {
|
|
329
|
+
result.provider = await getProvider(args.name);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!(error instanceof IntegrationError)) throw error;
|
|
332
|
+
result.read_back_error = error.message;
|
|
333
|
+
}
|
|
334
|
+
return result;
|
|
342
335
|
});
|
|
343
336
|
|
|
344
337
|
register('apim_update_api_provider', 'Update API provider',
|
|
@@ -396,11 +389,13 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
396
389
|
|
|
397
390
|
register('apim_list_api_proxies', 'List API proxies',
|
|
398
391
|
'List API Management API proxies (name, title, service type, state, version, provider).',
|
|
399
|
-
{ top: top() },
|
|
392
|
+
{ top: top(), cursor: cursorSchema },
|
|
400
393
|
READ_ONLY_ANNOTATIONS,
|
|
401
|
-
async
|
|
402
|
-
|
|
403
|
-
|
|
394
|
+
async args => collections.page({
|
|
395
|
+
...args, path: '/APIProxies', orderBy: 'name asc', identity: proxy => proxy.name,
|
|
396
|
+
query: { $select: 'name,title,description,service_code,state,version,provider_name' },
|
|
397
|
+
project: proxy => pick(proxy, ['name', 'title', 'description', 'service_code', 'state', 'version', 'provider_name'])
|
|
398
|
+
}));
|
|
404
399
|
|
|
405
400
|
register('apim_get_api_proxy', 'Get API proxy',
|
|
406
401
|
'Read one API proxy with its proxy/target endpoints, virtual hosts and deployment info, plus the computed runtime_url.',
|
|
@@ -409,7 +404,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
409
404
|
async ({ name }) => {
|
|
410
405
|
const proxy = await getProxy(name);
|
|
411
406
|
if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
|
|
412
|
-
return proxy;
|
|
407
|
+
return safeMetadata(withoutSecrets(redactSecrets(requireNamedMetadata(proxy, name, 'API proxy'))));
|
|
413
408
|
});
|
|
414
409
|
|
|
415
410
|
register('apim_export_api_proxy', 'Export API proxy bundle',
|
|
@@ -420,20 +415,22 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
420
415
|
|
|
421
416
|
register('apim_list_virtual_hosts', 'List virtual hosts',
|
|
422
417
|
'List API Management virtual hosts (host, port, SSL, default flag).',
|
|
423
|
-
{},
|
|
418
|
+
{ top: top(), cursor: cursorSchema },
|
|
424
419
|
READ_ONLY_ANNOTATIONS,
|
|
425
|
-
async
|
|
426
|
-
|
|
420
|
+
async args => collections.page({
|
|
421
|
+
...args, path: '/VirtualHosts', mode: 'local',
|
|
422
|
+
project: host => pick(host, ['id', 'name', 'virtual_host', 'virtual_port', 'isSSL', 'isDefault'])
|
|
423
|
+
}));
|
|
427
424
|
|
|
428
425
|
register('apim_create_api_proxy', 'Create API proxy',
|
|
429
|
-
'Create and deploy an API proxy (Transport.svc import) for an existing API provider or a fixed https URL; for OData provider targets relative_path must be a /sap/opu/odata(4)/ service path. Returns the runtime_url. Requires confirmation exactly equal to CREATE API PROXY <name>.',
|
|
430
|
-
proxyInput,
|
|
426
|
+
'Create and deploy an API proxy (Transport.svc import) for an existing API provider or a fixed https URL; for OData provider targets relative_path must be a /sap/opu/odata(4)/ service path. Optional policies use the same templates as apim_attach_api_proxy_policies and are included before the first deployment, avoiding an unsecured revision. Returns the runtime_url. Requires confirmation exactly equal to CREATE API PROXY <name>.',
|
|
427
|
+
proxyInput.safeExtend({ policies: z.array(policySpec).min(1).max(10).optional() }),
|
|
431
428
|
CREATE_ANNOTATIONS,
|
|
432
429
|
async args => {
|
|
433
430
|
requireConfirmation(args.confirmation, `CREATE API PROXY ${args.name}`);
|
|
434
|
-
if (await exists(
|
|
431
|
+
if (await exists('/APIProxies', args.name)) throw new IntegrationError(`API proxy ${args.name} already exists; use apim_update_api_proxy`);
|
|
435
432
|
const target = await requireProvider(args.target);
|
|
436
|
-
|
|
433
|
+
let bundle = buildApiProxyBundle({
|
|
437
434
|
name: args.name,
|
|
438
435
|
title: args.title ?? args.name,
|
|
439
436
|
description: args.description ?? '',
|
|
@@ -441,6 +438,11 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
441
438
|
serviceType: args.service_type,
|
|
442
439
|
target
|
|
443
440
|
});
|
|
441
|
+
if (args.policies) {
|
|
442
|
+
const files = readBundle(bundle);
|
|
443
|
+
attachPolicies(files, args.name, args.policies);
|
|
444
|
+
bundle = writeBundle(files);
|
|
445
|
+
}
|
|
444
446
|
return { imported: true, base_path: args.base_path, ...(await importBundle(args.name, bundle)) };
|
|
445
447
|
});
|
|
446
448
|
|
|
@@ -459,33 +461,80 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
459
461
|
basePath: args.base_path,
|
|
460
462
|
target
|
|
461
463
|
});
|
|
464
|
+
const deployment = await importBundle(args.name, writeBundle(files));
|
|
462
465
|
return {
|
|
463
|
-
updated:
|
|
464
|
-
imported: true,
|
|
466
|
+
updated: deployment.content_verified,
|
|
465
467
|
base_path: args.base_path,
|
|
466
468
|
preserved_policies: preservedPolicies,
|
|
467
|
-
...
|
|
469
|
+
...deployment
|
|
468
470
|
};
|
|
469
471
|
});
|
|
470
472
|
|
|
471
473
|
register('apim_attach_api_proxy_policies', 'Attach API proxy policies',
|
|
472
|
-
'Add or replace policies
|
|
474
|
+
'Add or replace policies from templates (verify_api_key, quota, spike_arrest, cors, assign_headers, oauth_verify_access_token, ip_allow, response_cache, basic_auth_from_kvm, raise_fault, javascript, custom). Export, replace policy XML, register, append new steps and import; this replaces the draft, imports a new revision and requests deployment. Re-attaching replaces policy XML without duplicating or moving its exact supported assignment; a supplied condition updates that assignment, while omission preserves its condition. Use apim_edit_policy_steps for moves, removals or ordering. response_cache steps on proxy preFlow request and postFlow response; basic_auth_from_kvm adds <name>_kvm then <name>, default target preFlow request. Requires confirmation exactly equal to UPDATE API PROXY POLICIES <name>.',
|
|
473
475
|
z.object({ name: apimName, policies: z.array(policySpec).min(1).max(10), confirmation }).strict(),
|
|
474
476
|
OVERWRITE_ANNOTATIONS,
|
|
475
477
|
async args => {
|
|
476
478
|
requireConfirmation(args.confirmation, `UPDATE API PROXY POLICIES ${args.name}`);
|
|
477
479
|
const files = await requireBundle(args.name);
|
|
478
480
|
const { policies, stepsAdded } = attachPolicies(files, args.name, args.policies);
|
|
479
|
-
|
|
481
|
+
const deployment = await importBundle(args.name, writeBundle(files));
|
|
482
|
+
return { updated: deployment.content_verified, policies, steps_added: stepsAdded, ...deployment };
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
register('apim_get_api_documentation', 'Get API documentation',
|
|
486
|
+
'Read registered OpenAPI JSON as a safe summary of version, security schemes and path/method inventory (100 operations per page; continue with next_operation_offset), optionally selecting one bounded operation. Examples, raw schemas and arbitrary extension payloads are never returned.',
|
|
487
|
+
z.object({
|
|
488
|
+
name: apimName,
|
|
489
|
+
path: z.string().min(1).max(2048).optional(),
|
|
490
|
+
method: z.enum(DOCUMENTATION_METHODS).optional(),
|
|
491
|
+
operation_offset: z.number().int().min(0).max(1_000_000).default(0)
|
|
492
|
+
}).strict().superRefine((value, context) => {
|
|
493
|
+
if ((value.path === undefined) !== (value.method === undefined)) context.addIssue({ code: 'custom', message: 'path and method must be supplied together' });
|
|
494
|
+
}),
|
|
495
|
+
READ_ONLY_ANNOTATIONS,
|
|
496
|
+
async ({ name, path, method, operation_offset }) => {
|
|
497
|
+
const document = bundleDocumentation(await requireBundle(name), name);
|
|
498
|
+
return { name, documentation_path: document.path, ...summarizeApiDocumentation(document.text, { path, method, operationOffset: operation_offset }) };
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
register('apim_update_api_documentation', 'Update API documentation',
|
|
502
|
+
'Replace the registered English SWAGGER_JSON documentation of an existing proxy with self-contained OpenAPI 2.0 or 3.0/3.1 JSON (maximum 1 MiB). Its explicit HTTPS public runtime URL must match the named proxy; external references and ambiguous layouts are rejected. Exports fresh, changes only documentation bytes, imports/redeploys and reads the decoded document back. Requires confirmation exactly equal to UPDATE API DOCUMENTATION <name>.',
|
|
503
|
+
z.object({
|
|
504
|
+
name: apimName,
|
|
505
|
+
openapi_json: z.string().min(2).max(MAX_DOCUMENTATION_BYTES).refine(value => Buffer.byteLength(value, 'utf8') <= MAX_DOCUMENTATION_BYTES, 'must not exceed 1 MiB'),
|
|
506
|
+
confirmation
|
|
507
|
+
}).strict(),
|
|
508
|
+
OVERWRITE_ANNOTATIONS,
|
|
509
|
+
async ({ name, openapi_json, confirmation: approved }) => {
|
|
510
|
+
requireConfirmation(approved, `UPDATE API DOCUMENTATION ${name}`);
|
|
511
|
+
parseApiDocumentation(openapi_json);
|
|
512
|
+
const proxy = await getProxy(name);
|
|
513
|
+
if (!proxy) throw new IntegrationError(`API proxy ${name} does not exist`);
|
|
514
|
+
if (!proxy.runtime_url) throw new IntegrationError(`API proxy ${name} has no resolved public runtime URL`);
|
|
515
|
+
parseApiDocumentation(openapi_json, proxy.runtime_url);
|
|
516
|
+
const files = await requireBundle(name);
|
|
517
|
+
const { path } = patchBundleDocumentation(files, name, openapi_json);
|
|
518
|
+
const deployment = await importBundle(name, writeBundle(files));
|
|
519
|
+
const summary = summarizeApiDocumentation(openapi_json);
|
|
520
|
+
return {
|
|
521
|
+
updated: deployment.content_verified, ...deployment, documentation_path: path,
|
|
522
|
+
title: summary.title, version: summary.version, openapi_version: summary.openapi_version,
|
|
523
|
+
submitted_sha256: summary.sha256, submitted_bytes: summary.bytes,
|
|
524
|
+
path_count: summary.path_count, operation_count: summary.operation_count
|
|
525
|
+
};
|
|
480
526
|
});
|
|
481
527
|
|
|
482
528
|
// ---------------------------------------------------------------- products
|
|
483
529
|
|
|
484
530
|
register('apim_list_api_products', 'List API products',
|
|
485
531
|
'List API Management products (publish state, quota, scope).',
|
|
486
|
-
{ top: top() },
|
|
532
|
+
{ top: top(), cursor: cursorSchema },
|
|
487
533
|
READ_ONLY_ANNOTATIONS,
|
|
488
|
-
async
|
|
534
|
+
async args => collections.page({
|
|
535
|
+
...args, path: '/APIProducts', orderBy: 'name asc', identity: product => product.name,
|
|
536
|
+
query: { $select: PRODUCT_FIELDS.join(',') }, project: product => pick(product, PRODUCT_FIELDS)
|
|
537
|
+
}));
|
|
489
538
|
|
|
490
539
|
register('apim_get_api_product', 'Get API product',
|
|
491
540
|
'Read one API product with the names of its API proxies (api_proxies).',
|
|
@@ -512,9 +561,9 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
512
561
|
CREATE_ANNOTATIONS,
|
|
513
562
|
async args => {
|
|
514
563
|
requireConfirmation(args.confirmation, `CREATE API PRODUCT ${args.name}`);
|
|
515
|
-
if (await exists(
|
|
564
|
+
if (await exists('/APIProducts', args.name)) throw new IntegrationError(`API product ${args.name} already exists; use apim_update_api_product`);
|
|
516
565
|
for (const proxy of args.proxies) {
|
|
517
|
-
if (!(await exists(
|
|
566
|
+
if (!(await exists('/APIProxies', proxy))) throw new IntegrationError(`API proxy ${proxy} does not exist; nothing was created`);
|
|
518
567
|
}
|
|
519
568
|
const body = {
|
|
520
569
|
name: args.name,
|
|
@@ -523,7 +572,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
523
572
|
version: '1',
|
|
524
573
|
scope: args.scope ?? '',
|
|
525
574
|
isPublished: args.publish,
|
|
526
|
-
|
|
575
|
+
status_code: args.publish ? 'PUBLISHED' : 'DRAFT',
|
|
527
576
|
...quotaFields(args.quota),
|
|
528
577
|
apiProxies: args.proxies.map(proxy => ({ __metadata: { uri: `APIProxies(name=${odataKey(proxy)})` } }))
|
|
529
578
|
};
|
|
@@ -553,8 +602,50 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
553
602
|
if (args.scope !== undefined) body.scope = args.scope;
|
|
554
603
|
if (args.quota !== undefined) Object.assign(body, quotaFields(args.quota));
|
|
555
604
|
if (args.publish === true) Object.assign(body, { isPublished: true, status_code: 'PUBLISHED' });
|
|
556
|
-
if (args.publish === false) body
|
|
557
|
-
|
|
605
|
+
if (args.publish === false) Object.assign(body, { isPublished: false, status_code: 'DRAFT' });
|
|
606
|
+
const batch = `batch_${randomUUID()}`;
|
|
607
|
+
const changeset = `changeset_${randomUUID()}`;
|
|
608
|
+
const payload = [
|
|
609
|
+
`--${batch}`,
|
|
610
|
+
`Content-Type: multipart/mixed; boundary=${changeset}`,
|
|
611
|
+
'',
|
|
612
|
+
`--${changeset}`,
|
|
613
|
+
'Content-Type: application/http',
|
|
614
|
+
'Content-Transfer-Encoding: binary',
|
|
615
|
+
'Content-ID: 1',
|
|
616
|
+
'',
|
|
617
|
+
`PUT APIProducts(name=${odataKey(args.name)}) HTTP/1.1`,
|
|
618
|
+
'Content-Type: application/json',
|
|
619
|
+
'Accept: application/json',
|
|
620
|
+
'',
|
|
621
|
+
JSON.stringify(body),
|
|
622
|
+
`--${changeset}--`,
|
|
623
|
+
`--${batch}--`,
|
|
624
|
+
''
|
|
625
|
+
].join('\r\n');
|
|
626
|
+
const response = await apim('POST', '/$batch', {
|
|
627
|
+
body: payload,
|
|
628
|
+
contentType: `multipart/mixed; boundary=${batch}`,
|
|
629
|
+
accept: 'multipart/mixed',
|
|
630
|
+
responseType: 'text',
|
|
631
|
+
expect: [200, 202]
|
|
632
|
+
});
|
|
633
|
+
const statuses = [...String(response).matchAll(/(?:^|\r?\n)HTTP\/1\.[01] (\d{3})(?:[ \r\n])/g)].map(match => Number(match[1]));
|
|
634
|
+
if (statuses.length !== 1 || ![200, 204].includes(statuses[0])) {
|
|
635
|
+
throw new IntegrationError(statuses.length
|
|
636
|
+
? `API product update changeset failed (HTTP ${statuses.join(', ')})`
|
|
637
|
+
: 'API product update returned no embedded HTTP result; update is not confirmed');
|
|
638
|
+
}
|
|
639
|
+
const inner = String(response).slice(String(response).search(/HTTP\/1\.[01] /));
|
|
640
|
+
const headerEnd = /\r?\n\r?\n/.exec(inner);
|
|
641
|
+
const responseBody = headerEnd ? inner.slice(headerEnd.index + headerEnd[0].length).split(/\r?\n--/)[0].trim() : '';
|
|
642
|
+
if (responseBody) {
|
|
643
|
+
let decoded;
|
|
644
|
+
try { decoded = JSON.parse(responseBody); } catch {
|
|
645
|
+
throw new IntegrationError('API product update returned an unreadable embedded response; update is not confirmed');
|
|
646
|
+
}
|
|
647
|
+
if (decoded?.error) throw new IntegrationError('API product update changeset returned an embedded OData error');
|
|
648
|
+
}
|
|
558
649
|
return { updated: true, product: await getProduct(args.name) };
|
|
559
650
|
});
|
|
560
651
|
|
|
@@ -562,21 +653,23 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
562
653
|
|
|
563
654
|
register('apim_list_key_value_maps', 'List key value maps',
|
|
564
655
|
'List environment key value maps (name, encrypted, scope); values are not read.',
|
|
565
|
-
{ top: top() },
|
|
656
|
+
{ top: top(), cursor: cursorSchema },
|
|
566
657
|
READ_ONLY_ANNOTATIONS,
|
|
567
|
-
async
|
|
658
|
+
async args => collections.page({
|
|
659
|
+
...args, path: '/KeyMapEntries', orderBy: 'name asc', identity: map => map.name,
|
|
660
|
+
query: { $select: 'name,encrypted,scope' }, project: map => pick(map, ['name', 'encrypted', 'scope'])
|
|
661
|
+
}));
|
|
568
662
|
|
|
569
663
|
register('apim_get_key_value_map', 'Get key value map',
|
|
570
|
-
'Read one key value map with its entry names; values
|
|
664
|
+
'Read one key value map with its entry names; all values are write-only and returned as [redacted], regardless of encryption.',
|
|
571
665
|
{ name: apimName },
|
|
572
666
|
READ_ONLY_ANNOTATIONS,
|
|
573
667
|
async ({ name }) => {
|
|
574
668
|
const map = await getKeyValueMap(name);
|
|
575
669
|
if (!map) throw new IntegrationError(`Key value map ${name} does not exist`);
|
|
576
|
-
const encrypted = map.encrypted !== false && map.encrypted !== 'false';
|
|
577
670
|
return {
|
|
578
671
|
...pick(map, ['name', 'encrypted', 'scope']),
|
|
579
|
-
entries: list(map.keyMapEntryValues).map(entry => (
|
|
672
|
+
entries: list(map.keyMapEntryValues).map(entry => ({ name: entry.name, value: REDACTED }))
|
|
580
673
|
};
|
|
581
674
|
});
|
|
582
675
|
|
|
@@ -586,7 +679,7 @@ export function registerApimTools(register, client, { readBackDelayMs }) {
|
|
|
586
679
|
CREATE_ANNOTATIONS,
|
|
587
680
|
async args => {
|
|
588
681
|
requireConfirmation(args.confirmation, `CREATE KEY VALUE MAP ${args.name}`);
|
|
589
|
-
if (await
|
|
682
|
+
if (await getKeyValueMap(args.name)) throw new IntegrationError(`Key value map ${args.name} already exists; use apim_set_key_value_map_entries`);
|
|
590
683
|
try {
|
|
591
684
|
await apim('POST', '/KeyMapEntries', {
|
|
592
685
|
json: {
|
package/src/cf-integration.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Integration Suite credentials from the logged-in Cloud Foundry space: find the Process Integration
|
|
2
|
-
// Runtime (plan api)
|
|
3
|
-
// named service key
|
|
2
|
+
// Runtime (plan api), API Management (plan apiportal-apiaccess) and Developer Hub (devportal-apiaccess) instances;
|
|
3
|
+
// reuse or create a named service key, and optionally create a dedicated instance with the roles the tools need.
|
|
4
4
|
import { execFile as nodeExecFile } from 'node:child_process';
|
|
5
|
-
import { cfJson, runCf, serviceInstances, serviceKeyCredentials } from './cf-destination.mjs';
|
|
5
|
+
import { cfJson, getCloudFoundryTarget, runCf, serviceInstances, serviceKeyCredentials } from './cf-destination.mjs';
|
|
6
6
|
|
|
7
7
|
// Not the toolkit's destination-key prefix: the destination orphan sweep must not offer to delete these keys.
|
|
8
8
|
export const INTEGRATION_KEY_NAME = 'sap-ai-dev-integration';
|
|
@@ -27,14 +27,22 @@ export const INTEGRATION_CF_SERVICES = Object.freeze({
|
|
|
27
27
|
label: 'API Management, API portal',
|
|
28
28
|
dedicatedInstance: 'sap-ai-dev-integration-apim',
|
|
29
29
|
parameters: Object.freeze({ role: 'APIPortal.Administrator' })
|
|
30
|
+
}),
|
|
31
|
+
DEVHUB: Object.freeze({
|
|
32
|
+
// SAP-samples/btp-service-metadata v1 inventory identifies this Developer Hub CF service offering.
|
|
33
|
+
offering: 'apimanagement-devportal',
|
|
34
|
+
plan: 'devportal-apiaccess',
|
|
35
|
+
label: 'API Management, Developer Hub',
|
|
36
|
+
dedicatedInstance: 'sap-ai-dev-integration-devhub',
|
|
37
|
+
parameters: Object.freeze({ role: 'AuthGroup.API.Admin' })
|
|
30
38
|
})
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
/** Integration Suite instances in the space, by side: { CPI: [
|
|
41
|
+
/** Integration Suite instances in the space, by side: { CPI: [...], APIM: [...], DEVHUB: [...] }. */
|
|
34
42
|
export async function findIntegrationInstances({ env = process.env, spaceGuid, execFileImpl = nodeExecFile } = {}) {
|
|
35
43
|
const options = { env, execFileImpl };
|
|
36
44
|
const plans = new Map();
|
|
37
|
-
const found = { CPI: [], APIM: [] };
|
|
45
|
+
const found = { CPI: [], APIM: [], DEVHUB: [] };
|
|
38
46
|
for (const instance of await serviceInstances(spaceGuid, options)) {
|
|
39
47
|
const planGuid = instance?.relationships?.service_plan?.data?.guid;
|
|
40
48
|
if (!planGuid || !instance?.name) continue;
|
|
@@ -66,9 +74,40 @@ export async function integrationServiceKey(instanceName, { env = process.env, e
|
|
|
66
74
|
return { keyName: INTEGRATION_KEY_NAME, created: true, credentials: await serviceKeyCredentials(instanceName, INTEGRATION_KEY_NAME, 'Integration Suite', options) };
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
/** Create
|
|
77
|
+
/** Create a dedicated instance only from a plan visible in the targeted CF space. */
|
|
70
78
|
export async function createIntegrationInstance(side, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
71
79
|
const service = INTEGRATION_CF_SERVICES[side];
|
|
72
|
-
|
|
80
|
+
const options = { env, execFileImpl };
|
|
81
|
+
const target = await getCloudFoundryTarget(options);
|
|
82
|
+
if (!target.available) throw new Error(target.reason);
|
|
83
|
+
const query = `/v3/service_plans?names=${encodeURIComponent(service.plan)}&include=service_offering`;
|
|
84
|
+
const matchingPlans = payload => (payload.resources || []).filter(plan =>
|
|
85
|
+
payload.included?.service_offerings?.some(offering =>
|
|
86
|
+
offering.guid === plan.relationships?.service_offering?.data?.guid && offering.name === service.offering));
|
|
87
|
+
const scoped = await cfJson(`${query}&space_guids=${encodeURIComponent(target.spaceGuid)}&available=true`, options);
|
|
88
|
+
const plans = matchingPlans(scoped);
|
|
89
|
+
if (!plans.length) {
|
|
90
|
+
const catalog = await cfJson(query, options);
|
|
91
|
+
const scopes = [];
|
|
92
|
+
for (const plan of matchingPlans(catalog)) {
|
|
93
|
+
try {
|
|
94
|
+
const visibility = await cfJson(`/v3/service_plans/${encodeURIComponent(plan.guid)}/visibility`, options);
|
|
95
|
+
scopes.push(...(visibility.organizations || []).map(org => org.name || org.guid));
|
|
96
|
+
} catch {
|
|
97
|
+
scopes.push('visibility details unavailable');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const detail = scopes.length ? ` Catalog organization visibility: ${[...new Set(scopes)].join(', ')}.` : ' No matching plan is visible in the catalog.';
|
|
101
|
+
throw new Error(`${service.offering}/${service.plan} is not available in ${target.orgName} / ${target.spaceName}.${detail} Assign the entitlement to the intended subaccount. No instance was created.`);
|
|
102
|
+
}
|
|
103
|
+
if (plans.length !== 1) throw new Error(`Multiple ${service.offering}/${service.plan} plans are visible in ${target.orgName} / ${target.spaceName}; broker selection is ambiguous. No instance was created.`);
|
|
104
|
+
const offering = scoped.included.service_offerings.find(item => item.guid === plans[0].relationships.service_offering.data.guid);
|
|
105
|
+
const brokerGuid = offering.relationships?.service_broker?.data?.guid;
|
|
106
|
+
if (!brokerGuid) throw new Error('The visible service offering has no broker reference. No instance was created.');
|
|
107
|
+
// Broker names are visible through offering fields even when broker-admin details are hidden.
|
|
108
|
+
const projected = await cfJson(`/v3/service_offerings/${encodeURIComponent(offering.guid)}?fields[service_broker]=guid,name`, options);
|
|
109
|
+
const broker = projected.included?.service_brokers?.find(item => item.guid === brokerGuid);
|
|
110
|
+
if (!broker?.name) throw new Error('The visible service broker has no accessible name. No instance was created.');
|
|
111
|
+
await runCf(['create-service', service.offering, service.plan, service.dedicatedInstance, '-b', broker.name, '-c', JSON.stringify(service.parameters), '--wait'], options);
|
|
73
112
|
return { name: service.dedicatedInstance };
|
|
74
113
|
}
|