chore: add missing source modules to version control
Deploy to Production / deploy (push) Failing after 7s

apix-demo, apix-portal/src, apix-spider/src, apix-registry/src,
apix-common/src were never staged. Without them the CI build has no
source to compile and the Docker images cannot be produced.

Also adds docs/ (infrastructure notes) missed in prior commits.

Co-Authored-By: Mira <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 15:49:03 +02:00
parent a9b3354bde
commit 46f32c2df2
87 changed files with 6657 additions and 34 deletions
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.botstandards</groupId>
<artifactId>apix-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>apix-demo</artifactId>
<name>APIX :: Demo</name>
<description>Sandbox-scoped mock service layer. Each sandbox configures realistic endpoints
with declared latency, rate limits, and APX pricing. Powers the APIX demo ecosystem
and third-party training environments.</description>
<dependencies>
<dependency>
<groupId>org.botstandards</groupId>
<artifactId>apix-common</artifactId>
</dependency>
<!-- REST server -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- Persistence — owns demo_config + mock_service_configs tables -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId>
</dependency>
<!-- Rate-limit bucket reset + cache invalidation -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<!-- Calls registry API to seed sandbox and register services -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,34 @@
package org.botstandards.apix.demo.client;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.demo.client.dto.RegistrationRequest;
import org.botstandards.apix.demo.client.dto.SandboxCreated;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import java.util.Map;
@RegisterRestClient(configKey = "registry")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface RegistryClient {
@POST
@Path("/sandbox/register")
SandboxCreated register(RegistrationRequest request);
@POST
@Path("/sandbox/{uuid}/services")
Map<String, Object> registerService(
@PathParam("uuid") String uuid,
@HeaderParam("X-Api-Key") String apiKey,
BsmPayload payload);
@PATCH
@Path("/sandbox/admin/{uuid}/tier")
Map<String, Object> promoteTier(
@PathParam("uuid") String uuid,
@HeaderParam("X-Admin-Key") String adminKey,
Map<String, String> body);
}
@@ -0,0 +1,4 @@
package org.botstandards.apix.demo.client.dto;
/** Minimal registration payload sent to POST /sandbox/register. */
public record RegistrationRequest(String name, String contactEmail, String location) {}
@@ -0,0 +1,13 @@
package org.botstandards.apix.demo.client.dto;
import java.time.Instant;
/** Response from POST /sandbox/register — only the fields the seed service needs. */
public record SandboxCreated(
String sandboxId,
String name,
String apiKey,
String maintenanceKey,
String tier,
Instant expiresAt
) {}
@@ -0,0 +1,17 @@
package org.botstandards.apix.demo.entity;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "demo_config")
public class DemoConfigEntry {
@Id
public String key;
public String value;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
}
@@ -0,0 +1,48 @@
package org.botstandards.apix.demo.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "mock_service_configs")
public class MockServiceConfig {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "sandbox_id", nullable = false)
public String sandboxId;
/** Leading-slash path, e.g. /v1/address/validate */
@Column(nullable = false)
public String path;
@Column(nullable = false)
public String method;
@Column(name = "latency_ms", nullable = false)
public int latencyMs;
@Column(name = "jitter_pct", nullable = false)
public int jitterPct;
@Column(name = "rate_per_minute", nullable = false)
public int ratePerMinute;
@Column(name = "status_code", nullable = false)
public int statusCode;
/** Static JSON response body returned verbatim on every call. */
@Column(name = "response_body", nullable = false)
public String responseBody;
/** APX cost surfaced in X-APX-Cost response header. */
@Column(name = "price_apx", nullable = false)
public BigDecimal priceApx;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
}
@@ -0,0 +1,52 @@
package org.botstandards.apix.demo.resource;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.demo.service.MockDispatcherService;
/**
* Catches all requests to /{sandboxId}/{path} and routes them through the
* sandbox-scoped mock dispatcher. Any sandbox owner can register custom
* mock endpoints; the APIX demo sandbox is pre-seeded on first boot.
*/
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class MockDispatcherResource {
@Inject
MockDispatcherService dispatcher;
@GET
@Path("/{sandboxId}/{path: .+}")
public Response handleGet(@PathParam("sandboxId") String sandboxId,
@PathParam("path") String path) {
return dispatcher.dispatch(sandboxId, path, "GET");
}
@POST
@Path("/{sandboxId}/{path: .+}")
public Response handlePost(@PathParam("sandboxId") String sandboxId,
@PathParam("path") String path,
String body) {
return dispatcher.dispatch(sandboxId, path, "POST");
}
@PUT
@Path("/{sandboxId}/{path: .+}")
public Response handlePut(@PathParam("sandboxId") String sandboxId,
@PathParam("path") String path,
String body) {
return dispatcher.dispatch(sandboxId, path, "PUT");
}
@PATCH
@Path("/{sandboxId}/{path: .+}")
public Response handlePatch(@PathParam("sandboxId") String sandboxId,
@PathParam("path") String path,
String body) {
return dispatcher.dispatch(sandboxId, path, "PATCH");
}
}
@@ -0,0 +1,327 @@
package org.botstandards.apix.demo.service;
import io.quarkus.logging.Log;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OrgType;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.demo.client.RegistryClient;
import org.botstandards.apix.demo.client.dto.RegistrationRequest;
import org.botstandards.apix.demo.client.dto.SandboxCreated;
import org.botstandards.apix.demo.entity.DemoConfigEntry;
import org.botstandards.apix.demo.entity.MockServiceConfig;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Seeds the APIX demo ecosystem on first boot.
*
* Flow:
* 1. Create demo sandbox via registry API (FREE tier).
* 2. Promote to DEMO tier via registry admin endpoint (never expires).
* 3. Register all 17 services in the sandbox via registry API.
* 4. Insert corresponding mock_service_configs (ON CONFLICT DO NOTHING).
* 5. Mark seeded in demo_config.
*
* Subsequent boots are no-ops. Step 3 tolerates partial failures — each
* service is attempted independently, and mock configs use ON CONFLICT DO NOTHING.
*/
@ApplicationScoped
public class DemoSeedService {
@Inject
EntityManager em;
@RestClient
@Inject
RegistryClient registryClient;
@ConfigProperty(name = "apix.demo.base-url")
String demoBaseUrl;
@ConfigProperty(name = "apix.registry.admin-key")
String adminKey;
void onStart(@Observes StartupEvent ev) {
try {
seed();
} catch (Exception e) {
Log.errorf(e, "Demo seed failed — will retry on next startup");
}
}
@Transactional
public void seed() {
String uuid = loadConfig("demo.sandbox.uuid");
String apiKey;
if (uuid == null) {
Log.info("Creating APIX demo sandbox...");
SandboxCreated result = registryClient.register(new RegistrationRequest(
"apix-demo-ecosystem",
"demo@api-index.org",
"Global Demo Ecosystem"));
uuid = result.sandboxId();
apiKey = result.apiKey();
saveConfig("demo.sandbox.uuid", uuid);
saveConfig("demo.sandbox.api-key", apiKey);
saveConfig("demo.sandbox.maintenance-key", result.maintenanceKey());
registryClient.promoteTier(uuid, adminKey, Map.of("tier", "DEMO"));
Log.infof("Demo sandbox created: %s", uuid);
} else {
apiKey = loadConfig("demo.sandbox.api-key");
}
if ("true".equals(loadConfig("demo.seeded"))) {
Log.infof("Demo ecosystem already seeded at sandbox %s", uuid);
return;
}
String base = demoBaseUrl + "/" + uuid;
int servicesRegistered = 0;
int configsInserted = 0;
for (EndpointSpec spec : ENDPOINTS) {
try {
registryClient.registerService(uuid, apiKey, buildBsm(spec, base));
servicesRegistered++;
} catch (Exception e) {
Log.warnf("Service registration skipped (%s %s): %s", spec.method(), spec.path(), e.getMessage());
}
int rows = em.createNativeQuery("""
INSERT INTO mock_service_configs
(id, sandbox_id, path, method, latency_ms, jitter_pct,
rate_per_minute, status_code, response_body, price_apx, created_at)
VALUES (gen_random_uuid(), :sid, :path, :method, :latency, :jitter,
:rate, 200, :response, :price, now())
ON CONFLICT (sandbox_id, path, method) DO NOTHING
""")
.setParameter("sid", uuid)
.setParameter("path", spec.path())
.setParameter("method", spec.method())
.setParameter("latency", spec.latencyMs())
.setParameter("jitter", spec.jitterPct())
.setParameter("rate", spec.ratePm())
.setParameter("response", spec.responseBody())
.setParameter("price", spec.priceApx())
.executeUpdate();
configsInserted += rows;
}
saveConfig("demo.seeded", "true");
Log.infof("Demo ecosystem seeded: %d services registered, %d mock configs inserted at %s/%s",
servicesRegistered, configsInserted, demoBaseUrl, uuid);
}
// ── Config store ──────────────────────────────────────────────────────────
private String loadConfig(String key) {
DemoConfigEntry e = em.find(DemoConfigEntry.class, key);
return e == null ? null : e.value;
}
private void saveConfig(String key, String value) {
DemoConfigEntry e = em.find(DemoConfigEntry.class, key);
if (e == null) {
e = new DemoConfigEntry();
e.key = key;
}
e.value = value;
e.updatedAt = Instant.now();
em.merge(e);
}
// ── BSM builder ───────────────────────────────────────────────────────────
private static BsmPayload buildBsm(EndpointSpec spec, String base) {
return new BsmPayload(
spec.serviceName(),
spec.description(),
base + spec.path(),
spec.capabilities(),
spec.regEmail(),
spec.regName(),
spec.regJurisdiction(),
OrgType.COMMERCIAL,
null, null, null, null, null,
new BsmPayload.Pricing("PER_CALL", spec.priceApx(), "APX", "per-call"),
"0.1",
ServiceStage.PRODUCTION,
null, null, null, null,
Map.of(
"declaredLatencyMs", spec.latencyMs(),
"ratePerMinute", spec.ratePm(),
"workflow", spec.workflow(),
"mockEndpoint", true));
}
// ── Endpoint specs ────────────────────────────────────────────────────────
private record EndpointSpec(
String method, String path,
int latencyMs, int jitterPct, int ratePm,
BigDecimal priceApx,
String responseBody,
String serviceName, String description,
List<String> capabilities,
String regEmail, String regName, String regJurisdiction,
String workflow) {}
private static final List<EndpointSpec> ENDPOINTS = List.of(
// ── W1: Cross-border Fulfilment ───────────────────────────────────────
new EndpointSpec("POST", "/v1/address/validate",
80, 10, 600, new BigDecimal("0.0010"),
"""
{"valid":true,"standardized":{"street":"Maximilianstra\\u00dfe 1","city":"M\\u00fcnchen","postalCode":"80539","countryCode":"DE","coordinates":{"lat":48.1391,"lon":11.5802}},"confidence":0.97,"normalizedFormat":"DIN_5008"}""",
"Address Validation", "Validates and standardises postal addresses to ISO/DIN format. Returns geocoordinates and confidence score.",
List.of("address-validation", "geocoding"),
"demo-databridge@api-index.org", "DataBridge Inc", "US", "W1"),
new EndpointSpec("GET", "/v1/customs/tariff",
300, 10, 120, new BigDecimal("0.0080"),
"""
{"hsCode":"8471.30","description":"Portable automatic data-processing machines","dutyRate":0.0,"vatRate":0.19,"specialMeasures":[],"regulation":"EU Tariff 2024/1234","currency":"EUR"}""",
"Customs Tariff Lookup", "Returns HS tariff codes, duty rates, and VAT rates for cross-border goods classification.",
List.of("customs-lookup", "trade-compliance", "hs-classification"),
"demo-eutariff@api-index.org", "EuTariff BV", "NL", "W1"),
new EndpointSpec("POST", "/v1/carrier/nord/quote",
150, 10, 300, new BigDecimal("0.0120"),
"""
{"carrier":"NordLogistik GmbH","service":"Economy EU","estimatedDays":5,"price":{"amount":12.40,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"16:00 CET","quoteId":"NL-Q-DEMO-0017"}""",
"NordLogistik Shipping Quote", "Economy EU shipping quotes from NordLogistik. Specialised in intra-European road and rail freight.",
List.of("shipping-quote", "logistics", "eu-delivery"),
"demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"),
new EndpointSpec("POST", "/v1/carrier/swift/quote",
120, 10, 300, new BigDecimal("0.0350"),
"""
{"carrier":"SwiftCargo Ltd","service":"Express Global","estimatedDays":2,"price":{"amount":35.00,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"14:00 GMT","quoteId":"SC-Q-DEMO-0043"}""",
"SwiftCargo Express Quote", "Global express shipping quotes. 2-day delivery to major hubs worldwide.",
List.of("shipping-quote", "express-delivery", "global-logistics"),
"demo-swiftcargo@api-index.org", "SwiftCargo Ltd", "GB", "W1"),
new EndpointSpec("POST", "/v1/carrier/pacrim/quote",
200, 10, 200, new BigDecimal("0.0180"),
"""
{"carrier":"PacRim Express Pte","service":"Asia-Pacific Economy","estimatedDays":7,"price":{"amount":18.20,"currency":"APX"},"trackingAvailable":true,"cutoffTime":"18:00 SGT","quoteId":"PR-Q-DEMO-0009"}""",
"PacRim Express Quote", "Economy Asia-Pacific shipping quotes. Cost-optimised routes covering SEA, JP, KR, AU.",
List.of("shipping-quote", "logistics", "asia-pacific-delivery"),
"demo-pacrim@api-index.org", "PacRim Express Pte", "SG", "W1"),
new EndpointSpec("POST", "/v1/shipment/label",
250, 10, 200, new BigDecimal("0.0150"),
"""
{"labelId":"SHP-DEMO-7734","trackingNumber":"NL1234567890DE","carrier":"NordLogistik GmbH","labelFormat":"PDF","estimatedPickup":"2026-05-15T16:00:00Z"}""",
"Shipment Label Generation", "Generates carrier-compliant shipping labels and assigns a tracking number.",
List.of("shipment-label", "logistics", "label-generation"),
"demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"),
new EndpointSpec("GET", "/v1/shipment/track",
100, 10, 500, new BigDecimal("0.0030"),
"""
{"trackingNumber":"NL1234567890DE","status":"IN_TRANSIT","currentLocation":"Frankfurt Hub, DE","estimatedDelivery":"2026-05-19T18:00:00Z","events":[{"timestamp":"2026-05-14T14:23:00Z","location":"Munich Depot","description":"Parcel collected"},{"timestamp":"2026-05-14T22:15:00Z","location":"Frankfurt Hub","description":"In transit to destination hub"}]}""",
"Shipment Tracking", "Real-time shipment status and event history by tracking number.",
List.of("shipment-tracking", "logistics"),
"demo-nordlogistik@api-index.org", "NordLogistik GmbH", "DE", "W1"),
// ── W2: Micro-lending Decision ────────────────────────────────────────
new EndpointSpec("POST", "/v1/identity/enrich",
350, 10, 200, new BigDecimal("0.0200"),
"""
{"enrichedName":"Johann K. M\\u00fcller","dateOfBirth":"1984-03-12","currentCity":"Berlin","country":"DE","verificationScore":0.91,"enrichmentId":"IE-DEMO-0552"}""",
"Identity Enrichment", "Enriches a person's identity with verified structured data. Input: name + date of birth. Output: normalised profile with verification score.",
List.of("identity-enrichment", "kyc", "data-enrichment"),
"demo-databridge@api-index.org", "DataBridge Inc", "US", "W2"),
new EndpointSpec("POST", "/v1/credit/signal",
450, 10, 60, new BigDecimal("0.0250"),
"""
{"enrichmentId":"IE-DEMO-0552","creditScore":720,"riskBand":"LOW","factors":[{"key":"payment_history","score":0.95,"weight":0.35},{"key":"credit_utilization","score":0.72,"weight":0.30},{"key":"account_age","score":0.88,"weight":0.15}],"reportedAt":"2026-05-14T09:00:00Z"}""",
"Credit Signal Aggregation", "Aggregates bureau signals into a single credit score and risk band. Requires prior identity enrichment ID.",
List.of("credit-scoring", "risk-assessment", "financial-analytics"),
"demo-trustscore@api-index.org", "TrustScore AG", "CH", "W2"),
new EndpointSpec("POST", "/v1/fraud/score",
380, 10, 90, new BigDecimal("0.0400"),
"""
{"requestId":"FS-DEMO-1103","fraudScore":12,"riskLevel":"MINIMAL","flags":[],"velocityCheck":"PASS","deviceFingerprint":"CONSISTENT","recommendedAction":"PROCEED"}""",
"Fraud Scoring", "Scores a transaction or application for fraud risk. Returns 0100 score, risk level, and triggered flags.",
List.of("fraud-detection", "risk-scoring", "transaction-security"),
"demo-trustscore@api-index.org", "TrustScore AG", "CH", "W2"),
new EndpointSpec("POST", "/v1/lending/offer",
200, 10, 120, new BigDecimal("0.0300"),
"""
{"offerId":"LO-DEMO-2287","approvedAmount":15000.00,"currency":"APX","interestRateAnnual":0.049,"termMonths":36,"monthlyPayment":448.32,"totalRepayable":16139.52,"validUntil":"2026-05-21T23:59:59Z"}""",
"Lending Offer Engine", "Generates a personalised loan offer based on credit score and requested amount.",
List.of("loan-origination", "lending", "credit-decision"),
"demo-lendfast@api-index.org", "LendFast GmbH", "DE", "W2"),
new EndpointSpec("POST", "/v1/contract/acknowledge",
150, 10, 300, new BigDecimal("0.0050"),
"""
{"contractId":"CTR-DEMO-0091","offerId":"LO-DEMO-2287","status":"PENDING_SIGNATURE","expiresAt":"2026-05-21T23:59:59Z","signingProvider":"DemoSign"}""",
"Contract Acknowledgement", "Initiates a digital contract signing workflow for an accepted lending offer.",
List.of("contract-management", "e-signature", "document-workflow"),
"demo-lendfast@api-index.org", "LendFast GmbH", "DE", "W2"),
// ── W3: Healthcare Referral ───────────────────────────────────────────
new EndpointSpec("POST", "/v1/symptom/triage",
500, 10, 120, new BigDecimal("0.0150"),
"""
{"triageId":"TR-DEMO-0834","urgency":"ROUTINE","urgencyScore":3,"specialtyRequired":"CARDIOLOGY","recommendedTimeframe":"within_2_weeks","disclaimer":"Non-clinical triage assessment. Consult a physician for medical advice."}""",
"Symptom Triage", "Non-clinical AI triage: maps reported symptoms to urgency level and required medical specialty.",
List.of("medical-triage", "symptom-assessment", "healthcare"),
"demo-mednet@api-index.org", "MedNet Systems", "NL", "W3"),
new EndpointSpec("GET", "/v1/specialist/availability",
250, 10, 180, new BigDecimal("0.0100"),
"""
{"specialty":"CARDIOLOGY","slots":[{"slotId":"SLT-20260520-0900","practitioner":"Dr. Helena Brandt","datetime":"2026-05-20T09:00:00Z","location":"Charit\\u00e9 Outpatient, Berlin","type":"IN_PERSON"},{"slotId":"SLT-20260520-1400","practitioner":"Dr. Markus Frei","datetime":"2026-05-20T14:00:00Z","type":"VIDEO"}],"nextAvailable":"2026-05-20T09:00:00Z"}""",
"Specialist Availability", "Returns available appointment slots for a given medical specialty and location.",
List.of("appointment-booking", "specialist-scheduling", "healthcare"),
"demo-mednet@api-index.org", "MedNet Systems", "NL", "W3"),
new EndpointSpec("POST", "/v1/insurance/eligibility",
320, 10, 150, new BigDecimal("0.0220"),
"""
{"covered":true,"coveragePercent":80,"copay":25.00,"currency":"APX","preAuthRequired":true,"preAuthCode":null,"planName":"DemoPlan Premium","validThrough":"2026-12-31"}""",
"Insurance Eligibility Check", "Verifies patient insurance coverage for a given service code. Returns copay, coverage %, and pre-auth requirements.",
List.of("insurance-verification", "coverage-check", "healthcare-billing"),
"demo-insubridge@api-index.org", "InsuBridge AG", "CH", "W3"),
new EndpointSpec("POST", "/v1/appointment/reserve",
200, 10, 200, new BigDecimal("0.0180"),
"""
{"appointmentId":"APT-DEMO-0900","slotId":"SLT-20260520-0900","confirmationCode":"CONF-4471-X","practitioner":"Dr. Helena Brandt","datetime":"2026-05-20T09:00:00Z","location":"Charit\\u00e9 Outpatient, Charit\\u00e9platz 1, 10117 Berlin"}""",
"Appointment Reservation", "Reserves a specialist appointment slot and returns a confirmation code.",
List.of("appointment-booking", "scheduling", "healthcare"),
"demo-insubridge@api-index.org", "InsuBridge AG", "CH", "W3"),
new EndpointSpec("POST", "/v1/prescription/preauth",
600, 10, 30, new BigDecimal("0.0750"),
"""
{"authId":"PA-2026-48821","authorized":true,"medication":"Bisoprolol 5mg","diagnosis":"Suspected stable angina (I25.1)","authorizedQuantity":30,"refillsAllowed":2,"validUntil":"2026-06-14T23:59:59Z","dispensingInstructions":"Once daily with food. Blood pressure monitoring required."}""",
"Prescription Pre-authorisation", "Issues pre-authorisation for prescribed medications. Highest latency — regulatory validation required. Rate-limited to 30/min.",
List.of("prescription-authorization", "pharmacy", "healthcare"),
"demo-rxchain@api-index.org", "RxChain Corp", "JP", "W3")
);
}
@@ -0,0 +1,89 @@
package org.botstandards.apix.demo.service;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import org.botstandards.apix.demo.entity.MockServiceConfig;
@ApplicationScoped
public class MockDispatcherService {
@Inject
EntityManager em;
@Inject
RateLimiterService rateLimiter;
private final Random rng = new Random();
/** (sandboxId:path:method) → Optional<config>. Empty means "confirmed not found". */
private final ConcurrentHashMap<String, Optional<MockServiceConfig>> configCache =
new ConcurrentHashMap<>();
@Scheduled(every = "5M")
void invalidateCache() {
configCache.clear();
}
public Response dispatch(String sandboxId, String path, String method) {
String fullPath = "/" + path;
MockServiceConfig cfg = resolveConfig(sandboxId, fullPath, method.toUpperCase());
if (cfg == null) {
return Response.status(404)
.entity(Map.of(
"message", method.toUpperCase() + " " + fullPath + " not configured in sandbox " + sandboxId,
"hint", "Register a mock config via the APIX demo API"))
.build();
}
if (!rateLimiter.allow(sandboxId, fullPath, method.toUpperCase(), cfg.ratePerMinute)) {
return Response.status(429)
.header("Retry-After", "60")
.entity(Map.of(
"message", "Rate limit exceeded",
"limitPerMinute", cfg.ratePerMinute,
"retryAfterSeconds", 60))
.build();
}
int jitter = (int) (cfg.latencyMs * (cfg.jitterPct / 100.0) * (rng.nextDouble() * 2 - 1));
int delay = Math.max(0, cfg.latencyMs + jitter);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return Response.status(cfg.statusCode)
.entity(cfg.responseBody)
.header("Content-Type", "application/json")
.header("X-APX-Cost", cfg.priceApx.toPlainString())
.header("X-APX-Latency-Ms", String.valueOf(delay))
.header("X-APX-Sandbox", sandboxId)
.build();
}
private MockServiceConfig resolveConfig(String sandboxId, String path, String method) {
String key = sandboxId + ":" + path + ":" + method;
return configCache.computeIfAbsent(key, k ->
em.createQuery(
"SELECT c FROM MockServiceConfig c " +
"WHERE c.sandboxId = :sid AND c.path = :path AND c.method = :method",
MockServiceConfig.class)
.setParameter("sid", sandboxId)
.setParameter("path", path)
.setParameter("method", method)
.getResultList()
.stream().findFirst()
).orElse(null);
}
}
@@ -0,0 +1,30 @@
package org.botstandards.apix.demo.service;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Fixed-window per-minute rate limiter keyed by (sandboxId, path, method).
* Resets all buckets at the start of each minute. Good enough for demo purposes —
* a token-bucket implementation would reduce burst risk in production.
*/
@ApplicationScoped
public class RateLimiterService {
private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
/** Returns true if the request is within the declared rate limit. */
public boolean allow(String sandboxId, String path, String method, int ratePerMinute) {
String key = sandboxId + ":" + path + ":" + method;
return counters.computeIfAbsent(key, k -> new AtomicInteger(0))
.incrementAndGet() <= ratePerMinute;
}
@Scheduled(cron = "0 * * * * ?")
void resetBuckets() {
counters.clear();
}
}
@@ -0,0 +1,23 @@
quarkus.http.port=8083
quarkus.smallrye-health.root-path=/q/health
quarkus.log.level=${LOG_LEVEL:INFO}
# DB — shares the same PostgreSQL instance as the registry
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/apix}
quarkus.datasource.username=${DB_USER:apix}
quarkus.datasource.password=${DB_PASSWORD:apix}
quarkus.hibernate-orm.database.generation=none
# Liquibase — demo owns demo_config + mock_service_configs
quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml
quarkus.liquibase.migrate-at-start=true
# Registry REST client — seed service calls the registry to create sandbox and register services
quarkus.rest-client.registry.url=${APIX_REGISTRY_URL:http://registry:8180}
quarkus.rest-client.registry.connect-timeout=5000
quarkus.rest-client.registry.read-timeout=10000
# Demo config
apix.demo.base-url=${APIX_DEMO_BASE_URL:https://demo.api-index.org}
apix.registry.admin-key=${APIX_API_KEY:dev-admin-key}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="demo-001" author="apix-demo">
<!-- Key-value store for demo bootstrap state (sandbox UUID, seed flag, etc.) -->
<createTable tableName="demo_config">
<column name="key" type="varchar(100)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="value" type="text"/>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Sandbox-scoped mock endpoint configurations.
Any sandbox owner can register mock endpoints here; the demo
ecosystem seed pre-populates the APIX demo sandbox on first boot. -->
<createTable tableName="mock_service_configs">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="sandbox_id" type="varchar(100)">
<constraints nullable="false"/>
</column>
<!-- Leading slash, e.g. /v1/address/validate -->
<column name="path" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="method" type="varchar(10)" defaultValue="POST">
<constraints nullable="false"/>
</column>
<column name="latency_ms" type="int" defaultValueNumeric="200">
<constraints nullable="false"/>
</column>
<!-- Applied as ±jitter_pct% random deviation around latency_ms -->
<column name="jitter_pct" type="int" defaultValueNumeric="10">
<constraints nullable="false"/>
</column>
<column name="rate_per_minute" type="int" defaultValueNumeric="100">
<constraints nullable="false"/>
</column>
<column name="status_code" type="int" defaultValueNumeric="200">
<constraints nullable="false"/>
</column>
<!-- Static JSON body returned verbatim on every call -->
<column name="response_body" type="text">
<constraints nullable="false"/>
</column>
<!-- Cost surfaced in X-APX-Cost response header -->
<column name="price_apx" type="numeric(10,4)" defaultValueNumeric="0.0100">
<constraints nullable="false"/>
</column>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="mock_service_configs"
columnNames="sandbox_id, path, method"
constraintName="uq_mock_config_sandbox_path_method"/>
<createIndex tableName="mock_service_configs" indexName="idx_mock_configs_sandbox">
<column name="sandbox_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<include file="changes/001-demo-schema.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>