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:
+17
@@ -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(
|
||||||
|
|||||||
+15
-1
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user