From a0ea8d822cda35a5e8de0011cf6b5cac6df64f09 Mon Sep 17 00:00:00 2001 From: Janaka Premathilaka Date: Fri, 10 Apr 2026 14:08:39 -0700 Subject: [PATCH] Add phase 4 cloud-native observability and deployment examples --- README.md | 9 ++ deploy/docker-compose.yml | 45 ++++++ deploy/k8s/command-observability-demo.yaml | 35 +++++ deploy/k8s/resilience-fault-demo.yaml | 55 +++++++ deploy/otel-collector-config.yaml | 21 +++ docs/phase-4-cloud-native-observability.md | 46 ++++++ pom.xml | 24 +++ .../CorrelatedSagaOrchestrator.java | 71 +++++++++ .../observability/CorrelatedSagaStep.java | 6 + .../observability/ObservedCircuitBreaker.java | 39 +++++ .../ObservedCommandExecutor.java | 49 ++++++ .../example/ObservabilityPatternsTest.java | 113 ++++++++++++++ .../example/ResilienceFaultInjectionTest.java | 144 ++++++++++++++++++ 13 files changed, 657 insertions(+) create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/k8s/command-observability-demo.yaml create mode 100644 deploy/k8s/resilience-fault-demo.yaml create mode 100644 deploy/otel-collector-config.yaml create mode 100644 docs/phase-4-cloud-native-observability.md create mode 100644 src/main/java/org/example/cloudnative/observability/CorrelatedSagaOrchestrator.java create mode 100644 src/main/java/org/example/cloudnative/observability/CorrelatedSagaStep.java create mode 100644 src/main/java/org/example/cloudnative/observability/ObservedCircuitBreaker.java create mode 100644 src/main/java/org/example/cloudnative/observability/ObservedCommandExecutor.java create mode 100644 src/test/java/example/ObservabilityPatternsTest.java create mode 100644 src/test/java/example/ResilienceFaultInjectionTest.java diff --git a/README.md b/README.md index 55a83f9..2297052 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Phase tracking: - [Phase 1 — Production-ready reference baseline](docs/phase-1-production-readiness.md) - [Phase 2 — Expand from GoF to enterprise patterns](docs/phase-2-enterprise-patterns.md) - [Phase 3 — Modern Java implementation variants](docs/phase-3-modern-java-variants.md) +- [Phase 4 — Cloud-native + observability examples](docs/phase-4-cloud-native-observability.md) --- @@ -100,6 +101,14 @@ Package: `src/main/java/org/example/architecture` - **Strangler Fig migration sample** (`strangler`) - **Anti-Corruption Layer** (`acl`) +### 9) Cloud-native + observability examples +Package: `src/main/java/org/example/cloudnative/observability` + +- **Command latency tracing** (OpenTelemetry spans + latency histogram) +- **Circuit breaker metrics** (success/failure/rejection counters) +- **Saga correlation IDs** (trace attributes and correlated execution) +- **Resilience fault-injection scenarios** (Testcontainers + WireMock) + --- ## Pattern index matrix diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..1140df9 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,45 @@ +version: "3.9" + +services: + command-observability-demo: + image: ghcr.io/janaka2/designpatterns-command-demo:latest + environment: + OTEL_SERVICE_NAME: command-observability-demo + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + ports: + - "8088:8080" + depends_on: + - otel-collector + + resilience-fault-demo: + image: ghcr.io/janaka2/designpatterns-resilience-demo:latest + environment: + UPSTREAM_BASE_URL: http://wiremock:8080 + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + ports: + - "8090:8080" + depends_on: + - wiremock + - otel-collector + + wiremock: + image: wiremock/wiremock:3.13.1 + command: ["--verbose"] + ports: + - "8089:8080" + + otel-collector: + image: otel/opentelemetry-collector:0.122.1 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + depends_on: + - jaeger + + jaeger: + image: jaegertracing/all-in-one:1.64.0 + ports: + - "16686:16686" diff --git a/deploy/k8s/command-observability-demo.yaml b/deploy/k8s/command-observability-demo.yaml new file mode 100644 index 0000000..4aa5c43 --- /dev/null +++ b/deploy/k8s/command-observability-demo.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: command-observability-demo +spec: + replicas: 1 + selector: + matchLabels: + app: command-observability-demo + template: + metadata: + labels: + app: command-observability-demo + spec: + containers: + - name: app + image: ghcr.io/janaka2/designpatterns-command-demo:latest + ports: + - containerPort: 8080 + env: + - name: OTEL_SERVICE_NAME + value: command-observability-demo + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: http://otel-collector:4317 +--- +apiVersion: v1 +kind: Service +metadata: + name: command-observability-demo +spec: + selector: + app: command-observability-demo + ports: + - port: 80 + targetPort: 8080 diff --git a/deploy/k8s/resilience-fault-demo.yaml b/deploy/k8s/resilience-fault-demo.yaml new file mode 100644 index 0000000..277a7cd --- /dev/null +++ b/deploy/k8s/resilience-fault-demo.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: resilience-fault-demo +spec: + replicas: 1 + selector: + matchLabels: + app: resilience-fault-demo + template: + metadata: + labels: + app: resilience-fault-demo + spec: + containers: + - name: app + image: ghcr.io/janaka2/designpatterns-resilience-demo:latest + ports: + - containerPort: 8080 + env: + - name: UPSTREAM_BASE_URL + value: http://wiremock:8080 + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: http://otel-collector:4317 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wiremock +spec: + replicas: 1 + selector: + matchLabels: + app: wiremock + template: + metadata: + labels: + app: wiremock + spec: + containers: + - name: wiremock + image: wiremock/wiremock:3.13.1 + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: wiremock +spec: + selector: + app: wiremock + ports: + - port: 8080 + targetPort: 8080 diff --git a/deploy/otel-collector-config.yaml b/deploy/otel-collector-config.yaml new file mode 100644 index 0000000..b6d2484 --- /dev/null +++ b/deploy/otel-collector-config.yaml @@ -0,0 +1,21 @@ +receivers: + otlp: + protocols: + grpc: + http: + +exporters: + debug: + otlp/jaeger: + endpoint: jaeger:4317 + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug, otlp/jaeger] + metrics: + receivers: [otlp] + exporters: [debug] diff --git a/docs/phase-4-cloud-native-observability.md b/docs/phase-4-cloud-native-observability.md new file mode 100644 index 0000000..18b4b54 --- /dev/null +++ b/docs/phase-4-cloud-native-observability.md @@ -0,0 +1,46 @@ +# Phase 4 — Cloud-native + observability examples + +This phase adds observability-first examples and deployment-ready scaffolding for running selected pattern demos in containerized environments. + +## Observability-first pattern examples + +Package: `org.example.cloudnative.observability` + +### 1) Command latency trace +- `ObservedCommandExecutor` wraps command execution with an OpenTelemetry span (`command.execute`). +- Records latency metric `pattern.command.latency.ms` to make command performance visible in dashboards. + +### 2) Circuit breaker metrics +- `ObservedCircuitBreaker` wraps `CircuitBreaker` and emits counter metric `pattern.circuit_breaker.executions`. +- Counter outcome labels: `success`, `failure`, `rejected` (open circuit). + +### 3) Saga correlation IDs +- `CorrelatedSagaOrchestrator` accepts a `correlationId` and propagates it through: + - saga orchestration span, + - per-step action span, + - compensation spans on rollback. +- `CorrelatedSagaStep` keeps saga step contracts explicit and correlation-aware. + +## Resilience test scenarios + +`ResilienceFaultInjectionTest` introduces fault injection via **Testcontainers + WireMock**: + +- timeout behavior (`/timeout` fixed delay) +- partial/transient failures (`/inventory` stateful scenario) +- retries via `RetryExecutor` + +This verifies that resilience patterns remain understandable under stress and failure conditions. + +## Deployment-ready examples + +Minimal deployment assets are included under `deploy/`: + +- `deploy/docker-compose.yml` + - command observability demo + - resilience fault demo + - WireMock dependency simulator + - OpenTelemetry Collector + Jaeger +- `deploy/k8s/command-observability-demo.yaml` +- `deploy/k8s/resilience-fault-demo.yaml` + +These manifests are intentionally minimal so teams can copy them as a starting point for real service demos. diff --git a/pom.xml b/pom.xml index 57442e5..901c198 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,8 @@ 10.12.4 4.8.6.6 0.8.12 + 1.46.0 + 1.20.6 @@ -56,6 +58,28 @@ ${reactor.version} test + + io.opentelemetry + opentelemetry-api + ${opentelemetry.version} + + + io.opentelemetry + opentelemetry-sdk + ${opentelemetry.version} + + + io.opentelemetry + opentelemetry-sdk-testing + ${opentelemetry.version} + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + diff --git a/src/main/java/org/example/cloudnative/observability/CorrelatedSagaOrchestrator.java b/src/main/java/org/example/cloudnative/observability/CorrelatedSagaOrchestrator.java new file mode 100644 index 0000000..064502e --- /dev/null +++ b/src/main/java/org/example/cloudnative/observability/CorrelatedSagaOrchestrator.java @@ -0,0 +1,71 @@ +package org.example.cloudnative.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class CorrelatedSagaOrchestrator { + private final Tracer tracer; + + public CorrelatedSagaOrchestrator(Tracer tracer) { + this.tracer = Objects.requireNonNull(tracer); + } + + public void execute(String correlationId, List steps) { + Span sagaSpan = tracer.spanBuilder("saga.orchestration") + .setSpanKind(SpanKind.INTERNAL) + .setAttribute("pattern.type", "saga") + .setAttribute("saga.correlation_id", correlationId) + .startSpan(); + + List completed = new ArrayList<>(); + try { + for (CorrelatedSagaStep step : steps) { + runStep(correlationId, step); + completed.add(step); + } + sagaSpan.setStatus(StatusCode.OK); + } catch (RuntimeException ex) { + rollback(correlationId, completed); + sagaSpan.recordException(ex); + sagaSpan.setStatus(StatusCode.ERROR, "saga failed"); + throw new IllegalStateException("Correlated saga orchestration failed", ex); + } finally { + sagaSpan.end(); + } + } + + private void runStep(String correlationId, CorrelatedSagaStep step) { + Span span = tracer.spanBuilder("saga.step") + .setAttribute("saga.correlation_id", correlationId) + .setAttribute("saga.step", step.name()) + .startSpan(); + try { + step.action().accept(correlationId); + span.setStatus(StatusCode.OK); + } finally { + span.end(); + } + } + + private void rollback(String correlationId, List completed) { + for (int i = completed.size() - 1; i >= 0; i--) { + CorrelatedSagaStep step = completed.get(i); + Span compensationSpan = tracer.spanBuilder("saga.compensation") + .setAttribute("saga.correlation_id", correlationId) + .setAttribute("saga.step", step.name()) + .startSpan(); + try { + step.compensation().accept(correlationId); + compensationSpan.setStatus(StatusCode.OK); + } finally { + compensationSpan.end(); + } + } + } +} diff --git a/src/main/java/org/example/cloudnative/observability/CorrelatedSagaStep.java b/src/main/java/org/example/cloudnative/observability/CorrelatedSagaStep.java new file mode 100644 index 0000000..b0b9c9b --- /dev/null +++ b/src/main/java/org/example/cloudnative/observability/CorrelatedSagaStep.java @@ -0,0 +1,6 @@ +package org.example.cloudnative.observability; + +import java.util.function.Consumer; + +public record CorrelatedSagaStep(String name, Consumer action, Consumer compensation) { +} diff --git a/src/main/java/org/example/cloudnative/observability/ObservedCircuitBreaker.java b/src/main/java/org/example/cloudnative/observability/ObservedCircuitBreaker.java new file mode 100644 index 0000000..3bdd683 --- /dev/null +++ b/src/main/java/org/example/cloudnative/observability/ObservedCircuitBreaker.java @@ -0,0 +1,39 @@ +package org.example.cloudnative.observability; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import org.example.enterprise.resilience.CircuitBreaker; + +import java.util.Objects; +import java.util.function.Supplier; + +public class ObservedCircuitBreaker { + private static final AttributeKey OUTCOME = AttributeKey.stringKey("outcome"); + + private final CircuitBreaker delegate; + private final LongCounter executionCounter; + + public ObservedCircuitBreaker(CircuitBreaker delegate, Meter meter) { + this.delegate = Objects.requireNonNull(delegate); + this.executionCounter = Objects.requireNonNull(meter) + .counterBuilder("pattern.circuit_breaker.executions") + .setDescription("Circuit breaker execution outcomes") + .build(); + } + + public T execute(Supplier operation) { + try { + T result = delegate.execute(operation); + executionCounter.add(1, Attributes.of(OUTCOME, "success")); + return result; + } catch (IllegalStateException openCircuit) { + executionCounter.add(1, Attributes.of(OUTCOME, "rejected")); + throw openCircuit; + } catch (RuntimeException failure) { + executionCounter.add(1, Attributes.of(OUTCOME, "failure")); + throw failure; + } + } +} diff --git a/src/main/java/org/example/cloudnative/observability/ObservedCommandExecutor.java b/src/main/java/org/example/cloudnative/observability/ObservedCommandExecutor.java new file mode 100644 index 0000000..c1a7e9c --- /dev/null +++ b/src/main/java/org/example/cloudnative/observability/ObservedCommandExecutor.java @@ -0,0 +1,49 @@ +package org.example.cloudnative.observability; + +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public class ObservedCommandExecutor { + private final Tracer tracer; + private final DoubleHistogram latencyHistogram; + + public ObservedCommandExecutor(Tracer tracer, Meter meter) { + this.tracer = Objects.requireNonNull(tracer); + this.latencyHistogram = Objects.requireNonNull(meter) + .histogramBuilder("pattern.command.latency.ms") + .setDescription("Command execution latency") + .setUnit("ms") + .build(); + } + + public T execute(String commandName, Supplier commandAction) { + long startNanos = System.nanoTime(); + Span span = tracer.spanBuilder("command.execute") + .setSpanKind(SpanKind.INTERNAL) + .setAttribute("pattern.type", "command") + .setAttribute("command.name", commandName) + .startSpan(); + + try { + T result = commandAction.get(); + span.setStatus(StatusCode.OK); + return result; + } catch (RuntimeException ex) { + span.recordException(ex); + span.setStatus(StatusCode.ERROR, "command failure"); + throw ex; + } finally { + double latencyMillis = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startNanos) / 1_000.0; + latencyHistogram.record(latencyMillis); + span.end(); + } + } +} diff --git a/src/test/java/example/ObservabilityPatternsTest.java b/src/test/java/example/ObservabilityPatternsTest.java new file mode 100644 index 0000000..49f5a04 --- /dev/null +++ b/src/test/java/example/ObservabilityPatternsTest.java @@ -0,0 +1,113 @@ +package example; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.InMemoryMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import org.example.cloudnative.observability.CorrelatedSagaOrchestrator; +import org.example.cloudnative.observability.CorrelatedSagaStep; +import org.example.cloudnative.observability.ObservedCircuitBreaker; +import org.example.cloudnative.observability.ObservedCommandExecutor; +import org.example.enterprise.resilience.CircuitBreaker; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ObservabilityPatternsTest { + + @Test + public void commandLatencyAndSagaCorrelationProduceSpans() { + InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .setResource(Resource.empty()) + .build(); + SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .setResource(Resource.empty()) + .build(); + + OpenTelemetry openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setMeterProvider(meterProvider) + .build(); + + Tracer tracer = openTelemetry.getTracer("patterns-test"); + Meter meter = openTelemetry.getMeter("patterns-test"); + + ObservedCommandExecutor commandExecutor = new ObservedCommandExecutor(tracer, meter); + String result = commandExecutor.execute("CreateOrder", () -> "ok"); + + AtomicReference seenCorrelation = new AtomicReference<>(); + CorrelatedSagaOrchestrator saga = new CorrelatedSagaOrchestrator(tracer); + saga.execute("corr-42", List.of( + new CorrelatedSagaStep("reserveInventory", seenCorrelation::set, value -> { + }) + )); + + List spans = spanExporter.getFinishedSpanItems(); + List spanNames = spans.stream().map(SpanData::getName).collect(Collectors.toList()); + assertTrue(spanNames.contains("command.execute")); + assertTrue(spanNames.contains("saga.orchestration")); + assertEquals("corr-42", seenCorrelation.get()); + assertEquals("ok", result); + + List metrics = metricReader.collectAllMetrics(); + assertTrue(metrics.stream().anyMatch(m -> m.getName().equals("pattern.command.latency.ms"))); + + tracerProvider.close(); + meterProvider.close(); + } + + @Test + public void circuitBreakerMetricsRecordOutcomes() { + InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .build(); + Meter meter = OpenTelemetrySdk.builder() + .setMeterProvider(meterProvider) + .build() + .getMeter("patterns-test"); + + CircuitBreaker breaker = new CircuitBreaker(1, Duration.ofMinutes(1), + Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)); + ObservedCircuitBreaker observed = new ObservedCircuitBreaker(breaker, meter); + + assertThrows(RuntimeException.class, () -> observed.execute(() -> { + throw new RuntimeException("down"); + })); + assertThrows(IllegalStateException.class, () -> observed.execute(() -> "never")); + + List metrics = metricReader.collectAllMetrics(); + MetricData counter = metrics.stream() + .filter(metric -> metric.getName().equals("pattern.circuit_breaker.executions")) + .findFirst() + .orElseThrow(); + + int points = counter.getLongSumData().getPoints().size(); + assertEquals(2, points); + + meterProvider.close(); + } +} diff --git a/src/test/java/example/ResilienceFaultInjectionTest.java b/src/test/java/example/ResilienceFaultInjectionTest.java new file mode 100644 index 0000000..804d030 --- /dev/null +++ b/src/test/java/example/ResilienceFaultInjectionTest.java @@ -0,0 +1,144 @@ +package example; + +import org.example.enterprise.resilience.ExponentialBackoffStrategy; +import org.example.enterprise.resilience.RetryExecutor; +import org.example.enterprise.resilience.TimeoutExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +public class ResilienceFaultInjectionTest { + + private final TimeoutExecutor timeoutExecutor = new TimeoutExecutor(); + + @AfterEach + public void cleanup() { + timeoutExecutor.shutdown(); + } + + @Test + public void wiremockFaultInjectionShowsTimeoutsAndRetries() throws Exception { + assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker is required for Testcontainers"); + + try (GenericContainer wiremock = new GenericContainer<>(DockerImageName.parse("wiremock/wiremock:3.13.1")) + .withExposedPorts(8080) + .waitingFor(Wait.forHttp("/__admin"))) { + wiremock.start(); + + String baseUrl = "http://" + wiremock.getHost() + ":" + wiremock.getMappedPort(8080); + configureTimeoutStub(baseUrl); + configureFlakyScenario(baseUrl); + + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)).build(); + + assertThrows(RuntimeException.class, () -> timeoutExecutor.execute(() -> httpGet(client, baseUrl + "/timeout"), + Duration.ofMillis(300))); + + RetryExecutor retryExecutor = new RetryExecutor(3, new ExponentialBackoffStrategy(0, 1)); + AtomicInteger attempts = new AtomicInteger(); + String body = retryExecutor.execute(() -> { + attempts.incrementAndGet(); + return httpGet(client, baseUrl + "/inventory"); + }); + + assertEquals("ok", body); + assertEquals(3, attempts.get()); + } + } + + private void configureTimeoutStub(String baseUrl) throws Exception { + String payload = """ + { + "request": {"method": "GET", "url": "/timeout"}, + "response": {"status": 200, "fixedDelayMilliseconds": 2000, "body": "late"} + } + """; + postMapping(baseUrl, payload); + } + + private void configureFlakyScenario(String baseUrl) throws Exception { + String firstFailure = """ + { + "scenarioName": "Inventory Retry", + "requiredScenarioState": "Started", + "newScenarioState": "second-failure", + "request": {"method": "GET", "url": "/inventory"}, + "response": {"status": 503, "body": "temporary-failure"} + } + """; + String secondFailure = """ + { + "scenarioName": "Inventory Retry", + "requiredScenarioState": "second-failure", + "newScenarioState": "healthy", + "request": {"method": "GET", "url": "/inventory"}, + "response": {"status": 500, "body": "partial-failure"} + } + """; + String success = """ + { + "scenarioName": "Inventory Retry", + "requiredScenarioState": "healthy", + "request": {"method": "GET", "url": "/inventory"}, + "response": {"status": 200, "body": "ok"} + } + """; + + postMapping(baseUrl, firstFailure); + postMapping(baseUrl, secondFailure); + postMapping(baseUrl, success); + } + + private void postMapping(String baseUrl, String payload) throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/__admin/mappings")) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + + HttpResponse response; + try { + response = client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException("Failed to configure WireMock mapping", e); + } + + if (response.statusCode() >= 300) { + throw new IllegalStateException("WireMock mapping rejected: " + response.body()); + } + } + + private String httpGet(HttpClient client, String url) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(4)) + .GET() + .build(); + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 500) { + throw new IllegalStateException("Upstream failure " + response.statusCode()); + } + return response.body(); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException("HTTP call failed", e); + } + } +}