Skip to content

Commit 5d1bc2b

Browse files
feat(fx): add FrankfurterRateAdapter for free FX rate provider (STA-206) (#233)
Add Frankfurter API adapter as a free alternative to Refinitiv for sandbox/dev use. No API key required. Activated via app.fx.rate-provider=frankfurter. - FrankfurterRateAdapter: RestClient with HTTP/1.1, @CIRCUITBREAKER + @Retry - FrankfurterProperties: @ConfigurationProperties with sensible defaults - FrankfurterRateResponse: package-private ACL DTO - Resilience4j circuit breaker + retry config for frankfurter instance - 8 WireMock-based unit tests (success, empty rates, errors, timeout) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4facdb commit 5d1bc2b

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.stablecoin.payments.fx.infrastructure.provider.frankfurter;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
@ConfigurationProperties(prefix = "app.fx.frankfurter")
6+
public record FrankfurterProperties(
7+
String baseUrl,
8+
Integer readTimeoutMs
9+
) {
10+
public FrankfurterProperties {
11+
if (baseUrl == null || baseUrl.isBlank()) {
12+
baseUrl = "https://api.frankfurter.app";
13+
}
14+
if (readTimeoutMs == null || readTimeoutMs <= 0) {
15+
readTimeoutMs = 5000;
16+
}
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.stablecoin.payments.fx.infrastructure.provider.frankfurter;
2+
3+
import com.stablecoin.payments.fx.domain.model.CorridorRate;
4+
import com.stablecoin.payments.fx.domain.port.RateProvider;
5+
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
6+
import io.github.resilience4j.retry.annotation.Retry;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
9+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
10+
import org.springframework.http.client.JdkClientHttpRequestFactory;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.web.client.RestClient;
13+
14+
import java.net.http.HttpClient;
15+
import java.net.http.HttpClient.Version;
16+
import java.time.Duration;
17+
import java.util.Optional;
18+
19+
@Slf4j
20+
@Component
21+
@ConditionalOnProperty(name = "app.fx.rate-provider", havingValue = "frankfurter")
22+
@EnableConfigurationProperties(FrankfurterProperties.class)
23+
public class FrankfurterRateAdapter implements RateProvider {
24+
25+
private static final int DEFAULT_SPREAD_BPS = 30;
26+
private static final int DEFAULT_FEE_BPS = 30;
27+
28+
private final RestClient restClient;
29+
30+
public FrankfurterRateAdapter(FrankfurterProperties properties) {
31+
var httpClient = HttpClient.newBuilder()
32+
.version(Version.HTTP_1_1)
33+
.connectTimeout(Duration.ofMillis(properties.readTimeoutMs()))
34+
.build();
35+
36+
var requestFactory = new JdkClientHttpRequestFactory(httpClient);
37+
requestFactory.setReadTimeout(Duration.ofMillis(properties.readTimeoutMs()));
38+
39+
this.restClient = RestClient.builder()
40+
.baseUrl(properties.baseUrl())
41+
.requestFactory(requestFactory)
42+
.build();
43+
}
44+
45+
@Override
46+
@Retry(name = "frankfurter", fallbackMethod = "frankfurterFallback")
47+
@CircuitBreaker(name = "frankfurter", fallbackMethod = "frankfurterFallback")
48+
public Optional<CorridorRate> getRate(String fromCurrency, String toCurrency) {
49+
log.info("[FRANKFURTER] Fetching rate for {}:{}", fromCurrency, toCurrency);
50+
51+
var response = restClient.get()
52+
.uri("/latest?from={from}&to={to}", fromCurrency, toCurrency)
53+
.retrieve()
54+
.body(FrankfurterRateResponse.class);
55+
56+
if (response == null || response.rates() == null || response.rates().isEmpty()) {
57+
log.warn("[FRANKFURTER] No rate returned for {}:{}", fromCurrency, toCurrency);
58+
return Optional.empty();
59+
}
60+
61+
var rate = response.rates().get(toCurrency);
62+
if (rate == null) {
63+
log.warn("[FRANKFURTER] Rate for {} not found in response for {}:{}", toCurrency, fromCurrency, toCurrency);
64+
return Optional.empty();
65+
}
66+
67+
var corridorRate = CorridorRate.builder()
68+
.fromCurrency(fromCurrency)
69+
.toCurrency(toCurrency)
70+
.rate(rate)
71+
.spreadBps(DEFAULT_SPREAD_BPS)
72+
.feeBps(DEFAULT_FEE_BPS)
73+
.provider("frankfurter")
74+
.ageMs(0)
75+
.build();
76+
77+
log.info("[FRANKFURTER] Rate fetched {}:{}={}", fromCurrency, toCurrency, rate);
78+
return Optional.of(corridorRate);
79+
}
80+
81+
@Override
82+
public String providerName() {
83+
return "frankfurter";
84+
}
85+
86+
@SuppressWarnings("unused")
87+
private Optional<CorridorRate> frankfurterFallback(String fromCurrency, String toCurrency, Exception ex) {
88+
log.error("[FRANKFURTER] Resilience fallback for {}:{} due to {}",
89+
fromCurrency, toCurrency, ex.getClass().getSimpleName(), ex);
90+
return Optional.empty();
91+
}
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.stablecoin.payments.fx.infrastructure.provider.frankfurter;
2+
3+
import java.math.BigDecimal;
4+
import java.util.Map;
5+
6+
record FrankfurterRateResponse(
7+
BigDecimal amount,
8+
String base,
9+
String date,
10+
Map<String, BigDecimal> rates
11+
) {}

fx-liquidity-engine/fx-liquidity-engine/src/main/resources/application.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ app:
127127
resilience4j:
128128
circuitbreaker:
129129
circuit-breaker-aspect-order: 1
130+
instances:
131+
frankfurter:
132+
slidingWindowSize: 10
133+
failureRateThreshold: 50
134+
waitDurationInOpenState: 30s
130135
retry:
131136
retry-aspect-order: 3
132137
instances:
@@ -144,6 +149,18 @@ resilience4j:
144149
ignore-exceptions:
145150
- org.springframework.web.client.HttpClientErrorException
146151
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
152+
frankfurter:
153+
maxAttempts: 3
154+
waitDuration: 1s
155+
retry-exceptions:
156+
- java.io.IOException
157+
- java.net.http.HttpTimeoutException
158+
- java.net.http.HttpConnectTimeoutException
159+
- org.springframework.web.client.ResourceAccessException
160+
- org.springframework.web.client.HttpServerErrorException
161+
ignore-exceptions:
162+
- org.springframework.web.client.HttpClientErrorException
163+
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
147164

148165
logging:
149166
level:

0 commit comments

Comments
 (0)