+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 5e58ae2a..7163d794 100644
--- a/README.md
+++ b/README.md
@@ -1,139 +1,274 @@
-# Allo Bank Backend Developer Take-Home Test
+# IDR Exchange Rate Aggregator API
+
+A Spring Boot REST API that aggregates Indonesian Rupiah (IDR) exchange rate data from the public [Frankfurter API](https://api.frankfurter.app), demonstrating the Strategy Pattern, FactoryBean, and ApplicationRunner startup ingestion.
+
+---
+
+## ๐ค Personalization Note
+
+| Field | Value |
+|---|---|
+| **GitHub Username** | `johndoe47` |
+| **ASCII Sum** | `j(106)+o(111)+h(104)+n(110)+d(100)+o(111)+e(101)+4(52)+7(55)` = **850** |
+| **Spread Factor** | `(850 % 1000) / 100_000.0` = **0.00850** |
+| **Formula** | `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + 0.00850)` |
+
+> **To use your own username**: change `frankfurter.github-username` in `application.yml`. The spread factor is recalculated automatically on startup.
+
+---
+
+## ๐๏ธ Project Structure
+
+```
+src/main/java/com/example/idrapi/
+โโโ IdrApiApplication.java # Entry point
+โโโ config/
+โ โโโ FrankfurterProperties.java # @ConfigurationProperties
+โ โโโ FrankfurterWebClientFactory.java# FactoryBean (Constraint B)
+โโโ controller/
+โ โโโ FinanceDataController.java # Single REST endpoint (zero if/else)
+โ โโโ GlobalExceptionHandler.java # @RestControllerAdvice
+โ โโโ ResourceNotFoundException.java # Custom 404 exception
+โโโ dto/
+โ โโโ LatestRatesResponse.java
+โ โโโ HistoricalRatesResponse.java
+โโโ model/
+โ โโโ FinanceDataResponse.java # Immutable record (Java 16+)
+โ โโโ ErrorResponse.java # Immutable error envelope
+โโโ runner/
+โ โโโ FinanceDataStartupRunner.java # ApplicationRunner (Constraint C)
+โโโ service/
+โ โโโ FinanceDataService.java # Strategy registry + orchestration
+โ โโโ FinanceDataStore.java # Thread-safe, sealable in-memory store
+โโโ strategy/
+ โโโ IDRDataFetcher.java # Strategy interface (Constraint A)
+ โโโ impl/
+ โโโ LatestIDRRatesFetcher.java # Strategy 1: /latest?base=IDR + spread
+ โโโ HistoricalIDRUSDFetcher.java# Strategy 2: time-series IDR/USD
+ โโโ SupportedCurrenciesFetcher.java # Strategy 3: /currencies
+```
+
+---
+
+## โ๏ธ Prerequisites
+
+- **Java 17+**
+- **Maven 3.8+**
+- Internet access to `api.frankfurter.app` (only needed at startup)
+
+---
+
+## ๐ Setup & Run
+
+### 1. Clone
+```bash
+git clone https://github.com//idr-api.git
+cd idr-api
+```
+
+### 2. Configure (optional)
+Edit `src/main/resources/application.yml` to change the GitHub username or date range:
+```yaml
+frankfurter:
+ base-url: https://api.frankfurter.app
+ github-username: johndoe47 # โ change to YOUR GitHub username
+ historical:
+ start-date: 2024-01-01
+ end-date: 2024-01-05
+```
+
+### 3. Build
+```bash
+mvn clean package -DskipTests
+```
+
+### 4. Run
+```bash
+mvn spring-boot:run
+# OR
+java -jar target/idr-api-1.0.0.jar
+```
+
+The application starts on **http://localhost:8080**.
+On startup, all three resources are fetched from Frankfurter and loaded into memory. The API is ready to serve immediately after the `ApplicationRunner` completes.
+
+---
+
+## ๐งช Run Tests
+
+```bash
+# All tests
+mvn test
+
+# Unit tests only
+mvn test -Dtest="*FetcherTest"
+
+# Integration tests only
+mvn test -Dtest="*IntegrationTest,*ControllerTest"
+```
+
+---
+
+## ๐ Endpoint Usage
+
+```
+GET /api/finance/data/{resourceType}
+```
+
+### Resource Types
+
+| `{resourceType}` | Description |
+|---|---|
+| `latest_idr_rates` | Latest exchange rates with base=IDR + USD buy spread |
+| `historical_idr_usd` | IDRโUSD daily rates from 2024-01-01 to 2024-01-05 |
+| `supported_currencies` | All currencies supported by Frankfurter API |
+
+---
+
+### cURL Examples
+
+#### 1. Latest IDR Rates (includes `USD_BuySpread_IDR`)
+```bash
+curl -X GET http://localhost:8080/api/finance/data/latest_idr_rates \
+ -H "Accept: application/json" | jq .
+```
+
+**Sample Response:**
+```json
+{
+ "resourceType": "latest_idr_rates",
+ "fetchedAt": "2024-01-05T08:00:00Z",
+ "results": [
+ {
+ "base": "IDR",
+ "date": "2024-01-05",
+ "rates": {
+ "USD": 0.000064,
+ "EUR": 0.000059,
+ "SGD": 0.000086
+ },
+ "spreadFactor": 0.0085,
+ "USD_BuySpread_IDR": 15687.23
+ }
+ ]
+}
+```
-Thank you for applying to our team! This take-home test is designed to evaluate your practical skills in building **production-ready** Spring Boot applications within a finance domain, focusing on architectural patterns and complex data handling.
+---
-## ๐ Objective
+#### 2. Historical IDR/USD Rates
+```bash
+curl -X GET http://localhost:8080/api/finance/data/historical_idr_usd \
+ -H "Accept: application/json" | jq .
+```
-Your task is to create a single Spring Boot REST API endpoint capable of aggregating data from multiple, distinct resources provided by the public, keyless **Frankfurter Exchange Rate API**. The primary focus is on handling Indonesian Rupiah (IDR) data.
+**Sample Response:**
+```json
+{
+ "resourceType": "historical_idr_usd",
+ "fetchedAt": "2024-01-05T08:00:00Z",
+ "results": [
+ { "date": "2024-01-02", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000064 },
+ { "date": "2024-01-03", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000065 },
+ { "date": "2024-01-04", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000063 },
+ { "date": "2024-01-05", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000066 }
+ ]
+}
+```
-The focus of this test is not just functional correctness, but demonstrating clean code, advanced Spring concepts, thread-safe design, and architectural clarity.
+---
-## I. Core Task: The Polymorphic API
+#### 3. Supported Currencies
+```bash
+curl -X GET http://localhost:8080/api/finance/data/supported_currencies \
+ -H "Accept: application/json" | jq .
+```
-### 1. External API Integration (Frankfurter API)
+**Sample Response:**
+```json
+{
+ "resourceType": "supported_currencies",
+ "fetchedAt": "2024-01-05T08:00:00Z",
+ "results": [
+ { "code": "USD", "name": "US Dollar" },
+ { "code": "EUR", "name": "Euro" },
+ { "code": "IDR", "name": "Indonesian Rupiah" }
+ ]
+}
+```
-* **Base URL (Public):** `https://api.frankfurter.app/`.
+---
-* You must integrate with three distinct data resources to enforce the architectural pattern:
+#### 4. Unknown Resource Type (404)
+```bash
+curl -X GET http://localhost:8080/api/finance/data/invalid_type \
+ -H "Accept: application/json" | jq .
+```
- 1. `/latest?base=IDR` (The latest rates relative to IDR)
+**Response:**
+```json
+{
+ "status": 404,
+ "error": "Not Found",
+ "message": "Resource type 'invalid_type' not found. Valid types: [latest_idr_rates, historical_idr_usd, supported_currencies]",
+ "path": "/api/finance/data/invalid_type",
+ "timestamp": "2024-01-05T08:00:01Z"
+}
+```
- 2. **Historical Data:** Query a specific, small time series (e.g., `/2024-01-01..2024-01-05?from=IDR&to=USD`). **Note:** *Use the date range provided in this example unless a different range is communicated separately.*
+---
- 3. `/currencies` (The list of all supported currency symbols)
+## ๐ ๏ธ Architectural Rationale
-### 2. Internal API Endpoint
+### Polymorphism: Why Strategy Pattern over if/else?
-You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}```
+The Strategy Pattern was chosen over a conditional block (`if/else` or `switch`) in the service layer for the following reasons:
-Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`.
+**Extensibility (Open/Closed Principle):** Adding a new `resourceType` (e.g., `idr_to_gbp`) requires only writing a new class that implements `IDRDataFetcher` and annotating it with `@Component`. The controller, service, and data store require **zero changes**. With a `switch` block, every new resource type means modifying existing, tested code โ increasing regression risk.
-### 3. Required Functionality & Business Logic
+**Maintainability:** Each concrete strategy class (`LatestIDRRatesFetcher`, `HistoricalIDRUSDFetcher`, `SupportedCurrenciesFetcher`) is a single-responsibility unit. It is independently readable, testable, and deployable. A monolithic conditional block mixes unrelated fetching and transformation logic in one place, making it harder to read and test.
+
+**Spring's Auto-Discovery:** Spring automatically discovers all `IDRDataFetcher` beans and injects them as a `List` into `FinanceDataService`. The service indexes them by `getResourceType()` key into a `Map`, enabling O(1) dispatch. This is idiomatic Spring โ no manual registry maintenance required.
-* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies.
+**Testability:** Each strategy can be unit-tested in complete isolation with a mocked `WebClient`, without starting a Spring context.
-* **Data Load:** All three resources should be fetched from the external API.
+---
-* **Data Transformation (Latest IDR Rates only) - Unique Calculation:** For the **`latest_idr_rates`** resource, you must calculate and include a new field, `"USD_BuySpread_IDR"`. This is the Rupiah selling rate to USD after applying a banking spread/margin.
+### Client Factory: Why FactoryBean over @Bean?
- **The Spread Factor Must Be Unique :**
+`FrankfurterWebClientFactory` implements `FactoryBean` rather than defining WebClient as a `@Bean` method in a `@Configuration` class. The key benefits:
- 1. **Input:** Your GitHub username (e.g., `johndoe47`).
- 2. **Calculation:** Calculate the sum of the Unicode (ASCII) values of all characters in your lowercase GitHub username string.
- 3. **Spread Factor Derivation:** `Spread Factor = (Sum of Unicode Values % 1000) / 100000.0`
- *(This will yield a unique factor between 0.00000 and 0.00999, ensuring a personalized result.)*
+**Encapsulation of Construction Logic:** The factory class is the sole owner of all WebClient construction concerns โ base URL, timeouts, shared headers, logging filters. A `@Bean` method typically lives inside a broader `@Configuration` class, diluting separation of concerns. The factory is a self-contained, single-purpose class.
- **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`).
+**Spring Lifecycle Hooks:** `FactoryBean` integrates with Spring's full lifecycle. `isSingleton()` guarantees a single shared `WebClient` instance. Future enhancements (e.g., `afterPropertiesSet()` validation, prototype scoping) are cleanly supported by the interface contract without retrofitting.
-* **Other Resources:** The `historical_idr_usd` and `supported_currencies` resources can return their data with minimal transformation, but the final output must be a unified JSON array of results.
+**Validation at Startup:** The factory can validate required properties (e.g., null `baseUrl`) in its constructor or `getObject()` method, causing a fast, clear startup failure rather than a cryptic NullPointerException at the first HTTP call.
-## II. Architectural Constraints
+**Clarity:** Any developer who sees `@Autowired WebClient webClient` in the strategies immediately knows there is a dedicated factory responsible for its construction โ making the codebase easier to navigate.
-Meeting the core task is only one part of the solution. The following constraints must be strictly adhered to and will be heavily weighted during evaluation:
+---
-### Constraint A: The Strategy Pattern
+### Startup Runner: Why ApplicationRunner over @PostConstruct?
-The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**.
+`FinanceDataStartupRunner` implements `ApplicationRunner` rather than using `@PostConstruct` on a service method. The key justifications:
-1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`).
+**Full Context Readiness:** `ApplicationRunner.run()` is invoked **after** the entire `ApplicationContext` is fully refreshed and all beans are wired and ready. `@PostConstruct` fires during the bean initialization phase โ before the context is fully ready. If `WebClient` (or any transitive dependency) has not completed initialization, outbound HTTP calls inside `@PostConstruct` can fail non-deterministically.
-2. Implement **three concrete strategy classes** (one for each resource).
+**Clean Test Isolation:** `ApplicationRunner` can be excluded from specific test slices (e.g., `@WebMvcTest`) without special configuration. `@PostConstruct` fires unconditionally whenever the annotated bean is created, making it harder to avoid in tests that don't need network calls.
-3. The main `Controller` should dynamically select the correct strategy implementation using a map-based lookup injected by Spring, avoiding any manual `if/else` or `switch` logic in the controller layer.
+**ApplicationArguments Support:** `ApplicationRunner` receives parsed `ApplicationArguments`, enabling future CLI-driven behavior (e.g., `--dry-run`, `--skip-prefetch`) without changing the runner's internal logic.
-### Constraint B: Client Factory Bean
+**Failure Propagation:** Exceptions thrown from `ApplicationRunner.run()` propagate through Spring Boot's startup mechanism, causing a clean application exit with a visible error. This prevents the application from starting in a silently broken, data-less state.
-The instance of your chosen external API client (`WebClient` or `RestTemplate`) **must be defined and created within a custom implementation of Spring's `FactoryBean` interface**.
+---
-* This `FactoryBean` should be responsible for externalizing the API Base URL via `@Value` or `@ConfigurationProperties` and applying any initial configuration (e.g., timeouts, shared headers).
+## ๐ฆ Technologies
-* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.***
-
-### Constraint C: Startup Data Runner & Immutability
-
-The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store.
-
-1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process.
-
-2. The API endpoint (`GET /api/finance/data/{resourceType}`) must serve the data from this **in-memory store**, not by making a new call to the external API on every request.
-
-3. The in-memory storage mechanism (e.g., a service holding the data) must be designed to be **thread-safe** and ensure the data is **immutable** once the `ApplicationRunner` has finished loading it.
-
-## III. Production Readiness & Deliverables
-
-Your final solution must demonstrate production quality through code, testing, and communication.
-
-### 1. Robustness & Best Practices
-
-* Graceful **Error Handling** for network failures or 4xx/5xx responses from the external API.
-
-* Proper use of **Configuration Properties** (e.g., `application.yml`) for external service URLs.
-
-* Clear separation of concerns (Controller, Service, Model/DTO, etc.).
-
-### 2. Testing
-
-* **Unit Tests** for all three `IDRDataFetcher` strategy implementations, ensuring data calculation and transformation logic is covered (using mock clients for external calls).
-
-* **Integration Tests** to verify the `ApplicationRunner` successfully initializes and loads the data into the in-memory store before the application context is ready.
-
-### 3. Documentation
-
-A clear `README.md` is mandatory. It must include:
-
-* **Setup/Run Instructions:** Clear steps to clone, build, and run the application and tests.
-
-* **Endpoint Usage:** Example cURL commands to test the three different resource types.
-
-* **Personalization Note:** Clearly state your GitHub username and show the exact **Spread Factor** (e.g., `0.00765`) calculated by your function.
-
-* ---
-
-* ### ๐ ๏ธ Architectural Rationale
-
- This section should contain a brief, but detailed, explanation answering the following questions:
-
- 1. **Polymorphism Justification:** Explain *why* the Strategy Pattern was used over a simpler conditional block in the service layer for handling the multi-resource endpoint. Discuss the benefits in terms of **extensibility** and **maintainability**.
-
- 2. **Client Factory:** Explain the specific role and benefit of using a **`FactoryBean`** to construct the external API client. Why is this preferable to defining the client using a standard `@Bean` method in this scenario?
-
- 3. **Startup Runner Choice:** Justify the choice of using an `ApplicationRunner` (or `CommandLineRunner`) for the initial data ingestion over a simpler `@PostConstruct` method.
-
-## IV. Submission & Review Process
-
-1. **Fork** this repository.
-
-2. Implement your solution on a dedicated feature branch (e.g., `feat/idr-rate-aggregator`).
-
-3. When complete, submit your solution via a **Pull Request (PR)** back to the main repository.
-4. Please complete the form to submit your technical test: [Click Here](https://forms.gle/nZKQ2EjTCPfAKHog7)
-
-**Your PR will be evaluated on the following:**
-
-* **Commit History:** Clean, atomic, and descriptive commit messages (e.g., "feat: Implement IDR latest rates strategy," "fix: Correctly calculate IDR spread in tests").
-
-* **PR Description:** The description must clearly summarize the solution and **must contain the full answers** to the three "Architectural Rationale" questions from Section III.
-
-* **Code Review Readiness:** The code should be well-structured and ready for immediate review.
-
-Good luck!
+| Technology | Version | Purpose |
+|---|---|---|
+| Spring Boot | 3.3.0 | Application framework |
+| Spring WebFlux / WebClient | 6.x | Reactive HTTP client |
+| Project Lombok | latest | Boilerplate reduction |
+| JUnit 5 + Mockito | latest | Unit & integration testing |
+| AssertJ | latest | Fluent test assertions |
+| Java Records | Java 17 | Immutable response models |
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 00000000..5e104442
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.0
+
+
+
+ com.example
+ idr-api
+ 1.0.0
+ idr-api
+ IDR Exchange Rate Aggregator API
+
+
+ 17
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
diff --git a/src/main/java/com/example/idrapi/IdrApiApplication.java b/src/main/java/com/example/idrapi/IdrApiApplication.java
new file mode 100644
index 00000000..a000e78f
--- /dev/null
+++ b/src/main/java/com/example/idrapi/IdrApiApplication.java
@@ -0,0 +1,13 @@
+package com.example.idrapi;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+@SpringBootApplication
+@EnableConfigurationProperties
+public class IdrApiApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(IdrApiApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/example/idrapi/config/FrankfurterProperties.java b/src/main/java/com/example/idrapi/config/FrankfurterProperties.java
new file mode 100644
index 00000000..e9095ac0
--- /dev/null
+++ b/src/main/java/com/example/idrapi/config/FrankfurterProperties.java
@@ -0,0 +1,33 @@
+package com.example.idrapi.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "frankfurter")
+public class FrankfurterProperties {
+
+ private String baseUrl;
+ private String githubUsername;
+ private Historical historical = new Historical();
+
+ public String getBaseUrl() { return baseUrl; }
+ public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
+
+ public String getGithubUsername() { return githubUsername; }
+ public void setGithubUsername(String githubUsername) { this.githubUsername = githubUsername; }
+
+ public Historical getHistorical() { return historical; }
+ public void setHistorical(Historical historical) { this.historical = historical; }
+
+ public static class Historical {
+ private String startDate;
+ private String endDate;
+
+ public String getStartDate() { return startDate; }
+ public void setStartDate(String startDate) { this.startDate = startDate; }
+
+ public String getEndDate() { return endDate; }
+ public void setEndDate(String endDate) { this.endDate = endDate; }
+ }
+}
diff --git a/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java b/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java
new file mode 100644
index 00000000..43ac86ef
--- /dev/null
+++ b/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java
@@ -0,0 +1,96 @@
+package com.example.idrapi.config;
+
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import reactor.netty.http.client.HttpClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Constraint B: FactoryBean implementation for WebClient.
+ *
+ * Using FactoryBean instead of a plain @Bean method gives us:
+ * - Full Spring lifecycle integration (afterPropertiesSet, isSingleton, etc.)
+ * - A dedicated, self-contained class that encapsulates ALL client construction
+ * logic, keeping @Configuration classes clean and focused on wiring.
+ * - The ability to validate required properties before the bean is returned,
+ * failing fast at startup rather than at first use.
+ */
+@Component("frankfurterWebClientFactory")
+public class FrankfurterWebClientFactory implements FactoryBean {
+
+ private static final Logger log = LoggerFactory.getLogger(FrankfurterWebClientFactory.class);
+
+ private static final int CONNECT_TIMEOUT_MS = 5_000;
+ private static final int READ_TIMEOUT_SEC = 10;
+ private static final int WRITE_TIMEOUT_SEC = 10;
+
+ private final FrankfurterProperties properties;
+
+ public FrankfurterWebClientFactory(FrankfurterProperties properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * Builds and returns a fully-configured, singleton WebClient instance.
+ * Called once by the Spring container.
+ */
+ @Override
+ public WebClient getObject() {
+ log.info("Building Frankfurter WebClient with base URL: {}", properties.getBaseUrl());
+
+ HttpClient httpClient = HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MS)
+ .responseTimeout(Duration.ofSeconds(READ_TIMEOUT_SEC))
+ .doOnConnected(conn -> conn
+ .addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT_SEC, TimeUnit.SECONDS))
+ .addHandlerLast(new WriteTimeoutHandler(WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)));
+
+ return WebClient.builder()
+ .baseUrl(properties.getBaseUrl())
+ .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .clientConnector(new ReactorClientHttpConnector(httpClient))
+ .filter(logRequest())
+ .filter(logResponse())
+ .build();
+ }
+
+ @Override
+ public Class> getObjectType() {
+ return WebClient.class;
+ }
+
+ /** Singleton: only one WebClient instance is created per application context. */
+ @Override
+ public boolean isSingleton() {
+ return true;
+ }
+
+ // ------------------------------------------------------------------ filters
+
+ private ExchangeFilterFunction logRequest() {
+ return ExchangeFilterFunction.ofRequestProcessor(request -> {
+ log.debug("HTTP Request: {} {}", request.method(), request.url());
+ return Mono.just(request);
+ });
+ }
+
+ private ExchangeFilterFunction logResponse() {
+ return ExchangeFilterFunction.ofResponseProcessor(response -> {
+ log.debug("HTTP Response status: {}", response.statusCode());
+ return Mono.just(response);
+ });
+ }
+}
diff --git a/src/main/java/com/example/idrapi/controller/FinanceDataController.java b/src/main/java/com/example/idrapi/controller/FinanceDataController.java
new file mode 100644
index 00000000..06a24e70
--- /dev/null
+++ b/src/main/java/com/example/idrapi/controller/FinanceDataController.java
@@ -0,0 +1,42 @@
+package com.example.idrapi.controller;
+
+import com.example.idrapi.model.FinanceDataResponse;
+import com.example.idrapi.service.FinanceDataService;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/finance/data")
+@Slf4j
+public class FinanceDataController {
+ private final FinanceDataService financeDataService;
+
+ public FinanceDataController(FinanceDataService financeDataService) {
+ this.financeDataService = financeDataService;
+ }
+
+ @GetMapping("/{resourceType}")
+ public ResponseEntity getFinanceData(
+ @PathVariable String resourceType) {
+
+ log.debug("ResourceType: '{}'", resourceType);
+
+ FinanceDataResponse response = financeDataService.getData(resourceType)
+ .orElseThrow(() -> new ResourceNotFoundException(
+ String.format(
+ "Resource type '%s' not found. Valid types: %s",
+ resourceType,
+ financeDataService.getRegisteredResourceTypes()
+ )
+ ));
+ log.info("response data class {} {}", financeDataService.getClass(), response);
+
+ return ResponseEntity.ok(response);
+ }
+}
diff --git a/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java b/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java
new file mode 100644
index 00000000..d0236933
--- /dev/null
+++ b/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java
@@ -0,0 +1,56 @@
+package com.example.idrapi.controller;
+
+import com.example.idrapi.model.ErrorResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.time.Instant;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleIllegalArgument(
+ IllegalArgumentException ex, HttpServletRequest request) {
+ log.warn("Bad request at {}: {}", request.getRequestURI(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+ .body(new ErrorResponse(400, "Bad Request", ex.getMessage(),
+ request.getRequestURI(), Instant.now()));
+ }
+
+ @ExceptionHandler(ResourceNotFoundException.class)
+ public ResponseEntity handleNotFound(
+ ResourceNotFoundException ex, HttpServletRequest request) {
+ log.warn("Resource not found at {}: {}", request.getRequestURI(), ex.getMessage());
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ErrorResponse(404, "Not Found", ex.getMessage(),
+ request.getRequestURI(), Instant.now()));
+ }
+
+ @ExceptionHandler(RuntimeException.class)
+ public ResponseEntity handleRuntimeException(
+ RuntimeException ex, HttpServletRequest request) {
+ log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse(500, "Internal Server Error",
+ "An unexpected error occurred. Please try again later.",
+ request.getRequestURI(), Instant.now()));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(
+ Exception ex, HttpServletRequest request) {
+ log.error("Unhandled exception at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(new ErrorResponse(500, "Internal Server Error",
+ "An unexpected error occurred.",
+ request.getRequestURI(), Instant.now()));
+ }
+}
diff --git a/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java b/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java
new file mode 100644
index 00000000..7b04362c
--- /dev/null
+++ b/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java
@@ -0,0 +1,8 @@
+package com.example.idrapi.controller;
+
+public class ResourceNotFoundException extends RuntimeException {
+
+ public ResourceNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java b/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java
new file mode 100644
index 00000000..1722a4be
--- /dev/null
+++ b/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java
@@ -0,0 +1,19 @@
+package com.example.idrapi.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HistoricalRatesResponse {
+
+ private String base;
+ private String startDate;
+ private String endDate;
+ private Map> rates; // date -> (currency -> rate)
+}
diff --git a/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java b/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java
new file mode 100644
index 00000000..c22cec4e
--- /dev/null
+++ b/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java
@@ -0,0 +1,19 @@
+package com.example.idrapi.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class LatestRatesResponse {
+
+ private String base;
+ private String date;
+ private Map rates = new HashMap<>();
+}
diff --git a/src/main/java/com/example/idrapi/model/ErrorResponse.java b/src/main/java/com/example/idrapi/model/ErrorResponse.java
new file mode 100644
index 00000000..d8556e02
--- /dev/null
+++ b/src/main/java/com/example/idrapi/model/ErrorResponse.java
@@ -0,0 +1,11 @@
+package com.example.idrapi.model;
+
+import java.time.Instant;
+
+public record ErrorResponse(
+ int status,
+ String error,
+ String message,
+ String path,
+ Instant timestamp
+) {}
diff --git a/src/main/java/com/example/idrapi/model/FinanceDataResponse.java b/src/main/java/com/example/idrapi/model/FinanceDataResponse.java
new file mode 100644
index 00000000..b1e61e5e
--- /dev/null
+++ b/src/main/java/com/example/idrapi/model/FinanceDataResponse.java
@@ -0,0 +1,25 @@
+package com.example.idrapi.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record FinanceDataResponse(
+ String resourceType,
+ Instant fetchedAt,
+ List