diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterProperties.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterProperties.java new file mode 100644 index 00000000..c01c25e8 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterProperties.java @@ -0,0 +1,18 @@ +package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.fx.frankfurter") +public record FrankfurterProperties( + String baseUrl, + Integer readTimeoutMs +) { + public FrankfurterProperties { + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "https://api.frankfurter.app"; + } + if (readTimeoutMs == null || readTimeoutMs <= 0) { + readTimeoutMs = 5000; + } + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapter.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapter.java new file mode 100644 index 00000000..4b41e017 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapter.java @@ -0,0 +1,92 @@ +package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; + +import com.stablecoin.payments.fx.domain.model.CorridorRate; +import com.stablecoin.payments.fx.domain.port.RateProvider; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@Component +@ConditionalOnProperty(name = "app.fx.rate-provider", havingValue = "frankfurter") +@EnableConfigurationProperties(FrankfurterProperties.class) +public class FrankfurterRateAdapter implements RateProvider { + + private static final int DEFAULT_SPREAD_BPS = 30; + private static final int DEFAULT_FEE_BPS = 30; + + private final RestClient restClient; + + public FrankfurterRateAdapter(FrankfurterProperties properties) { + var httpClient = HttpClient.newBuilder() + .version(Version.HTTP_1_1) + .connectTimeout(Duration.ofMillis(properties.readTimeoutMs())) + .build(); + + var requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofMillis(properties.readTimeoutMs())); + + this.restClient = RestClient.builder() + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + } + + @Override + @Retry(name = "frankfurter", fallbackMethod = "frankfurterFallback") + @CircuitBreaker(name = "frankfurter", fallbackMethod = "frankfurterFallback") + public Optional getRate(String fromCurrency, String toCurrency) { + log.info("[FRANKFURTER] Fetching rate for {}:{}", fromCurrency, toCurrency); + + var response = restClient.get() + .uri("/latest?from={from}&to={to}", fromCurrency, toCurrency) + .retrieve() + .body(FrankfurterRateResponse.class); + + if (response == null || response.rates() == null || response.rates().isEmpty()) { + log.warn("[FRANKFURTER] No rate returned for {}:{}", fromCurrency, toCurrency); + return Optional.empty(); + } + + var rate = response.rates().get(toCurrency); + if (rate == null) { + log.warn("[FRANKFURTER] Rate for {} not found in response for {}:{}", toCurrency, fromCurrency, toCurrency); + return Optional.empty(); + } + + var corridorRate = CorridorRate.builder() + .fromCurrency(fromCurrency) + .toCurrency(toCurrency) + .rate(rate) + .spreadBps(DEFAULT_SPREAD_BPS) + .feeBps(DEFAULT_FEE_BPS) + .provider("frankfurter") + .ageMs(0) + .build(); + + log.info("[FRANKFURTER] Rate fetched {}:{}={}", fromCurrency, toCurrency, rate); + return Optional.of(corridorRate); + } + + @Override + public String providerName() { + return "frankfurter"; + } + + @SuppressWarnings("unused") + private Optional frankfurterFallback(String fromCurrency, String toCurrency, Exception ex) { + log.error("[FRANKFURTER] Resilience fallback for {}:{} due to {}", + fromCurrency, toCurrency, ex.getClass().getSimpleName(), ex); + return Optional.empty(); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateResponse.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateResponse.java new file mode 100644 index 00000000..e074017b --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateResponse.java @@ -0,0 +1,11 @@ +package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; + +import java.math.BigDecimal; +import java.util.Map; + +record FrankfurterRateResponse( + BigDecimal amount, + String base, + String date, + Map rates +) {} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/resources/application.yml b/fx-liquidity-engine/fx-liquidity-engine/src/main/resources/application.yml index 8415898c..ad53e06f 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/resources/application.yml +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/resources/application.yml @@ -127,6 +127,11 @@ app: resilience4j: circuitbreaker: circuit-breaker-aspect-order: 1 + instances: + frankfurter: + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 30s retry: retry-aspect-order: 3 instances: @@ -144,6 +149,18 @@ resilience4j: ignore-exceptions: - org.springframework.web.client.HttpClientErrorException - io.github.resilience4j.circuitbreaker.CallNotPermittedException + frankfurter: + maxAttempts: 3 + waitDuration: 1s + retry-exceptions: + - java.io.IOException + - java.net.http.HttpTimeoutException + - java.net.http.HttpConnectTimeoutException + - org.springframework.web.client.ResourceAccessException + - org.springframework.web.client.HttpServerErrorException + ignore-exceptions: + - org.springframework.web.client.HttpClientErrorException + - io.github.resilience4j.circuitbreaker.CallNotPermittedException logging: level: diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapterTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapterTest.java new file mode 100644 index 00000000..f1f8a5f7 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapterTest.java @@ -0,0 +1,245 @@ +package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.stablecoin.payments.fx.domain.model.CorridorRate; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FrankfurterRateAdapterTest { + + private static WireMockServer wireMock; + private FrankfurterRateAdapter adapter; + + @BeforeAll + static void startWireMock() { + wireMock = new WireMockServer(wireMockConfig().dynamicPort()); + wireMock.start(); + } + + @AfterAll + static void stopWireMock() { + wireMock.stop(); + } + + @BeforeEach + void setUp() { + wireMock.resetAll(); + var properties = new FrankfurterProperties(wireMock.baseUrl(), 2000); + adapter = new FrankfurterRateAdapter(properties); + } + + @Nested + @DisplayName("Successful rate fetch") + class SuccessfulFetch { + + @Test + @DisplayName("should return rate for valid USD to EUR pair") + void shouldReturnRateForValidCurrencyPair() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=EUR")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "amount": 1.0, + "base": "USD", + "date": "2026-03-17", + "rates": { + "EUR": 0.86723 + } + } + """))); + + var result = adapter.getRate("USD", "EUR"); + + assertThat(result).isPresent(); + var expected = CorridorRate.builder() + .fromCurrency("USD") + .toCurrency("EUR") + .rate(new BigDecimal("0.86723")) + .spreadBps(30) + .feeBps(30) + .provider("frankfurter") + .ageMs(0) + .build(); + assertThat(result.get()) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .isEqualTo(expected); + + wireMock.verify(1, getRequestedFor(urlEqualTo("/latest?from=USD&to=EUR"))); + } + + @Test + @DisplayName("should return rate for EUR to USD pair") + void shouldReturnRateForEurToUsd() { + wireMock.stubFor(get(urlEqualTo("/latest?from=EUR&to=USD")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "amount": 1.0, + "base": "EUR", + "date": "2026-03-17", + "rates": { + "USD": 1.1531 + } + } + """))); + + var result = adapter.getRate("EUR", "USD"); + + assertThat(result).isPresent(); + var expected = CorridorRate.builder() + .fromCurrency("EUR") + .toCurrency("USD") + .rate(new BigDecimal("1.1531")) + .spreadBps(30) + .feeBps(30) + .provider("frankfurter") + .ageMs(0) + .build(); + assertThat(result.get()) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("Empty or missing rates") + class EmptyRates { + + @Test + @DisplayName("should return empty when rates map is empty") + void shouldReturnEmptyWhenRatesMapIsEmpty() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=XYZ")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "amount": 1.0, + "base": "USD", + "date": "2026-03-17", + "rates": {} + } + """))); + + var result = adapter.getRate("USD", "XYZ"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should return empty when target currency not in rates") + void shouldReturnEmptyWhenTargetCurrencyNotInRates() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=GBP")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "amount": 1.0, + "base": "USD", + "date": "2026-03-17", + "rates": { + "EUR": 0.86723 + } + } + """))); + + var result = adapter.getRate("USD", "GBP"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should return empty when response body is empty JSON") + void shouldReturnEmptyWhenResponseBodyIsEmptyJson() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=EUR")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{}"))); + + var result = adapter.getRate("USD", "EUR"); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Error handling") + class ErrorHandling { + + @Test + @DisplayName("should throw on server error 500") + void shouldThrowOnServerError() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=EUR")) + .willReturn(aResponse().withStatus(500))); + + assertThatThrownBy(() -> adapter.getRate("USD", "EUR")) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("should throw on client error 404") + void shouldThrowOnClientError() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=EUR")) + .willReturn(aResponse().withStatus(404))); + + assertThatThrownBy(() -> adapter.getRate("USD", "EUR")) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("should throw on read timeout") + void shouldThrowOnReadTimeout() { + wireMock.stubFor(get(urlEqualTo("/latest?from=USD&to=EUR")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withFixedDelay(5000) + .withBody(""" + { + "amount": 1.0, + "base": "USD", + "date": "2026-03-17", + "rates": { + "EUR": 0.86723 + } + } + """))); + + assertThatThrownBy(() -> adapter.getRate("USD", "EUR")) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("Provider name") + class ProviderName { + + @Test + @DisplayName("should return frankfurter as provider name") + void shouldReturnProviderName() { + assertThat(adapter.providerName()).isEqualTo("frankfurter"); + } + } +}