diff --git a/src/main/kotlin/dev/vality/rateboss/client/cbr/CbrApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/cbr/CbrApiClient.kt index 05e3235..ecede92 100644 --- a/src/main/kotlin/dev/vality/rateboss/client/cbr/CbrApiClient.kt +++ b/src/main/kotlin/dev/vality/rateboss/client/cbr/CbrApiClient.kt @@ -1,6 +1,5 @@ package dev.vality.rateboss.client.cbr -import dev.vality.rateboss.client.cbr.model.CbrExchangeRateData import dev.vality.rateboss.config.properties.RatesProperties import org.springframework.http.HttpEntity import org.springframework.http.HttpMethod @@ -17,12 +16,12 @@ class CbrApiClient( private val restTemplate: RestTemplate, private val ratesProperties: RatesProperties, ) { - fun getExchangeRates(time: Instant): CbrExchangeRateData { + fun getExchangeRates(time: Instant): String { val baseUrl = ratesProperties.source.cbr.rootUrl val timezone = ratesProperties.source.cbr.timeZone val date = time.atZone(timezone).toLocalDate() val url = buildUrl(baseUrl, date) - return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(null, null)).body!! + return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(null, null)).body!! } private fun buildUrl( diff --git a/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrBigDecimalXmlAdapter.kt b/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrBigDecimalXmlAdapter.kt deleted file mode 100644 index fbb8e79..0000000 --- a/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrBigDecimalXmlAdapter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.vality.rateboss.client.cbr.adapter - -import java.math.BigDecimal -import javax.xml.bind.annotation.adapters.XmlAdapter - -class CbrBigDecimalXmlAdapter : XmlAdapter() { - override fun unmarshal(stringValue: String?): BigDecimal? = - stringValue?.let { - BigDecimal(it.replace(',', '.')) - } - - override fun marshal(bigDecimalValue: BigDecimal?): String? = bigDecimalValue?.toString()?.replace('.', ',') -} diff --git a/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrLocalDateXmlAdapter.kt b/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrLocalDateXmlAdapter.kt deleted file mode 100644 index a270f9a..0000000 --- a/src/main/kotlin/dev/vality/rateboss/client/cbr/adapter/CbrLocalDateXmlAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package dev.vality.rateboss.client.cbr.adapter - -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import javax.xml.bind.annotation.adapters.XmlAdapter - -class CbrLocalDateXmlAdapter : XmlAdapter() { - override fun unmarshal(stringValue: String?): LocalDate? = - stringValue?.let { - LocalDate.from(DATE_FORMATTER.parse(it)) - } - - override fun marshal(dateValue: LocalDate?): String? = - dateValue?.let { - DATE_FORMATTER.format(it) - } - - companion object { - val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") - } -} diff --git a/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrCurrencyData.kt b/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrCurrencyData.kt deleted file mode 100644 index bd9c8a0..0000000 --- a/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrCurrencyData.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.vality.rateboss.client.cbr.model - -import dev.vality.rateboss.client.cbr.adapter.CbrBigDecimalXmlAdapter -import java.math.BigDecimal -import javax.xml.bind.annotation.* -import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter - -@XmlRootElement(name = "Valute") -@XmlAccessorType(XmlAccessType.FIELD) -class CbrCurrencyData { - @XmlAttribute(name = "ID") - var id: String? = null - - @XmlElement(name = "NumCode") - var numCode: Int? = null - - @XmlElement(name = "CharCode") - var charCode: String? = null - - @XmlElement(name = "Nominal") - var nominal: Int? = null - - @XmlElement(name = "Name") - var name: String? = null - - @XmlJavaTypeAdapter(CbrBigDecimalXmlAdapter::class) - @XmlElement(name = "Value") - var value: BigDecimal? = null -} diff --git a/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrExchangeRateData.kt b/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrExchangeRateData.kt deleted file mode 100644 index d6fb8bf..0000000 --- a/src/main/kotlin/dev/vality/rateboss/client/cbr/model/CbrExchangeRateData.kt +++ /dev/null @@ -1,20 +0,0 @@ -package dev.vality.rateboss.client.cbr.model - -import dev.vality.rateboss.client.cbr.adapter.CbrLocalDateXmlAdapter -import java.time.LocalDate -import javax.xml.bind.annotation.* -import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter - -@XmlRootElement(name = "ValCurs") -@XmlAccessorType(XmlAccessType.FIELD) -class CbrExchangeRateData { - @XmlAttribute - var name: String? = null - - @XmlAttribute(name = "Date") - @XmlJavaTypeAdapter(CbrLocalDateXmlAdapter::class) - var date: LocalDate? = null - - @XmlElement(name = "Valute") - var currencies: List? = null -} diff --git a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt index e2eeaad..5dc65c6 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt @@ -7,6 +7,7 @@ import dev.vality.rateboss.job.NbkzExchangeGrabberMasterJob import org.quartz.* import org.quartz.impl.JobDetailImpl import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration import javax.annotation.PostConstruct @@ -18,6 +19,9 @@ class JobConfig { @Autowired private lateinit var ratesProperties: RatesProperties + @Value("\${rates.run-on-startup:true}") + private var runOnStartup: Boolean = true + @PostConstruct fun init() { val fixerJobTriggerName = ratesProperties.fixerJob.jobTriggerName @@ -26,6 +30,9 @@ class JobConfig { if (!schedulerFactoryBean.checkExists(TriggerKey(fixerJobTriggerName))) { schedulerFactoryBean.scheduleJob(fixerExchangeRateGrabberMasterTrigger()) } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.fixerJob.jobKey)) + } } val cbrJobTriggerName = ratesProperties.cbrJob.jobTriggerName if (cbrJobTriggerName.isNotEmpty()) { @@ -33,6 +40,9 @@ class JobConfig { if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.cbrJob.jobTriggerName))) { schedulerFactoryBean.scheduleJob(cbrExchangeRateGrabberMasterTrigger()) } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.cbrJob.jobKey)) + } } val nbkzJobTriggerName = ratesProperties.nbkzJob.jobTriggerName if (nbkzJobTriggerName.isNotEmpty()) { @@ -40,6 +50,9 @@ class JobConfig { if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.nbkzJob.jobTriggerName))) { schedulerFactoryBean.scheduleJob(nbkzExchangeRateGrabberMasterTrigger()) } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkzJob.jobKey)) + } } } diff --git a/src/main/kotlin/dev/vality/rateboss/converter/ExRateConverter.kt b/src/main/kotlin/dev/vality/rateboss/converter/ExRateConverter.kt index b7bb481..548f83f 100644 --- a/src/main/kotlin/dev/vality/rateboss/converter/ExRateConverter.kt +++ b/src/main/kotlin/dev/vality/rateboss/converter/ExRateConverter.kt @@ -27,10 +27,10 @@ class ExRateConverter { DEFAULT_EXPONENT } return ExRate().apply { - destinationCurrencySymbolicCode = baseCurrencySymbolCode - destinationCurrencyExponent = baseCurrencyExponent - sourceCurrencySymbolicCode = exchangeRateMapEntry.key - sourceCurrencyExponent = exponent.toShort() + sourceCurrencySymbolicCode = baseCurrencySymbolCode + sourceCurrencyExponent = baseCurrencyExponent + destinationCurrencySymbolicCode = exchangeRateMapEntry.key + destinationCurrencyExponent = exponent.toShort() val rational = exchangeRateMapEntry.value.toRational() rationalP = rational.numerator rationalQ = rational.denominator diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/CbrExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/CbrExchangeRateSource.kt index d80b600..205eebf 100644 --- a/src/main/kotlin/dev/vality/rateboss/source/impl/CbrExchangeRateSource.kt +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/CbrExchangeRateSource.kt @@ -7,9 +7,16 @@ import dev.vality.rateboss.source.ExchangeRateSourceException import dev.vality.rateboss.source.model.ExchangeRates import mu.KotlinLogging import org.springframework.stereotype.Component +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.ByteArrayInputStream import java.math.BigDecimal +import java.nio.charset.StandardCharsets import java.time.Instant +import java.time.LocalDate import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import javax.xml.parsers.DocumentBuilderFactory private val log = KotlinLogging.logger {} @@ -26,15 +33,21 @@ class CbrExchangeRateSource( } catch (e: Exception) { throw ExchangeRateSourceException("Remote client exception", e) } - if (response.currencies.isNullOrEmpty()) { + val rates = + try { + parseRates(response) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse response from CbrApi", e) + } + if (rates.isEmpty()) { throw ExchangeRateSourceException("Unsuccessful response from CbrApi") } - - val rates: Map = - response.currencies!!.associate { - it.charCode!! to it.value!!.divide(it.nominal!!.toBigDecimal()) + val responseDate = + try { + parseDate(response) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse date from CbrApi", e) } - val responseDate = response.date!! val nextDayTimestamp = responseDate.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) log.info("Exchange rates from cbr have been retrieved, date=$responseDate, exchangeRates=$rates, targetTimestamp=$nextDayTimestamp") return ExchangeRates( @@ -44,4 +57,59 @@ class CbrExchangeRateSource( } override fun getSourceId(): String = ExRateSources.CBR + + private fun parseRates(xmlContent: String): Map { + val document = parseXml(xmlContent) + val items = document.getElementsByTagName("Valute") + val rates = mutableMapOf() + for (i in 0 until items.length) { + val itemNode = items.item(i) + if (itemNode.nodeType != Node.ELEMENT_NODE) { + continue + } + val item = itemNode as Element + val charCode = + item + .getElementsByTagName("CharCode") + .item(0) + ?.textContent + ?.trim() + val nominalStr = + item + .getElementsByTagName("Nominal") + .item(0) + ?.textContent + ?.trim() + val valueStr = + item + .getElementsByTagName("Value") + .item(0) + ?.textContent + ?.trim() + if (charCode.isNullOrBlank() || nominalStr.isNullOrBlank() || valueStr.isNullOrBlank()) { + continue + } + val nominal = nominalStr.toBigDecimal() + val value = valueStr.replace(",", ".").toBigDecimal() + rates[charCode] = value.divide(nominal) + } + return rates + } + + private fun parseDate(xmlContent: String): LocalDate { + val document = parseXml(xmlContent) + val root = document.documentElement + val dateAttr = root.getAttribute("Date") + return LocalDate.parse(dateAttr, DATE_FORMATTER) + } + + private fun parseXml(xmlContent: String) = + DocumentBuilderFactory + .newInstance() + .newDocumentBuilder() + .parse(ByteArrayInputStream(xmlContent.toByteArray(StandardCharsets.UTF_8))) + + companion object { + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + } } diff --git a/src/test/kotlin/dev/vality/rateboss/ContainerConfiguration.kt b/src/test/kotlin/dev/vality/rateboss/ContainerConfiguration.kt index ae526b3..a8367dd 100644 --- a/src/test/kotlin/dev/vality/rateboss/ContainerConfiguration.kt +++ b/src/test/kotlin/dev/vality/rateboss/ContainerConfiguration.kt @@ -42,6 +42,7 @@ class ContainerConfiguration { registry.add("spring.flyway.url", postgresql::getJdbcUrl) registry.add("spring.flyway.user", postgresql::getUsername) registry.add("spring.flyway.password", postgresql::getPassword) + registry.add("rates.run-on-startup") { "false" } } } } diff --git a/src/test/kotlin/dev/vality/rateboss/client/cbr/CbrApiClientTest.kt b/src/test/kotlin/dev/vality/rateboss/client/cbr/CbrApiClientTest.kt index e724b80..19e1dc0 100644 --- a/src/test/kotlin/dev/vality/rateboss/client/cbr/CbrApiClientTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/client/cbr/CbrApiClientTest.kt @@ -1,6 +1,8 @@ package dev.vality.rateboss.client.cbr import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.impl.CbrExchangeRateSource import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Disabled @@ -14,17 +16,28 @@ import java.time.Instant @Disabled("integration test") @ExtendWith(SpringExtension::class) -@ContextConfiguration(classes = [CbrApiClient::class]) +@ContextConfiguration(classes = [CbrApiClient::class, CbrExchangeRateSource::class]) @Import(TestConfig::class) class CbrApiClientTest { @Autowired lateinit var cbrApiClient: CbrApiClient + @Autowired + lateinit var cbrExchangeRateSource: ExchangeRateSource + @Test fun getExchangeRates() { val response = cbrApiClient.getExchangeRates(Instant.now()) assertNotNull(response) - assertTrue(response.currencies!!.isNotEmpty()) + assertTrue(response.isNotBlank()) + } + + @Test + fun getExchangeRatesViaSource() { + val exchangeRates = cbrExchangeRateSource.getExchangeRate("RUB") + + assertNotNull(exchangeRates) + assertTrue(exchangeRates.rates.isNotEmpty()) } } diff --git a/src/test/kotlin/dev/vality/rateboss/service/ExchangeDaoServiceTest.kt b/src/test/kotlin/dev/vality/rateboss/service/ExchangeDaoServiceTest.kt index 104ce2c..7354088 100644 --- a/src/test/kotlin/dev/vality/rateboss/service/ExchangeDaoServiceTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/service/ExchangeDaoServiceTest.kt @@ -67,7 +67,7 @@ class ExchangeDaoServiceTest : ContainerConfiguration() { val codes = records .stream() - .map(ExRateRecord::getSourceCurrencySymbolicCode) + .map(ExRateRecord::getDestinationCurrencySymbolicCode) .toList() assertThat(codes, contains("RUB", "AED", "AMD")) } diff --git a/src/test/kotlin/dev/vality/rateboss/source/CbrExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/CbrExchangeRateSourceTest.kt index 3ff5fd8..7f0937b 100644 --- a/src/test/kotlin/dev/vality/rateboss/source/CbrExchangeRateSourceTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/source/CbrExchangeRateSourceTest.kt @@ -1,11 +1,12 @@ package dev.vality.rateboss.source import dev.vality.rateboss.client.cbr.CbrApiClient -import dev.vality.rateboss.client.cbr.model.CbrCurrencyData -import dev.vality.rateboss.client.cbr.model.CbrExchangeRateData import dev.vality.rateboss.config.TestConfig import dev.vality.rateboss.source.impl.CbrExchangeRateSource -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any @@ -16,8 +17,6 @@ import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.web.client.ResourceAccessException -import java.math.BigDecimal -import java.time.LocalDate @ExtendWith(SpringExtension::class) @ContextConfiguration(classes = [CbrApiClient::class, CbrExchangeRateSource::class]) @@ -45,9 +44,7 @@ class CbrExchangeRateSourceTest { @Test fun getEmptyExchangeRate() { val currencySymbolCode = "RUB" - val cbrExchangeRateData = buildCbrExchangeRateData() - cbrExchangeRateData.currencies = emptyList() - whenever(cbrApiClient.getExchangeRates(any())).thenReturn(cbrExchangeRateData) + whenever(cbrApiClient.getExchangeRates(any())).thenReturn("") val exception = assertThrows(ExchangeRateSourceException::class.java) { @@ -60,9 +57,19 @@ class CbrExchangeRateSourceTest { @Test fun getSuccessExchangeRate() { val currencySymbolCode = "RUB" - val cbrExchangeRateData = buildCbrExchangeRateData() - cbrExchangeRateData.currencies = listOf(buildCbrCurrencyData(currencySymbolCode)) - whenever(cbrApiClient.getExchangeRates(any())).thenReturn(cbrExchangeRateData) + whenever(cbrApiClient.getExchangeRates(any())).thenReturn( + """ + + + 643 + RUB + 1 + Russian Ruble + 100,00 + + + """.trimIndent(), + ) val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) @@ -70,21 +77,4 @@ class CbrExchangeRateSourceTest { assertTrue(exchangeRate.rates.isNotEmpty()) assertTrue(exchangeRate.rates.containsKey(currencySymbolCode)) } - - private fun buildCbrExchangeRateData(): CbrExchangeRateData { - val cbrExchangeRateData = CbrExchangeRateData() - cbrExchangeRateData.name = "Foreign Currency Market" - cbrExchangeRateData.date = LocalDate.now() - return cbrExchangeRateData - } - - private fun buildCbrCurrencyData(currencySymbolCode: String): CbrCurrencyData { - val currencyData = CbrCurrencyData() - currencyData.value = BigDecimal.valueOf(100L) - currencyData.nominal = 1 - currencyData.charCode = currencySymbolCode - currencyData.numCode = 100 - currencyData.id = "R01010" - return currencyData - } }