diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java
index e76d1f3..a45eb6b 100644
--- a/.mvn/wrapper/MavenWrapperDownloader.java
+++ b/.mvn/wrapper/MavenWrapperDownloader.java
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import java.net.*;
import java.io.*;
import java.nio.channels.*;
@@ -25,7 +26,7 @@ public class MavenWrapperDownloader {
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
*/
private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
- + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
+ + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
/**
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
@@ -54,7 +55,7 @@ public static void main(String args[]) {
// wrapperUrl parameter.
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
String url = DEFAULT_DOWNLOAD_URL;
- if(mavenWrapperPropertyFile.exists()) {
+ if (mavenWrapperPropertyFile.exists()) {
FileInputStream mavenWrapperPropertyFileInputStream = null;
try {
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
@@ -65,7 +66,7 @@ public static void main(String args[]) {
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
} finally {
try {
- if(mavenWrapperPropertyFileInputStream != null) {
+ if (mavenWrapperPropertyFileInputStream != null) {
mavenWrapperPropertyFileInputStream.close();
}
} catch (IOException e) {
@@ -76,8 +77,8 @@ public static void main(String args[]) {
System.out.println("- Downloading from: " + url);
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
- if(!outputFile.getParentFile().exists()) {
- if(!outputFile.getParentFile().mkdirs()) {
+ if (!outputFile.getParentFile().exists()) {
+ if (!outputFile.getParentFile().mkdirs()) {
System.out.println(
"- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
}
diff --git a/pom.xml b/pom.xml
index b3c1148..7bdf8a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,49 +1,74 @@
- 4.0.0
-
- org.springframework.boot
- spring-boot-starter-parent
- 2.4.1
-
-
- com.crewmeister
- cm-coding-challenge
- 0.0.1-SNAPSHOT
- cm-coding-challenge
- The java coding challenge for developer candidates applying at Crewmeister
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.4.1
+
+
+ com.crewmeister
+ cm-coding-challenge
+ 0.0.1-SNAPSHOT
+ cm-coding-challenge
+ The java coding challenge for developer candidates applying at Crewmeister
-
- 11
-
+
+ 11
+ 1.18.24
+ 2.11.1
+
-
-
- org.springframework.boot
- spring-boot-starter-web
-
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.retry
+ spring-retry
+
+
+ org.springframework
+ spring-aspects
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-xml
+ ${jacson.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jacson.version}
+
-
- org.springframework.boot
- spring-boot-devtools
- runtime
- true
-
-
- org.springframework.boot
- spring-boot-starter-test
- test
-
-
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
-
-
-
- org.springframework.boot
- spring-boot-maven-plugin
-
-
-
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplication.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/CmCodingChallengeApplication.java
similarity index 52%
rename from src/main/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplication.java
rename to src/main/java/com/crewmeister/cmcodingchallenge/currency/CmCodingChallengeApplication.java
index 409a877..4f09ffc 100644
--- a/src/main/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplication.java
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/CmCodingChallengeApplication.java
@@ -1,13 +1,11 @@
-package com.crewmeister.cmcodingchallenge;
+package com.crewmeister.cmcodingchallenge.currency;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CmCodingChallengeApplication {
-
- public static void main(String[] args) {
- SpringApplication.run(CmCodingChallengeApplication.class, args);
- }
-
+ public static void main(String[] args) {
+ SpringApplication.run(CmCodingChallengeApplication.class, args);
+ }
}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyController.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyController.java
deleted file mode 100644
index d69be89..0000000
--- a/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyController.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.crewmeister.cmcodingchallenge.currency;
-
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-import java.util.ArrayList;
-
-@RestController()
-@RequestMapping("/api")
-public class CurrencyController {
-
- @GetMapping("/currencies")
- public ResponseEntity> getCurrencies() {
- ArrayList currencyConversionRates = new ArrayList();
- currencyConversionRates.add(new CurrencyConversionRates(2.5));
-
- return new ResponseEntity>(currencyConversionRates, HttpStatus.OK);
- }
-}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyConversionRates.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyConversionRates.java
deleted file mode 100644
index d3bf6e5..0000000
--- a/src/main/java/com/crewmeister/cmcodingchallenge/currency/CurrencyConversionRates.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.crewmeister.cmcodingchallenge.currency;
-
-public class CurrencyConversionRates {
- private double conversionRate;
-
- public CurrencyConversionRates(double conversionRate) {
- this.conversionRate = conversionRate;
- }
-
- public double getConversionRate() {
- return conversionRate;
- }
-
- public void setConversionRate(double conversionRate) {
- this.conversionRate = conversionRate;
- }
-}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/config/RestTemplateConfiguration.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/config/RestTemplateConfiguration.java
new file mode 100644
index 0000000..8198d23
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/config/RestTemplateConfiguration.java
@@ -0,0 +1,27 @@
+package com.crewmeister.cmcodingchallenge.currency.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.annotation.EnableRetry;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
+
+@Configuration
+@EnableRetry
+public class RestTemplateConfiguration {
+
+ @Value("${external.fx.timeout}")
+ private int timeout;
+
+ @Bean
+ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
+ return restTemplateBuilder
+ .defaultMessageConverters()
+ .setConnectTimeout(Duration.ofMillis(timeout))
+ .setReadTimeout(Duration.ofMillis(timeout))
+ .build();
+ }
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/constants/Currency.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/constants/Currency.java
new file mode 100644
index 0000000..a301eac
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/constants/Currency.java
@@ -0,0 +1,59 @@
+package com.crewmeister.cmcodingchallenge.currency.constants;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+public enum Currency {
+ AUD("AUD"),
+ BGN("BGN"),
+ BRL("BRL"),
+ CAD("CAD"),
+ CHF("CHF"),
+ CNY("CNY"),
+ CYP("CYP"),
+ CZK("CZK"),
+ DKK("DKK"),
+ EEK("EEK"),
+ GBP("GBP"),
+ GRD("GRD"),
+ HKD("HKD"),
+ HRK("HRK"),
+ HUF("HUF"),
+ IDR("IDR"),
+ ILS("ILS"),
+ INR("INR"),
+ ISK("ISK"),
+ JPY("JPY"),
+ KRW("KRW"),
+ LTL("LTL"),
+ LVL("LVL"),
+ MTL("MTL"),
+ MXN("MXN"),
+ MYR("MYR"),
+ NOK("NOK"),
+ NZD("NZD"),
+ PHP("PHP"),
+ PLN("PLN"),
+ ROL("ROL"),
+ RON("RON"),
+ RUB("RUB"),
+ SEK("SEK"),
+ SGD("SGD"),
+ SIT("SIT"),
+ SKK("SKK"),
+ THB("THB"),
+ TRL("TRL"),
+ TRY("TRY"),
+ USD("USD"),
+ ZAR("ZAR");
+ private final String isoCode;
+
+ Currency(String isoCode) {
+ this.isoCode = isoCode;
+ }
+
+ public static Optional getCurrency(String currencyCode) {
+ return Arrays.stream(values())
+ .filter(code -> code.isoCode.equalsIgnoreCase(currencyCode)).findFirst();
+ }
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/controller/CurrencyController.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/controller/CurrencyController.java
new file mode 100644
index 0000000..0ab742d
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/controller/CurrencyController.java
@@ -0,0 +1,70 @@
+package com.crewmeister.cmcodingchallenge.currency.controller;
+
+import com.crewmeister.cmcodingchallenge.currency.constants.Currency;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyConversionRates;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyResponse;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.ExchangedAmount;
+import com.crewmeister.cmcodingchallenge.currency.exception.CurrencyNotSupportedException;
+import com.crewmeister.cmcodingchallenge.currency.exception.RateNotAvailableException;
+import com.crewmeister.cmcodingchallenge.currency.service.FXRateService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController()
+@RequestMapping("/api")
+public class CurrencyController {
+
+ @Autowired
+ private FXRateService fxRateService;
+
+ @GetMapping("/currencies")
+ public ResponseEntity getCurrencies() {
+ return new ResponseEntity<>(new CurrencyResponse(Currency.values()), HttpStatus.OK);
+ }
+
+ @GetMapping("/exchange-rates")
+ public ResponseEntity getExchangeRates(
+ @RequestParam() String currencyCode,
+ @RequestParam(required = false) String date
+ ) {
+ CurrencyConversionRates rates = null;
+ try {
+ rates = fxRateService.getFXRates(currencyCode, date);
+ } catch (CurrencyNotSupportedException e) {
+ handleCurrencyNotSupportedException();
+ }
+ return new ResponseEntity<>(rates, HttpStatus.OK);
+ }
+
+ @GetMapping("/exchange-currency")
+ public ResponseEntity getCurrencyExchange(
+ @RequestParam() String currencyCode,
+ @RequestParam() String amount,
+ @RequestParam(required = false) String date
+
+ ) {
+ ExchangedAmount exchangedAmount = null;
+ try {
+ exchangedAmount = fxRateService.getFXValue(currencyCode, amount, date);
+ } catch (CurrencyNotSupportedException e) {
+ handleCurrencyNotSupportedException();
+ } catch (RateNotAvailableException e) {
+ handleRateNotAvailableException();
+ }
+ return new ResponseEntity<>(exchangedAmount, HttpStatus.OK);
+ }
+
+ private void handleCurrencyNotSupportedException() {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Currency Not Supported");
+ }
+
+ private void handleRateNotAvailableException() {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Rate Not Available for the specified date");
+ }
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/SchemaTransformer.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/SchemaTransformer.java
new file mode 100644
index 0000000..91c3d6a
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/SchemaTransformer.java
@@ -0,0 +1,55 @@
+package com.crewmeister.cmcodingchallenge.currency.domain;
+
+import com.crewmeister.cmcodingchallenge.currency.constants.Currency;
+import com.crewmeister.cmcodingchallenge.currency.domain.external.*;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.ConversionRate;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyConversionRates;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class SchemaTransformer {
+ private static final String BBK_STD_CURRENCY = "BBK_STD_CURRENCY";
+
+ public static CurrencyConversionRates transform(GenericData genericData) {
+ var currencyConversionRates = new CurrencyConversionRates(new ArrayList<>());
+ var series = extractSeries(genericData);
+ var obsList = extractObsList(series);
+ if (!obsList.isEmpty()) {
+ currencyConversionRates.getConversionRateList().addAll(
+ obsList.stream().map(e -> constructObject(series, e)).collect(Collectors.toList()));
+ }
+ return currencyConversionRates;
+ }
+
+ private static Series extractSeries(GenericData genericData) {
+ Optional dataset = Optional
+ .ofNullable(genericData)
+ .map(o -> Optional.ofNullable(o.getDataSet()).orElse(DataSet.builder().build()));
+
+ Optional series = Optional.empty();
+ if (dataset.isPresent()) {
+ series = dataset.map(o -> Optional.ofNullable(o.getSeries())).get();
+ }
+ return series.orElse(Series.builder().build());
+ }
+
+ private static String extractSeriesValue(List valueList, String key) {
+ return valueList.stream().filter(v -> key.equals(v.getId())).findFirst().orElse(new Value()).getValue();
+ }
+
+ private static List extractObsList(Series series) {
+ return Optional.ofNullable(series)
+ .map(o -> Optional.ofNullable(o.getObsList()).orElse(new ArrayList<>()))
+ .orElse(new ArrayList<>());
+ }
+
+ private static ConversionRate constructObject(Series series, Obs e) {
+ return new ConversionRate(
+ Optional.ofNullable(e.getObsValue()).orElse(new ObsValue("No value available")).getValue(),
+ Optional.ofNullable(e.getObsDimension()).orElse(new ObsDimension()).getValue(),
+ Currency.valueOf(extractSeriesValue(series.getSeriesKey().getValueList(), BBK_STD_CURRENCY)));
+ }
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/DataSet.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/DataSet.java
new file mode 100644
index 0000000..0ef44b9
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/DataSet.java
@@ -0,0 +1,21 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.jackson.Jacksonized;
+
+@Getter
+@Setter
+@Builder
+@Jacksonized
+public class DataSet {
+ private String structureRef;
+ private String setID;
+ private String action;
+ private String validFromDate;
+
+ @JacksonXmlProperty(localName = "Series")
+ Series series;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/GenericData.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/GenericData.java
new file mode 100644
index 0000000..2de5fdd
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/GenericData.java
@@ -0,0 +1,21 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import lombok.Builder;
+import lombok.Data;
+import lombok.extern.jackson.Jacksonized;
+
+@Data
+@Builder
+@JacksonXmlRootElement(localName = "GenericData")
+@Jacksonized
+public class GenericData {
+ private String schemaLocation;
+
+ @JacksonXmlProperty(localName = "Header")
+ private Header header;
+
+ @JacksonXmlProperty(localName = "DataSet")
+ private DataSet dataSet;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Header.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Header.java
new file mode 100644
index 0000000..edf65f0
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Header.java
@@ -0,0 +1,10 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class Header {
+ @JsonProperty("ID")
+ private String id;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Obs.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Obs.java
new file mode 100644
index 0000000..6c9cc1a
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Obs.java
@@ -0,0 +1,21 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class Obs {
+ @JacksonXmlProperty(localName = "ObsDimension")
+ private ObsDimension obsDimension;
+
+ @JacksonXmlProperty(localName = "ObsValue")
+ private ObsValue obsValue;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsDimension.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsDimension.java
new file mode 100644
index 0000000..edcaf68
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsDimension.java
@@ -0,0 +1,14 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class ObsDimension {
+ private String value;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsValue.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsValue.java
new file mode 100644
index 0000000..56573e8
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/ObsValue.java
@@ -0,0 +1,14 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class ObsValue {
+ private String value;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Series.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Series.java
new file mode 100644
index 0000000..07ec852
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Series.java
@@ -0,0 +1,25 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.jackson.Jacksonized;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@Getter
+@Setter
+@Builder
+@Jacksonized
+public class Series {
+ @JacksonXmlProperty(localName = "SeriesKey")
+ private SeriesKey seriesKey;
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "Obs")
+ private List obsList;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/SeriesKey.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/SeriesKey.java
new file mode 100644
index 0000000..3f3281a
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/SeriesKey.java
@@ -0,0 +1,20 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.jackson.Jacksonized;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Builder
+@Jacksonized
+public class SeriesKey {
+ @JacksonXmlElementWrapper(useWrapping = false)
+ @JacksonXmlProperty(localName = "Value")
+ private List valueList;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Value.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Value.java
new file mode 100644
index 0000000..0cd48db
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/external/Value.java
@@ -0,0 +1,18 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.external;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class Value {
+ @JacksonXmlProperty(localName = "id")
+ private String id;
+ @JacksonXmlProperty(localName = "value")
+ private String value;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ConversionRate.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ConversionRate.java
new file mode 100644
index 0000000..0ed3c97
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ConversionRate.java
@@ -0,0 +1,15 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.internal;
+
+import com.crewmeister.cmcodingchallenge.currency.constants.Currency;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class ConversionRate {
+ private String rate;
+ private String date;
+ private Currency currency;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyConversionRates.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyConversionRates.java
new file mode 100644
index 0000000..614a22a
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyConversionRates.java
@@ -0,0 +1,16 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.internal;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class CurrencyConversionRates {
+ private List conversionRateList;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyResponse.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyResponse.java
new file mode 100644
index 0000000..fe05d0e
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/CurrencyResponse.java
@@ -0,0 +1,16 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.internal;
+
+import com.crewmeister.cmcodingchallenge.currency.constants.Currency;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class CurrencyResponse {
+ public CurrencyResponse() {
+ }
+
+ private Currency[] currencies;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ExchangedAmount.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ExchangedAmount.java
new file mode 100644
index 0000000..b25125d
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/domain/internal/ExchangedAmount.java
@@ -0,0 +1,11 @@
+package com.crewmeister.cmcodingchallenge.currency.domain.internal;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class ExchangedAmount {
+ private String amount;
+ private ConversionRate conversionRate;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/CurrencyNotSupportedException.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/CurrencyNotSupportedException.java
new file mode 100644
index 0000000..a983c43
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/CurrencyNotSupportedException.java
@@ -0,0 +1,10 @@
+package com.crewmeister.cmcodingchallenge.currency.exception;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class CurrencyNotSupportedException extends Exception {
+ private String isoCode;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/RateNotAvailableException.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/RateNotAvailableException.java
new file mode 100644
index 0000000..ee056b5
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/exception/RateNotAvailableException.java
@@ -0,0 +1,10 @@
+package com.crewmeister.cmcodingchallenge.currency.exception;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class RateNotAvailableException extends Exception {
+ private String date;
+}
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/service/FXRateService.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/service/FXRateService.java
new file mode 100644
index 0000000..2dd35a2
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/service/FXRateService.java
@@ -0,0 +1,93 @@
+package com.crewmeister.cmcodingchallenge.currency.service;
+
+import com.crewmeister.cmcodingchallenge.currency.constants.Currency;
+import com.crewmeister.cmcodingchallenge.currency.domain.SchemaTransformer;
+import com.crewmeister.cmcodingchallenge.currency.domain.external.GenericData;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.ConversionRate;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyConversionRates;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.ExchangedAmount;
+import com.crewmeister.cmcodingchallenge.currency.exception.CurrencyNotSupportedException;
+import com.crewmeister.cmcodingchallenge.currency.exception.RateNotAvailableException;
+import com.crewmeister.cmcodingchallenge.currency.util.RestUtil;
+import lombok.extern.slf4j.Slf4j;
+import lombok.val;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.retry.annotation.Backoff;
+import org.springframework.retry.annotation.Retryable;
+import org.springframework.stereotype.Service;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.SocketTimeoutException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Optional;
+
+@Slf4j
+@Service
+public class FXRateService {
+
+ @Value("${external.fx.scheme}")
+ private String fxScheme;
+
+ @Value("${external.fx.host}")
+ private String fxHost;
+
+ @Autowired
+ private RestUtil restUtil;
+
+ private static final String TIME_SERIES = "BBEX3/D.XXX.EUR.BB.AC.000";
+
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd");
+
+ @Retryable(value = SocketTimeoutException.class, maxAttemptsExpression = "${external.retry.maxAttempts}",
+ backoff = @Backoff(delayExpression = "${external.retry.maxDelay}"))
+ public CurrencyConversionRates getFXRates(String currencyCode, String date) throws CurrencyNotSupportedException {
+ validateSupportedCurrency(currencyCode);
+ UriComponentsBuilder uriComponents = UriComponentsBuilder.newInstance()
+ .scheme(fxScheme).host(fxHost)
+ .path(TIME_SERIES.replace("XXX", currencyCode))
+ .queryParam("detail", "dataonly");
+
+ if (date != null) {
+ uriComponents.queryParam("startPeriod", date)
+ .queryParam("endPeriod", date);
+ }
+ var resp = restUtil.getForObject(uriComponents.build().toString(), GenericData.class);
+ return SchemaTransformer.transform(resp);
+ }
+
+ public ExchangedAmount getFXValue(String currencyCode, String amount, String date)
+ throws CurrencyNotSupportedException, RateNotAvailableException {
+ Optional currencyConversionRate =
+ getFXRates(currencyCode, date == null ? getCurrentDate() : date).getConversionRateList().stream().findFirst();
+ var exchangedAmount = new ExchangedAmount();
+ if (currencyConversionRate.isPresent()) {
+ try {
+ double conversionRate = Double.parseDouble(currencyConversionRate.get().getRate());
+ exchangedAmount.setConversionRate(currencyConversionRate.get());
+ exchangedAmount.setAmount(String.valueOf(conversionRate * Double.parseDouble(amount)));
+ } catch (NumberFormatException nfe) {
+ throw new RateNotAvailableException(date);
+ }
+ }
+ return exchangedAmount;
+ }
+ // usd against euro -> 0.8 -> 0.8 * 100 -> 80 euro
+ private void validateSupportedCurrency(String currencyCode) throws CurrencyNotSupportedException {
+ val currencyStream = Arrays.stream(Currency.values());
+ if (Currency.getCurrency(currencyCode).isEmpty()) {
+ throw new CurrencyNotSupportedException(currencyCode);
+ }
+ }
+
+ private String getCurrentDate() {
+ return DATE_TIME_FORMATTER.format(LocalDate.now());
+ }
+}
+
+
+// user -> service -> 3rd service - 1st aattmep X
+ // -> 3rd service - 2nd attempt X
+ // <- rate not available responmse
\ No newline at end of file
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/RestUtil.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/RestUtil.java
new file mode 100644
index 0000000..5f2e4d6
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/RestUtil.java
@@ -0,0 +1,19 @@
+package com.crewmeister.cmcodingchallenge.currency.util;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+@Slf4j
+@Component
+public class RestUtil {
+ @Autowired
+ private RestTemplate restTemplate;
+
+ public T getForObject(String url, Class typeC) {
+ log.info("invoking:" + url);
+ return restTemplate.getForObject(url, typeC);
+ }
+}
+
diff --git a/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/XMLUtil.java b/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/XMLUtil.java
new file mode 100644
index 0000000..fa5d574
--- /dev/null
+++ b/src/main/java/com/crewmeister/cmcodingchallenge/currency/util/XMLUtil.java
@@ -0,0 +1,13 @@
+package com.crewmeister.cmcodingchallenge.currency.util;
+
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+
+import java.io.IOException;
+
+public class XMLUtil {
+ private static final XmlMapper xmlMapper = new XmlMapper();
+
+ public static T parseData(String xmlData, Class typeC) throws IOException {
+ return xmlMapper.readValue(xmlData, typeC);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 8b13789..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
new file mode 100644
index 0000000..44207ea
--- /dev/null
+++ b/src/main/resources/application.yaml
@@ -0,0 +1,14 @@
+server:
+ application:
+ name: FX Rate API
+ error:
+ include-stacktrace: NEVER
+
+external:
+ fx:
+ scheme: https
+ host: api.statistiken.bundesbank.de/rest/data
+ timeout: 500
+ retry:
+ maxAttempts: 2
+ maxDelay: 2000
\ No newline at end of file
diff --git a/src/test/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplicationTests.java b/src/test/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplicationTests.java
deleted file mode 100644
index 0ca0d16..0000000
--- a/src/test/java/com/crewmeister/cmcodingchallenge/CmCodingChallengeApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.crewmeister.cmcodingchallenge;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class CmCodingChallengeApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/CmCodingChallengeApplicationTests.java b/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/CmCodingChallengeApplicationTests.java
new file mode 100644
index 0000000..99d57b9
--- /dev/null
+++ b/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/CmCodingChallengeApplicationTests.java
@@ -0,0 +1,19 @@
+package com.crewmeister.cmcodingchallenge.currency.integration;
+
+import com.crewmeister.cmcodingchallenge.currency.controller.CurrencyController;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.ApplicationContext;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@SpringBootTest
+class CmCodingChallengeApplicationTests {
+
+ @Test
+ void contextLoads(ApplicationContext context) {
+ assertNotNull(context);
+ assertNotNull(context.getBean(CurrencyController.class));
+ }
+
+}
diff --git a/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/TestCurrencyController.java b/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/TestCurrencyController.java
new file mode 100644
index 0000000..e601f49
--- /dev/null
+++ b/src/test/java/com/crewmeister/cmcodingchallenge/currency/integration/TestCurrencyController.java
@@ -0,0 +1,101 @@
+package com.crewmeister.cmcodingchallenge.currency.integration;
+
+import com.crewmeister.cmcodingchallenge.currency.domain.external.*;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyConversionRates;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.CurrencyResponse;
+import com.crewmeister.cmcodingchallenge.currency.domain.internal.ExchangedAmount;
+import com.crewmeister.cmcodingchallenge.currency.util.RestUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+
+import java.util.List;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class TestCurrencyController {
+
+ @Value(value = "${local.server.port}")
+ private int port;
+
+ @Autowired
+ private TestRestTemplate testRestTemplate;
+
+ @MockBean
+ private RestUtil restUtil;
+
+ @Test
+ public void testGetCurrenciesSuccess() {
+ var response = this.testRestTemplate.getForEntity(
+ "http://localhost:" + port + "/api/currencies",
+ CurrencyResponse.class);
+
+ assertEquals(response.getStatusCode(), HttpStatus.OK);
+ assertEquals(42, Objects.requireNonNull(response.getBody()).getCurrencies().length);
+ }
+
+ @Test
+ public void testGetExchangeRateForDateSuccess() {
+ String targetUrl = "https://api.statistiken.bundesbank.de/rest/data/BBEX3/D.CHF.EUR.BB.AC.000?detail=dataonly&startPeriod=2023-01-29&endPeriod=2023-01-29";
+
+ Mockito.when(restUtil.getForObject(targetUrl, GenericData.class)).thenReturn(createDummyResponse("2023-01-24", "1.4517"));
+ var response = this.testRestTemplate.getForEntity(
+ "http://localhost:" + port + "/api/exchange-rates?date=2023-01-29¤cyCode=CHF", CurrencyConversionRates.class);
+
+ assertEquals(response.getStatusCode(), HttpStatus.OK);
+ assertEquals(1, Objects.requireNonNull(response.getBody()).getConversionRateList().size());
+ assertEquals("1.4517", response.getBody().getConversionRateList().get(0).getRate());
+ }
+
+ @Test
+ public void testGetCurrencyExchangeSuccess() {//"2023-01-24" //"1.4517"
+ String targetUrl = "https://api.statistiken.bundesbank.de/rest/data/BBEX3/D.CHF.EUR.BB.AC.000?detail=dataonly&startPeriod=2023-01-29&endPeriod=2023-01-29";
+
+ Mockito.when(restUtil.getForObject(targetUrl, GenericData.class)).thenReturn(createDummyResponse("2023-01-24", "1.4517"));
+ var response = this.testRestTemplate.getForEntity(
+ "http://localhost:" + port + "/api/exchange-currency?date=2023-01-29¤cyCode=CHF&amount=2000", ExchangedAmount.class);
+
+ assertEquals(response.getStatusCode(), HttpStatus.OK);
+ assertNotNull(Objects.requireNonNull(response.getBody()).getAmount());
+ assertEquals("1.4517", Objects.requireNonNull(response.getBody()).getConversionRate().getRate());
+ assertEquals("2903.4", Objects.requireNonNull(response.getBody()).getAmount());
+ }
+
+ @Test
+ public void testThrowRateNotAvailableException() {
+ String targetUrl = "https://api.statistiken.bundesbank.de/rest/data/BBEX3/D.CHF.EUR.BB.AC.000?detail=dataonly&startPeriod=2023-01-28&endPeriod=2023-01-28";
+
+ Mockito.when(restUtil.getForObject(targetUrl, GenericData.class)).thenReturn(createDummyResponse("2023-01-28", null));
+ var response = this.testRestTemplate.getForEntity(
+ "http://localhost:" + port + "/api/exchange-currency?date=2023-01-28¤cyCode=CHF&amount=2000", ExchangedAmount.class);
+
+ assertEquals(response.getStatusCode(), HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetExchangeRateForNotSupportedCurrency() {
+ var response = this.testRestTemplate.getForEntity(
+ "http://localhost:" + port + "/api/exchange-rates?date=2023-01-29¤cyCode=AED", CurrencyConversionRates.class);
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ private GenericData createDummyResponse(String date, String value) {
+ var obsDimension = new ObsDimension(date);
+ var obsValue = value == null ? null : new ObsValue(value);
+ var obsList = List.of(new Obs(obsDimension, obsValue));
+ var series = Series.builder()
+ .seriesKey(SeriesKey.builder().valueList(List.of(new com.crewmeister.cmcodingchallenge.currency.domain.external.Value("BBK_STD_CURRENCY", "CHF"))).build())
+ .obsList(obsList).build();
+ return GenericData.builder()
+ .dataSet(DataSet.builder().series(series).build())
+ .build();
+ }
+}
diff --git a/src/test/java/com/crewmeister/cmcodingchallenge/currency/unit/TestXMLUtil.java b/src/test/java/com/crewmeister/cmcodingchallenge/currency/unit/TestXMLUtil.java
new file mode 100644
index 0000000..f7f5030
--- /dev/null
+++ b/src/test/java/com/crewmeister/cmcodingchallenge/currency/unit/TestXMLUtil.java
@@ -0,0 +1,31 @@
+package com.crewmeister.cmcodingchallenge.currency.unit;
+
+import com.crewmeister.cmcodingchallenge.currency.domain.external.GenericData;
+import com.crewmeister.cmcodingchallenge.currency.util.XMLUtil;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class TestXMLUtil {
+
+ @Test
+ void testXmlToEntity() throws IOException {
+ String resourceName = "test-data.xml";
+ ClassLoader classLoader = getClass().getClassLoader();
+ File file = new File(classLoader.getResource(resourceName).getFile());
+ String content = Files.readString(file.toPath());
+ GenericData value = XMLUtil.parseData(content, GenericData.class);
+ assertNotNull(value);
+ assertNotNull(value.getDataSet());
+ assertNotNull(value.getDataSet().getSeries());
+ assertNotNull(value.getDataSet().getSeries().getObsList());
+ assertEquals(10, value.getDataSet().getSeries().getObsList().size());
+ assertNotNull(value.getDataSet().getSeries().getObsList().get(0).getObsValue());
+ assertNotNull(value.getDataSet().getSeries().getObsList().get(0).getObsValue().getValue());
+ }
+}
diff --git a/src/test/resources/test-data.xml b/src/test/resources/test-data.xml
new file mode 100644
index 0000000..ad5e8ca
--- /dev/null
+++ b/src/test/resources/test-data.xml
@@ -0,0 +1,104 @@
+
+
+
+ BBEX3_D_PLN_EUR_BB_AC_000
+ false
+ 2023-01-25T16:00:29+01:00
+
+ Deutsche Bundesbank
+
+ presse-information@bundesbank.de
+
+
+
+
+ urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=BBK:BBK_ERX(1.0)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file