Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>dev.vality</groupId>
<artifactId>service-parent-pom</artifactId>
<version>3.1.4</version>
<version>3.1.7</version>
</parent>
<artifactId>rate-boss</artifactId>
<version>0.0.1-SNAPSHOT</version>
Expand All @@ -16,7 +16,7 @@
<management.port>8023</management.port>
<java.version>21</java.version>
<kotlin.version>2.1.21</kotlin.version>
<testcontainers.version>1.17.3</testcontainers.version>
<testcontainers.version>1.21.4</testcontainers.version>
<exposed.ports>${server.port} ${management.port}</exposed.ports>
<db.port>5432</db.port>
<db.user>postgres</db.user>
Expand Down Expand Up @@ -57,6 +57,10 @@
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
Expand Down
31 changes: 31 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dev.vality.rateboss.client.nbkr

import dev.vality.rateboss.config.properties.RatesProperties
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
import java.nio.charset.Charset

@Component
class NbkrApiClient(
private val restTemplate: RestTemplate,
private val ratesProperties: RatesProperties,
) {
fun getExchangeRates(): String {
val url = ratesProperties.source.nbkr.rootUrl
val bytes =
restTemplate
.exchange(
url,
HttpMethod.GET,
HttpEntity.EMPTY,
ByteArray::class.java,
).body ?: byteArrayOf()
return String(bytes, NBKR_XML_CHARSET)
}

companion object {
private val NBKR_XML_CHARSET: Charset = Charset.forName("Windows-1251")
}
}
11 changes: 11 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/config/AppConfig.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package dev.vality.rateboss.config

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
Expand All @@ -13,6 +16,14 @@ class AppConfig {
@Bean
fun restTemplate() = RestTemplate()

@Bean
fun nbkrXmlMapper(): XmlMapper =
XmlMapper
.builder()
.addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build()

@Bean
fun retryTemplate(
@Value("\${retryTemplate.backOffPeriod}") backOffPeriod: Long,
Expand Down
26 changes: 26 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dev.vality.rateboss.config
import dev.vality.rateboss.config.properties.RatesProperties
import dev.vality.rateboss.job.CbrExchangeGrabberMasterJob
import dev.vality.rateboss.job.FixerExchangeGrabberMasterJob
import dev.vality.rateboss.job.NbkrExchangeGrabberMasterJob
import dev.vality.rateboss.job.NbkzExchangeGrabberMasterJob
import org.quartz.*
import org.quartz.impl.JobDetailImpl
Expand Down Expand Up @@ -54,6 +55,16 @@ class JobConfig {
schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkzJob.jobKey))
}
}
val nbkrJobTriggerName = ratesProperties.nbkrJob.jobTriggerName
if (nbkrJobTriggerName.isNotEmpty()) {
schedulerFactoryBean.addJob(nbkrExchangeRateGrabberMasterJob(), true, true)
if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.nbkrJob.jobTriggerName))) {
schedulerFactoryBean.scheduleJob(nbkrExchangeRateGrabberMasterTrigger())
}
if (runOnStartup) {
schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkrJob.jobKey))
}
}
}

fun fixerExchangeRateGrabberMasterJob(): JobDetailImpl {
Expand Down Expand Up @@ -100,4 +111,19 @@ class JobConfig {
.withIdentity(ratesProperties.nbkzJob.jobTriggerName)
.withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.nbkzJob.jobCron))
.build()

fun nbkrExchangeRateGrabberMasterJob(): JobDetailImpl {
val jobDetail = JobDetailImpl()
jobDetail.key = JobKey(ratesProperties.nbkrJob.jobKey)
jobDetail.jobClass = NbkrExchangeGrabberMasterJob::class.java
return jobDetail
}

fun nbkrExchangeRateGrabberMasterTrigger(): CronTrigger =
TriggerBuilder
.newTrigger()
.forJob(nbkrExchangeRateGrabberMasterJob())
.withIdentity(ratesProperties.nbkrJob.jobTriggerName)
.withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.nbkrJob.jobCron))
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ data class RatesProperties(
val fixerJob: JobDescription,
val cbrJob: JobDescription,
val nbkzJob: JobDescription,
val nbkrJob: JobDescription,
val source: RatesSourceProperties,
)

Expand All @@ -29,6 +30,7 @@ data class RatesSourceProperties(
val fixer: FixerProperties,
val cbr: CbrProperties,
val nbkz: NbkzProperties,
val nbkr: NbkrProperties,
)

