Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/agents/robot-micronaut-coder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .cursor/agents/robot-quarkus-coder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .cursor/agents/robot-spring-boot-coder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions examples/requirements-examples/problem1/implementation/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
Expand All @@ -37,6 +49,41 @@
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.15.0</version>
<executions>
<execution>
<id>generate-api-boundary</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/../requirements/agile/US-001-god-analysis-api.openapi.yaml</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>info.jab.ms.api</apiPackage>
<modelPackage>info.jab.ms.api.model</modelPackage>
<generateApiTests>false</generateApiTests>
<generateModelTests>false</generateModelTests>
<generateApiDocumentation>false</generateApiDocumentation>
<generateModelDocumentation>false</generateModelDocumentation>
<configOptions>
<dateLibrary>java8</dateLibrary>
<useJakartaEe>true</useJakartaEe>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<useTags>true</useTags>
<skipDefaultInterface>true</skipDefaultInterface>
<openApiNullable>false</openApiNullable>
<annotationLibrary>none</annotationLibrary>
<documentationProvider>none</documentationProvider>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<String>> 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<String> 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<String> 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<List<String>> 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<GodData> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, URI> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading