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