diff --git a/.cursor/agents/robot-micronaut-coder.md b/.cursor/agents/robot-micronaut-coder.md
index f03b36ff..b9c0a878 100644
--- a/.cursor/agents/robot-micronaut-coder.md
+++ b/.cursor/agents/robot-micronaut-coder.md
@@ -34,6 +34,7 @@ Apply guidance from these Skills when relevant:
- `@521-frameworks-micronaut-testing-unit-tests`: Micronaut unit testing
- `@522-frameworks-micronaut-testing-integration-tests`: Micronaut integration testing
- `@523-frameworks-micronaut-testing-acceptance-tests`: Micronaut acceptance testing
+- `@702-technologies-wiremock`: Improve tests with Wiremock
### Workflow
diff --git a/.cursor/agents/robot-quarkus-coder.md b/.cursor/agents/robot-quarkus-coder.md
index c4d06f0b..fd957a7c 100644
--- a/.cursor/agents/robot-quarkus-coder.md
+++ b/.cursor/agents/robot-quarkus-coder.md
@@ -33,6 +33,7 @@ Apply guidance from these Skills when relevant:
- `@421-frameworks-quarkus-testing-unit-tests`: Quarkus Unit Testing
- `@422-frameworks-quarkus-testing-integration-tests`: Quarkus integration testing
- `@423-frameworks-quarkus-testing-acceptance-tests`: Quarkus acceptance testing
+- `@702-technologies-wiremock`: Improve tests with Wiremock
### Workflow
diff --git a/.cursor/agents/robot-spring-boot-coder.md b/.cursor/agents/robot-spring-boot-coder.md
index 1ce065ac..038e00cc 100644
--- a/.cursor/agents/robot-spring-boot-coder.md
+++ b/.cursor/agents/robot-spring-boot-coder.md
@@ -37,6 +37,7 @@ Apply guidance from these Skills when relevant:
- `@321-frameworks-spring-boot-testing-unit-tests`: Spring Boot unit testing
- `@322-frameworks-spring-boot-testing-integration-tests`: Spring Boot integration testing
- `@323-frameworks-spring-boot-testing-acceptance-tests`: Spring Boot acceptance testing
+- `@702-technologies-wiremock`: Improve tests with Wiremock
### Workflow
diff --git a/examples/requirements-examples/problem1/implementation/pom.xml b/examples/requirements-examples/problem1/implementation/pom.xml
index af3c7bc4..e4f45534 100644
--- a/examples/requirements-examples/problem1/implementation/pom.xml
+++ b/examples/requirements-examples/problem1/implementation/pom.xml
@@ -23,6 +23,18 @@
org.springframework.boot
spring-boot-starter-webmvc
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-json
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
org.springframework.boot
spring-boot-starter-webmvc-test
@@ -37,6 +49,41 @@
+
+ org.openapitools
+ openapi-generator-maven-plugin
+ 7.15.0
+
+
+ generate-api-boundary
+ generate-sources
+
+ generate
+
+
+ ${project.basedir}/../requirements/agile/US-001-god-analysis-api.openapi.yaml
+ spring
+ info.jab.ms.api
+ info.jab.ms.api.model
+ false
+ false
+ false
+ false
+
+ java8
+ true
+ true
+ true
+ true
+ true
+ false
+ none
+ none
+
+
+
+
+
org.apache.maven.plugins
maven-surefire-plugin
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/algorithm/UnicodeAggregator.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/algorithm/UnicodeAggregator.java
index 96e486b6..245cfcdf 100644
--- a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/algorithm/UnicodeAggregator.java
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/algorithm/UnicodeAggregator.java
@@ -1,42 +1,16 @@
package info.jab.ms.algorithm;
import java.math.BigInteger;
+import java.util.Objects;
+import org.springframework.stereotype.Component;
-/**
- * Concatenates decimal digits of each Unicode code point in a name, parses to {@link BigInteger},
- * and sums matching names. First-code-point filter uses case-insensitive comparison so the pinned
- * acceptance sum aligns with upstream data (e.g. {@code filter=n} matches {@code Nike}).
- */
+@Component
public final class UnicodeAggregator {
- private UnicodeAggregator() {}
-
- public static BigInteger sumFiltered(String filter, Iterable names) {
- if (filter == null || filter.isEmpty()) {
- return BigInteger.ZERO;
- }
- int filterCp = filter.codePointAt(0);
- BigInteger total = BigInteger.ZERO;
- for (String name : names) {
- if (name == null || name.isEmpty()) {
- continue;
- }
- int firstCp = name.codePointAt(0);
- if (!firstCodePointMatchesFilter(firstCp, filterCp)) {
- continue;
- }
- total = total.add(nameToBigInteger(name));
- }
- return total;
- }
-
- static boolean firstCodePointMatchesFilter(int nameFirstCodePoint, int filterCodePoint) {
- return Character.toLowerCase(nameFirstCodePoint) == Character.toLowerCase(filterCodePoint);
- }
-
- static BigInteger nameToBigInteger(String name) {
- StringBuilder digits = new StringBuilder();
- name.codePoints().forEach(cp -> digits.append(Integer.toString(cp)));
- return new BigInteger(digits.toString());
- }
+ public BigInteger toBigInteger(String value) {
+ Objects.requireNonNull(value, "value must not be null");
+ return value.codePoints()
+ .mapToObj(BigInteger::valueOf)
+ .reduce(BigInteger.ZERO, BigInteger::add);
+ }
}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/GodDataClient.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/GodDataClient.java
index 1e6f40f4..575def0d 100644
--- a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/GodDataClient.java
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/GodDataClient.java
@@ -1,75 +1,58 @@
package info.jab.ms.client;
+import info.jab.ms.algorithm.UnicodeAggregator;
+import info.jab.ms.api.model.PantheonSource;
import info.jab.ms.config.GodOutboundProperties;
+import info.jab.ms.service.GodData;
+import info.jab.ms.service.PantheonDataSource;
import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.MediaType;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
@Component
-public class GodDataClient {
-
- private static final Logger log = LoggerFactory.getLogger(GodDataClient.class);
-
- private static final ParameterizedTypeReference> STRING_LIST =
- new ParameterizedTypeReference<>() {};
-
- private final RestClient restClient;
- private final GodOutboundProperties properties;
-
- public GodDataClient(
- @Qualifier("godRestClient") RestClient restClient, GodOutboundProperties properties) {
- this.restClient = restClient;
- this.properties = properties;
- }
-
- /**
- * Single GET to the configured URL for the pantheon. On connect/read failure or non-2xx, returns
- * an empty list (no retries).
- */
- public List fetchNames(String pantheonKey) {
- String url = urlFor(pantheonKey);
- if (url == null || url.isBlank()) {
- log.warn("god.outbound.fetch.skipped source={} reason=no_url", pantheonKey);
- return List.of();
- }
- log.debug("god.outbound.fetch.start source={} url={}", pantheonKey, url);
- try {
- List names =
- restClient.get().uri(url).retrieve().body(STRING_LIST);
- if (names == null) {
- log.warn("god.outbound.fetch.empty_body source={}", pantheonKey);
- return List.of();
- }
- log.info(
- "god.outbound.fetch.ok source={} url={} nameCount={}",
- pantheonKey,
- url,
- names.size());
- return names;
- } catch (RestClientException ex) {
- log.warn(
- "god.outbound.fetch.failed source={} url={} error={}",
- pantheonKey,
- url,
- ex.toString());
- return List.of();
- }
- }
-
- private String urlFor(String pantheonKey) {
- if (properties.urls() == null) {
- return null;
- }
- return switch (pantheonKey) {
- case "greek" -> properties.urls().greek();
- case "roman" -> properties.urls().roman();
- case "nordic" -> properties.urls().nordic();
- default -> null;
- };
- }
+public class GodDataClient implements PantheonDataSource {
+
+ private static final ParameterizedTypeReference> GOD_NAMES_TYPE = new ParameterizedTypeReference<>() {
+ };
+
+ private final RestClient restClient;
+ private final GodOutboundProperties properties;
+ private final UnicodeAggregator unicodeAggregator;
+ private final OutboundCallObserver outboundCallObserver;
+
+ public GodDataClient(
+ @Qualifier("godOutboundRestClient") RestClient restClient,
+ GodOutboundProperties properties,
+ UnicodeAggregator unicodeAggregator,
+ OutboundCallObserver outboundCallObserver
+ ) {
+ this.restClient = restClient;
+ this.properties = properties;
+ this.unicodeAggregator = unicodeAggregator;
+ this.outboundCallObserver = outboundCallObserver;
+ }
+
+ @Override
+ public List fetch(PantheonSource source) {
+ outboundCallObserver.onStart(source);
+ try {
+ var names = restClient.get()
+ .uri(properties.getUrlFor(source))
+ .accept(MediaType.APPLICATION_JSON)
+ .retrieve()
+ .body(GOD_NAMES_TYPE);
+ var result = names.stream()
+ .map(name -> new GodData(name, unicodeAggregator.toBigInteger(name)))
+ .toList();
+ outboundCallObserver.onSuccess(source, result.size());
+ return result;
+ } catch (RestClientException exception) {
+ outboundCallObserver.onFailure(source, exception);
+ throw new OutboundSourceException(source, exception);
+ }
+ }
}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/LoggingOutboundCallObserver.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/LoggingOutboundCallObserver.java
new file mode 100644
index 00000000..d7d034b1
--- /dev/null
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/LoggingOutboundCallObserver.java
@@ -0,0 +1,27 @@
+package info.jab.ms.client;
+
+import info.jab.ms.api.model.PantheonSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+final class LoggingOutboundCallObserver implements OutboundCallObserver {
+
+ private static final Logger log = LoggerFactory.getLogger(LoggingOutboundCallObserver.class);
+
+ @Override
+ public void onStart(PantheonSource source) {
+ log.info("outbound_call_started source={}", source.getValue());
+ }
+
+ @Override
+ public void onSuccess(PantheonSource source, int payloadSize) {
+ log.info("outbound_call_completed source={} size={}", source.getValue(), payloadSize);
+ }
+
+ @Override
+ public void onFailure(PantheonSource source, Exception exception) {
+ log.warn("outbound_call_failed source={} reason={}", source.getValue(), exception.getMessage());
+ }
+}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundCallObserver.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundCallObserver.java
new file mode 100644
index 00000000..1a1af24b
--- /dev/null
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundCallObserver.java
@@ -0,0 +1,12 @@
+package info.jab.ms.client;
+
+import info.jab.ms.api.model.PantheonSource;
+
+public interface OutboundCallObserver {
+
+ void onStart(PantheonSource source);
+
+ void onSuccess(PantheonSource source, int payloadSize);
+
+ void onFailure(PantheonSource source, Exception exception);
+}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundSourceException.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundSourceException.java
new file mode 100644
index 00000000..99f66385
--- /dev/null
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/client/OutboundSourceException.java
@@ -0,0 +1,10 @@
+package info.jab.ms.client;
+
+import info.jab.ms.api.model.PantheonSource;
+
+public final class OutboundSourceException extends RuntimeException {
+
+ public OutboundSourceException(PantheonSource source, Throwable cause) {
+ super("Outbound source fetch failed for source: " + source.getValue(), cause);
+ }
+}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/GodOutboundProperties.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/GodOutboundProperties.java
index 4b383ed2..e3d9e231 100644
--- a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/GodOutboundProperties.java
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/GodOutboundProperties.java
@@ -1,14 +1,27 @@
package info.jab.ms.config;
+import info.jab.ms.api.model.PantheonSource;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.net.URI;
import java.time.Duration;
+import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.boot.context.properties.bind.DefaultValue;
+import org.springframework.validation.annotation.Validated;
+@Validated
@ConfigurationProperties(prefix = "god.outbound")
public record GodOutboundProperties(
- @DefaultValue("5s") Duration connectTimeout,
- @DefaultValue("5s") Duration readTimeout,
- Urls urls) {
+ @NotNull Duration connectTimeout,
+ @NotNull Duration readTimeout,
+ @NotEmpty Map urls
+) {
- public record Urls(String greek, String roman, String nordic) {}
+ public URI getUrlFor(PantheonSource source) {
+ var endpoint = urls.get(source.getValue());
+ if (endpoint == null) {
+ throw new IllegalStateException("Missing outbound URL for source: " + source.getValue());
+ }
+ return endpoint;
+ }
}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/HttpClientConfig.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/HttpClientConfig.java
index 00051c97..db320873 100644
--- a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/HttpClientConfig.java
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/config/HttpClientConfig.java
@@ -1,32 +1,27 @@
package info.jab.ms.config;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
+import java.net.http.HttpClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
@EnableConfigurationProperties(GodOutboundProperties.class)
-public class HttpClientConfig {
+class HttpClientConfig {
- @Bean
- public RestClient.Builder godRestClientBuilder(GodOutboundProperties props) {
- SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
- factory.setConnectTimeout((int) props.connectTimeout().toMillis());
- factory.setReadTimeout((int) props.readTimeout().toMillis());
- return RestClient.builder().requestFactory(factory);
- }
+ @Bean
+ RestClient godOutboundRestClient(GodOutboundProperties properties) {
+ var httpClient = HttpClient.newBuilder()
+ .connectTimeout(properties.connectTimeout())
+ .build();
- @Bean
- public RestClient godRestClient(RestClient.Builder godRestClientBuilder) {
- return godRestClientBuilder.build();
- }
+ var requestFactory = new JdkClientHttpRequestFactory(httpClient);
+ requestFactory.setReadTimeout(properties.readTimeout());
- @Bean(name = "godAnalysisExecutor")
- public Executor godAnalysisExecutor() {
- return Executors.newVirtualThreadPerTaskExecutor();
- }
+ return RestClient.builder()
+ .requestFactory(requestFactory)
+ .build();
+ }
}
diff --git a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/controller/GlobalExceptionHandler.java b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/controller/GlobalExceptionHandler.java
index 27052633..16e509e2 100644
--- a/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/controller/GlobalExceptionHandler.java
+++ b/examples/requirements-examples/problem1/implementation/src/main/java/info/jab/ms/controller/GlobalExceptionHandler.java
@@ -1,22 +1,71 @@
package info.jab.ms.controller;
+import info.jab.ms.api.model.ErrorResponse;
import info.jab.ms.exception.BadRequestException;
-import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+import java.util.stream.StreamSupport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.HandlerMethodValidationException;
-@ControllerAdvice
-public class GlobalExceptionHandler {
+@RestControllerAdvice
+class GlobalExceptionHandler {
- private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+ private static final Pattern UNEXPECTED_VALUE = Pattern.compile("Unexpected value '([^']+)'");
- @ExceptionHandler(BadRequestException.class)
- public ResponseEntity