feat(apix-demo): working Node.js discovery demo + sandbox cleanup

- apix-demo.mjs: all 6 steps run end-to-end — HATEOAS root, sandbox
  creation, BSM registration (3 carriers), capability search, parallel
  quote calls, best-price selection; all field names aligned to BsmPayload
- SandboxService: add deleteSandbox() — removes services then sandbox,
  DEMO tier protected
- AdminResource: add DELETE /admin/sandboxes/{uuid} backed by deleteSandbox
- cleanup-sandboxes.mjs: admin script to purge test sandboxes while
  preserving DEMO-tier and named exceptions (apix-demo-ecosystem)

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-17 18:04:06 +02:00
parent b85c24d9bf
commit 02420fcbc4
4 changed files with 154 additions and 29 deletions
@@ -88,6 +88,23 @@ public class AdminResource {
"expiresAt", sb.expiresAt.toString())).build(); "expiresAt", sb.expiresAt.toString())).build();
} }
@DELETE
@Path("/sandboxes/{uuid}")
@Operation(summary = "Delete a sandbox and all its services (admin)", description = "Requires X-Admin-Key. DEMO-tier sandboxes are protected.")
public Response deleteSandbox(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuidStr) {
requireAdmin(adminKey);
UUID id;
try { id = UUID.fromString(uuidStr); }
catch (IllegalArgumentException e) {
throw new WebApplicationException(
Response.status(404).entity(Map.of("message", "Sandbox not found")).build());
}
sandboxService.deleteSandbox(id);
return Response.noContent().build();
}
private void requireAdmin(String key) { private void requireAdmin(String key) {
if (adminApiKey.isBlank() || !adminApiKey.equals(key)) { if (adminApiKey.isBlank() || !adminApiKey.equals(key)) {
throw new WebApplicationException( throw new WebApplicationException(
@@ -237,7 +237,7 @@ public class SandboxService {
List<String> properties) { List<String> properties) {
ServiceStage targetStage = stage != null ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase()) ? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.DEVELOPMENT; : ServiceStage.PRODUCTION;
List<String[]> props = RegistryService.parsePropertyFilters(properties); List<String[]> props = RegistryService.parsePropertyFilters(properties);
StringBuilder sql = new StringBuilder( StringBuilder sql = new StringBuilder(
@@ -659,5 +659,19 @@ public class SandboxService {
} }
} }
/** Deletes a sandbox and all its registered services. DEMO tier sandboxes are protected. */
@Transactional
public void deleteSandbox(UUID id) {
SandboxEntity sb = requireById(id);
if ("DEMO".equals(sb.tier)) {
throw new WebApplicationException(
Response.status(403).entity(Map.of("message", "DEMO-tier sandboxes are permanent and cannot be deleted")).build());
}
em.createQuery("DELETE FROM ServiceEntity s WHERE s.sandboxId = :sid")
.setParameter("sid", id.toString())
.executeUpdate();
em.remove(em.contains(sb) ? sb : em.merge(sb));
}
public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {} public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {}
} }
+30 -28
View File
@@ -76,63 +76,65 @@ async function main() {
const carriers = [ const carriers = [
{ {
serviceName: 'NordLogistik Economy EU', name: 'NordLogistik Economy EU',
description: 'Economy EU road & rail freight. 5-day delivery. DE/AT/CH specialist.', description: 'Economy EU road & rail freight. 5-day delivery. DE/AT/CH specialist.',
endpoint: 'https://demo.api-index.org/carriers/nord/quote', endpoint: 'https://demo.api-index.org/carriers/nord/quote',
capabilities: ['shipping-quote', 'logistics', 'eu-delivery'], capabilities: ['shipping-quote', 'logistics', 'eu-delivery'],
registrantEmail: 'api@nordlogistik.example', registrantEmail: 'api@nordlogistik.example',
registrantName: 'NordLogistik GmbH', registrantName: 'NordLogistik GmbH',
registrantJurisdiction:'DE', registrantJurisdiction:'DE',
orgType: 'COMMERCIAL', registrantOrgType: 'COMMERCIAL',
pricing: { model: 'PER_CALL', amount: 0.012, currency: 'APX', unit: 'per-call' }, pricing: { billingModel: 'PER_CALL', pricePerCall: 0.012, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1', bsmVersion: '0.1',
stage: 'PRODUCTION', serviceStage: 'PRODUCTION',
}, },
{ {
serviceName: 'SwiftCargo Express', name: 'SwiftCargo Express',
description: 'Global express shipping. 2-day delivery to major hubs worldwide.', description: 'Global express shipping. 2-day delivery to major hubs worldwide.',
endpoint: 'https://demo.api-index.org/carriers/swift/quote', endpoint: 'https://demo.api-index.org/carriers/swift/quote',
capabilities: ['shipping-quote', 'express-delivery', 'global-logistics'], capabilities: ['shipping-quote', 'express-delivery', 'global-logistics'],
registrantEmail: 'api@swiftcargo.example', registrantEmail: 'api@swiftcargo.example',
registrantName: 'SwiftCargo Ltd', registrantName: 'SwiftCargo Ltd',
registrantJurisdiction:'GB', registrantJurisdiction:'GB',
orgType: 'COMMERCIAL', registrantOrgType: 'COMMERCIAL',
pricing: { model: 'PER_CALL', amount: 0.035, currency: 'APX', unit: 'per-call' }, pricing: { billingModel: 'PER_CALL', pricePerCall: 0.035, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1', bsmVersion: '0.1',
stage: 'PRODUCTION', serviceStage: 'PRODUCTION',
}, },
{ {
serviceName: 'PacRim Express', name: 'PacRim Express',
description: 'Cost-optimised Asia-Pacific shipping. SEA, JP, KR, AU routes.', description: 'Cost-optimised Asia-Pacific shipping. SEA, JP, KR, AU routes.',
endpoint: 'https://demo.api-index.org/carriers/pacrim/quote', endpoint: 'https://demo.api-index.org/carriers/pacrim/quote',
capabilities: ['shipping-quote', 'logistics', 'asia-pacific-delivery'], capabilities: ['shipping-quote', 'logistics', 'asia-pacific-delivery'],
registrantEmail: 'api@pacrim.example', registrantEmail: 'api@pacrim.example',
registrantName: 'PacRim Express Pte', registrantName: 'PacRim Express Pte',
registrantJurisdiction:'SG', registrantJurisdiction:'SG',
orgType: 'COMMERCIAL', registrantOrgType: 'COMMERCIAL',
pricing: { model: 'PER_CALL', amount: 0.018, currency: 'APX', unit: 'per-call' }, pricing: { billingModel: 'PER_CALL', pricePerCall: 0.018, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1', bsmVersion: '0.1',
stage: 'PRODUCTION', serviceStage: 'PRODUCTION',
}, },
]; ];
for (const manifest of carriers) { for (const manifest of carriers) {
await post(servicesHref, manifest, { 'X-Api-Key': apiKey }); await post(servicesHref, manifest, { 'X-Api-Key': apiKey });
console.log(` ✓ Registered: ${manifest.serviceName} (${manifest.registrantJurisdiction})`); console.log(` ✓ Registered: ${manifest.name} (${manifest.registrantJurisdiction})`);
} }
console.log('\n → Any provider self-registers. The agent discovers them automatically.'); console.log('\n → Any provider self-registers. The agent discovers them automatically.');
// ── Step 4: Agent discovers carriers ────────────────────────────────────── // ── Step 4: Agent discovers carriers ──────────────────────────────────────
hdr('Step 4 Agent queries: "who can provide shipping-quote?"'); hdr('Step 4 Agent queries: "who can provide shipping-quote?"');
const searchUrl = searchHrefTpl.replace('{?capability,stage}', '') // Strip the URI template expression {?...} and build the URL properly
+ '?capability=shipping-quote'; const searchBase = searchHrefTpl.replace(/\{[^}]+\}/, '');
const searchUrl = `${searchBase}?capability=shipping-quote&stage=PRODUCTION`;
console.log(` Query: GET ${searchUrl}\n`); console.log(` Query: GET ${searchUrl}\n`);
const found = await get(searchUrl); const found = await get(searchUrl);
console.log(` Found ${found.length} carrier service(s):\n`); console.log(` Found ${found.length} carrier service(s):\n`);
for (const svc of found) { for (const svc of found) {
const price = svc.pricing ? `${svc.pricing.amount} ${svc.pricing.currency}/${svc.pricing.unit}` : 'unknown'; const p = svc.pricing;
console.log(` ┌─ ${svc.serviceName}`); const price = p ? `${p.pricePerCall} ${p.currency}/${p.billingUnit}` : 'unknown';
console.log(` ┌─ ${svc.name}`);
console.log(` │ Jurisdiction : ${svc.registrantJurisdiction}`); console.log(` │ Jurisdiction : ${svc.registrantJurisdiction}`);
console.log(` │ Capabilities : ${svc.capabilities?.join(', ')}`); console.log(` │ Capabilities : ${svc.capabilities?.join(', ')}`);
console.log(` │ Price : ${price}`); console.log(` │ Price : ${price}`);
@@ -151,23 +153,23 @@ async function main() {
const quoteRequests = found.map(async svc => { const quoteRequests = found.map(async svc => {
const start = Date.now(); const start = Date.now();
try { try {
const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc)); const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc.name));
return { name: svc.serviceName, quote, ms: Date.now() - start }; return { svcName: svc.name, quote, ms: Date.now() - start };
} catch { } catch {
return { name: svc.serviceName, quote: mockQuote(svc), ms: Date.now() - start }; return { svcName: svc.name, quote: mockQuote(svc.name), ms: Date.now() - start };
} }
}); });
const results = await Promise.all(quoteRequests); const results = await Promise.all(quoteRequests);
results.sort((a, b) => a.quote.price.amount - b.quote.price.amount); results.sort((a, b) => a.quote.price.amount - b.quote.price.amount);
for (const { name, quote, ms } of results) { for (const { svcName, quote } of results) {
const best = results[0].name === name ? ' ← best price' : ''; const best = results[0].svcName === svcName ? ' ← best price' : '';
console.log(` ${name.padEnd(28)} ${quote.price.amount.toFixed(2)} ${quote.price.currency} · ${quote.estimatedDays}d${best}`); console.log(` ${svcName.padEnd(28)} ${quote.price.amount.toFixed(2)} ${quote.price.currency} · ${quote.estimatedDays}d${best}`);
} }
const winner = results[0]; const winner = results[0];
console.log(`\n Selected: ${winner.name} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`); console.log(`\n Selected: ${winner.svcName} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`);
// ── Summary ──────────────────────────────────────────────────────────────── // ── Summary ────────────────────────────────────────────────────────────────
hdr('Summary'); hdr('Summary');
@@ -189,7 +191,7 @@ async function main() {
} }
// Fallback mock quotes (in case demo endpoints are not reachable) // Fallback mock quotes (in case demo endpoints are not reachable)
function mockQuote(svc) { function mockQuote(name) {
const prices = { const prices = {
'NordLogistik Economy EU': { amount: 12.40, currency: 'APX' }, 'NordLogistik Economy EU': { amount: 12.40, currency: 'APX' },
'SwiftCargo Express': { amount: 35.00, currency: 'APX' }, 'SwiftCargo Express': { amount: 35.00, currency: 'APX' },
@@ -197,9 +199,9 @@ function mockQuote(svc) {
}; };
const days = { 'NordLogistik Economy EU': 5, 'SwiftCargo Express': 2, 'PacRim Express': 7 }; const days = { 'NordLogistik Economy EU': 5, 'SwiftCargo Express': 2, 'PacRim Express': 7 };
return { return {
carrier: svc.serviceName, carrier: name,
price: prices[svc.serviceName] ?? { amount: 20.00, currency: 'APX' }, price: prices[name] ?? { amount: 20.00, currency: 'APX' },
estimatedDays: days[svc.serviceName] ?? 5, estimatedDays: days[name] ?? 5,
trackingAvailable: true, trackingAvailable: true,
}; };
} }
+92
View File
@@ -0,0 +1,92 @@
/**
* Sandbox cleanup — deletes all non-protected sandboxes from the APIX registry.
*
* Protected sandboxes (never deleted):
* - tier = DEMO (server-enforced, returns 403)
* - name matching KEEP_NAMES below
*
* Usage:
* APIX_ADMIN_KEY=<key> node scripts/cleanup-sandboxes.mjs [--dry-run]
*
* The admin key is in /opt/apix/.env on the VPS (APIX_API_KEY=...).
*/
const REGISTRY_ROOT = 'https://api-index.org';
const KEEP_NAMES = new Set(['apix-demo-ecosystem']);
const DRY_RUN = process.argv.includes('--dry-run');
const ADMIN_KEY = process.env.APIX_ADMIN_KEY;
if (!ADMIN_KEY) {
console.error('Error: set APIX_ADMIN_KEY environment variable');
process.exit(1);
}
async function adminGet(path) {
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
headers: { Accept: 'application/json', 'X-Admin-Key': ADMIN_KEY },
});
if (!res.ok) throw new Error(`GET ${path}${res.status}: ${await res.text()}`);
return res.json();
}
async function adminDelete(path) {
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
method: 'DELETE',
headers: { 'X-Admin-Key': ADMIN_KEY },
});
if (res.status === 204) return;
throw new Error(`DELETE ${path}${res.status}: ${await res.text()}`);
}
async function main() {
console.log(`\n APIX Sandbox Cleanup${DRY_RUN ? ' [DRY RUN — nothing will be deleted]' : ''}\n`);
let page = 0;
const toDelete = [];
const keep = [];
while (true) {
const data = await adminGet(`/admin/sandboxes?page=${page}&size=50`);
const sandboxes = data.sandboxes ?? data.items ?? data;
if (!Array.isArray(sandboxes) || sandboxes.length === 0) break;
for (const sb of sandboxes) {
const name = sb.name ?? sb.sandboxName ?? '—';
const tier = sb.tier ?? '?';
const id = sb.sandboxId ?? sb.id;
if (KEEP_NAMES.has(name) || tier === 'DEMO') {
keep.push({ id, name, tier });
} else {
toDelete.push({ id, name, tier });
}
}
if (sandboxes.length < 50) break;
page++;
}
console.log(` Keeping (${keep.length}):`);
for (const sb of keep) console.log(`${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`);
console.log(`\n Deleting (${toDelete.length}):`);
for (const sb of toDelete) {
const label = `${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`;
if (DRY_RUN) {
console.log(`${label} [skipped — dry run]`);
} else {
try {
await adminDelete(`/admin/sandboxes/${sb.id}`);
console.log(`${label} deleted`);
} catch (err) {
console.log(` ! ${label} ERROR: ${err.message}`);
}
}
}
console.log('\n Done.\n');
}
main().catch(err => {
console.error('\n Error:', err.message);
process.exit(1);
});