data class FixerProperties(
Expand All @@ -46,3 +48,8 @@ data class NbkzProperties(
val dateFormat: String,
val timeZone: ZoneId,
)

data class NbkrProperties(
val rootUrl: String,
val timeZone: ZoneId,
)
37 changes: 37 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJob.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package dev.vality.rateboss.job

import dev.vality.rateboss.extensions.getApplicationContext
import dev.vality.rateboss.source.ExchangeRateSource
import dev.vality.rateboss.source.ExchangeRateSourceException
import dev.vality.rateboss.source.impl.NbkrExchangeRateSource
import dev.vality.rateboss.source.model.ExchangeRates
import mu.KotlinLogging
import org.quartz.JobExecutionContext
import org.springframework.context.ApplicationContext
import org.springframework.retry.support.RetryTemplate

private val log = KotlinLogging.logger {}

class NbkrExchangeGrabberJob : AbstractExchangeGrabberJob() {
override fun executeInternal(context: JobExecutionContext) {
val applicationContext = context.getApplicationContext()
val currencySymbolCode = context.jobDetail.jobDataMap["currencySymbolCode"] as String
val currencyExponent = context.jobDetail.jobDataMap["currencyExponent"] as Int
val exchangeRateSource = applicationContext.getBean(NbkrExchangeRateSource::class.java)
val sourceId = exchangeRateSource.getSourceId()
log.info { "Process NbkrExchangeGrabberJob for $sourceId" }
val exchangeRates = getExchangeRates(applicationContext, exchangeRateSource, currencySymbolCode)
saveExchangeRates(applicationContext, currencySymbolCode, currencyExponent, exchangeRates, sourceId)
}

private fun getExchangeRates(
applicationContext: ApplicationContext,
exchangeRateSource: ExchangeRateSource,
currencySymbolCode: String,
): ExchangeRates {
val retryTemplate = applicationContext.getBean(RetryTemplate::class.java)
return retryTemplate.execute<ExchangeRates, ExchangeRateSourceException> {
exchangeRateSource.getExchangeRate(currencySymbolCode)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.vality.rateboss.job

import dev.vality.rateboss.config.properties.RatesProperties
import dev.vality.rateboss.extensions.getApplicationContext
import org.quartz.JobExecutionContext
import org.quartz.Scheduler

class NbkrExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() {
override fun executeInternal(context: JobExecutionContext) {
val applicationContext = context.getApplicationContext()
val ratesProperties = applicationContext.getBean(RatesProperties::class.java)
val currencies = ratesProperties.nbkrJob.currencies
val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java)
launchJob(currencies, schedulerFactoryBean, NbkrExchangeGrabberJob::class.java, getJobName())
}

override fun getJobName(): String = "nbkrJob"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ object ExRateSources {
const val FIXER = "fixer"
const val CBR = "cbr"
const val NBKZ = "nbkz"
const val NBKR = "nbkr"
}
26 changes: 26 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/model/NbkrDailyRatesXml.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.vality.rateboss.model

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement

@JacksonXmlRootElement(localName = "CurrencyRates")
@JsonIgnoreProperties(ignoreUnknown = true)
data class NbkrDailyRatesXml(
@field:JacksonXmlProperty(isAttribute = true, localName = "Date")
val date: String? = null,
@field:JacksonXmlElementWrapper(useWrapping = false)
@field:JacksonXmlProperty(localName = "Currency")
val currencies: List<NbkrCurrencyXml>? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class NbkrCurrencyXml(
@field:JacksonXmlProperty(isAttribute = true, localName = "ISOCode")
val isoCode: String? = null,
@field:JacksonXmlProperty(localName = "Nominal")
val nominal: String? = null,
@field:JacksonXmlProperty(localName = "Value")
val value: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package dev.vality.rateboss.source.impl

import com.fasterxml.jackson.dataformat.xml.XmlMapper
import dev.vality.rateboss.client.nbkr.NbkrApiClient
import dev.vality.rateboss.config.properties.RatesProperties
import dev.vality.rateboss.job.constant.ExRateSources
import dev.vality.rateboss.model.NbkrDailyRatesXml
import dev.vality.rateboss.source.ExchangeRateSource
import dev.vality.rateboss.source.ExchangeRateSourceException
import dev.vality.rateboss.source.model.ExchangeRates
import mu.KotlinLogging
import org.springframework.stereotype.Component
import java.math.BigDecimal
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

private val log = KotlinLogging.logger {}

@Component
class NbkrExchangeRateSource(
private val nbkrApiClient: NbkrApiClient,
private val ratesProperties: RatesProperties,
private val xmlMapper: XmlMapper,
) : ExchangeRateSource {
override fun getExchangeRate(currencySymbolCode: String): ExchangeRates {
val timeZone = ratesProperties.source.nbkr.timeZone
val date = LocalDate.now(timeZone)
log.info { "Trying to get exchange rates from nbkr for currency=$currencySymbolCode, date=$date" }
val parsed = fetchDailyRates()
val rates = buildRatesMap(parsed)
if (rates.isEmpty()) {
throw ExchangeRateSourceException("Unsuccessful response from NbkrApi")
}
val responseDate =
try {
parseDate(parsed)
} catch (e: Exception) {
throw ExchangeRateSourceException("Failed to parse date from NbkrApi", e)
}
val nextDayTimestamp = responseDate.atStartOfDay().toEpochSecond(ZoneOffset.UTC)
log.info {
"Exchange rates from nbkr have been retrieved, date=$responseDate, " +
"exchangeRates=$rates, targetTimestamp=$nextDayTimestamp"
}
return ExchangeRates(
rates = rates,
timestamp = nextDayTimestamp,
)
}

override fun getSourceId(): String = ExRateSources.NBKR

private fun fetchDailyRates(): NbkrDailyRatesXml =
try {
xmlMapper.readValue(nbkrApiClient.getExchangeRates(), NbkrDailyRatesXml::class.java)
} catch (e: Exception) {
throw ExchangeRateSourceException("Failed to get daily rates", e)
}

private fun buildRatesMap(root: NbkrDailyRatesXml): Map<String, BigDecimal> {
val rates = mutableMapOf<String, BigDecimal>()
for (item in root.currencies.orEmpty()) {
val isoCode = item.isoCode?.trim { it <= ' ' }.orEmpty()
val nominal = item.nominal?.trim { it <= ' ' }?.toBigDecimalOrNull()
val value =
item.value
?.trim { it <= ' ' }
?.replace(",", ".")
?.toBigDecimalOrNull()
if (isoCode.isBlank() || nominal == null || value == null || nominal.compareTo(BigDecimal.ZERO) == 0) {
log.debug { "Skip malformed NBKR currency record: $item" }
continue
}
rates[isoCode] = value.divide(nominal)
}
return rates
}

private fun parseDate(root: NbkrDailyRatesXml): LocalDate {
val dateAttr = root.date?.trim { it <= ' ' }.orEmpty()
require(dateAttr.isNotBlank()) { "Missing Date on CurrencyRates" }
return LocalDate.parse(dateAttr, DATE_FORMATTER)
}

companion object {
private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
}
}
10 changes: 10 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ rates:
currencies:
- symbolCode: "KZT"
exponent: 2
nbkrJob:
jobCron: '0 0 0/1 * * ?'
jobKey: 'nbkr-exchange-rate-grabber-master-job'
jobTriggerName: 'nbkr-exchange-rate-grabber-master-job-trigger'
currencies:
- symbolCode: "KGS"
exponent: 2
source:
fixer:
rootUrl: https://api.apilayer.com/fixer/
Expand All @@ -108,3 +115,6 @@ rates:
rootUrl: https://nationalbank.kz/rss/get_rates.cfm
dateFormat: dd.MM.yyyy
timeZone: Asia/Almaty
nbkr:
rootUrl: https://www.nbkr.kg/XML/daily.xml
timeZone: Asia/Bishkek
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package dev.vality.rateboss.client.nbkr

import dev.vality.rateboss.config.TestConfig
import dev.vality.rateboss.source.ExchangeRateSource
import dev.vality.rateboss.source.impl.NbkrExchangeRateSource
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Import
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.junit.jupiter.SpringExtension

@Disabled("integration test")
@ExtendWith(SpringExtension::class)
@ContextConfiguration(classes = [NbkrApiClient::class, NbkrExchangeRateSource::class])
@Import(TestConfig::class)
class NbkrApiClientTest {
@Autowired
lateinit var nbkrApiClient: NbkrApiClient

@Autowired
lateinit var nbkrExchangeRateSource: ExchangeRateSource

@Test
fun getExchangeRates() {
val response = nbkrApiClient.getExchangeRates()

assertNotNull(response)
assertTrue(response.isNotBlank())
}

@Test
fun getExchangeRatesViaSource() {
val exchangeRates = nbkrExchangeRateSource.getExchangeRate("KGS")

assertNotNull(exchangeRates)
assertTrue(exchangeRates.rates.isNotEmpty())
}
}
Loading
Loading