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
29 changes: 29 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/client/nbuz/NbuzApiClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package dev.vality.rateboss.client.nbuz

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 org.springframework.web.client.exchange
import org.springframework.web.util.UriComponentsBuilder
import java.time.LocalDate

@Component
class NbuzApiClient(
private val restTemplate: RestTemplate,
private val ratesProperties: RatesProperties,
) {
fun getExchangeRates(date: LocalDate): String {
val url = buildUrl(date)
return restTemplate.exchange<String>(url, HttpMethod.GET, HttpEntity.EMPTY).body!!
}

private fun buildUrl(date: LocalDate): String =
UriComponentsBuilder
.fromUriString(ratesProperties.source.nbuz.rootUrl)
.queryParam("filter[locale:contains]", ratesProperties.source.nbuz.locale)
.queryParam("filter[data_sozdaniya:contains]", date)
.build()
.toUriString()
}
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 @@ -5,6 +5,7 @@ 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 dev.vality.rateboss.job.NbuzExchangeGrabberMasterJob
import org.quartz.*
import org.quartz.impl.JobDetailImpl
import org.springframework.beans.factory.annotation.Autowired
Expand Down Expand Up @@ -65,6 +66,16 @@ class JobConfig {
schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkrJob.jobKey))
}
}
val nbuzJobTriggerName = ratesProperties.nbuzJob.jobTriggerName
if (nbuzJobTriggerName.isNotEmpty()) {
schedulerFactoryBean.addJob(nbuzExchangeRateGrabberMasterJob(), true, true)
if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.nbuzJob.jobTriggerName))) {
schedulerFactoryBean.scheduleJob(nbuzExchangeRateGrabberMasterTrigger())
}
if (runOnStartup) {
schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbuzJob.jobKey))
}
}
}

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

fun nbuzExchangeRateGrabberMasterJob(): JobDetailImpl {
val jobDetail = JobDetailImpl()
jobDetail.key = JobKey(ratesProperties.nbuzJob.jobKey)
jobDetail.jobClass = NbuzExchangeGrabberMasterJob::class.java
return jobDetail
}

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

Expand All @@ -31,6 +32,7 @@ data class RatesSourceProperties(
val cbr: CbrProperties,
val nbkz: NbkzProperties,
val nbkr: NbkrProperties,
val nbuz: NbuzProperties,
)

data class FixerProperties(
Expand All @@ -53,3 +55,9 @@ data class NbkrProperties(
val rootUrl: String,
val timeZone: ZoneId,
)

data class NbuzProperties(
val rootUrl: String,
val locale: String,
val timeZone: ZoneId,
)
37 changes: 37 additions & 0 deletions src/main/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJob.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.NbuzExchangeRateSource
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 NbuzExchangeGrabberJob : 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(NbuzExchangeRateSource::class.java)
val sourceId = exchangeRateSource.getSourceId()
log.info { "Process NbuzExchangeGrabberJob 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 NbuzExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() {
override fun executeInternal(context: JobExecutionContext) {
val applicationContext = context.getApplicationContext()
val ratesProperties = applicationContext.getBean(RatesProperties::class.java)
val currencies = ratesProperties.nbuzJob.currencies
val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java)
launchJob(currencies, schedulerFactoryBean, NbuzExchangeGrabberJob::class.java, getJobName())
}

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

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class NbuzRatesResponse(
val data: List<NbuzRatesEntry>? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class NbuzRatesEntry(
val rates: List<NbuzRateItem>? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class NbuzRateItem(
@JsonProperty("rate_code")
val rateCode: String? = null,
@JsonProperty("rate_sb")
val rateSb: String? = null,
@JsonProperty("rate_equivalent")
val rateEquivalent: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package dev.vality.rateboss.source.impl

import com.fasterxml.jackson.databind.ObjectMapper
import dev.vality.rateboss.client.nbuz.NbuzApiClient
import dev.vality.rateboss.config.properties.RatesProperties
import dev.vality.rateboss.job.constant.ExRateSources
import dev.vality.rateboss.model.NbuzRatesResponse
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

private val log = KotlinLogging.logger {}

@Component
class NbuzExchangeRateSource(
private val nbuzApiClient: NbuzApiClient,
private val ratesProperties: RatesProperties,
private val objectMapper: ObjectMapper,
) : ExchangeRateSource {
override fun getExchangeRate(currencySymbolCode: String): ExchangeRates {
val timeZone = ratesProperties.source.nbuz.timeZone
val date = LocalDate.now(timeZone)
log.info { "Trying to get exchange rates from nbuz for currency=$currencySymbolCode, date=$date" }
val response =
try {
objectMapper.readValue(nbuzApiClient.getExchangeRates(date), NbuzRatesResponse::class.java)
} catch (e: Exception) {
throw ExchangeRateSourceException("Failed to get daily rates", e)
}
val rates = buildRatesMap(response)
if (rates.isEmpty()) {
throw ExchangeRateSourceException("Unsuccessful response from NbuzApi")
}
val nextDayTimestamp = date.atStartOfDay().toEpochSecond(ZoneOffset.UTC)
log.info { "Exchange rates from nbuz have been retrieved, date=$date, exchangeRates=$rates, targetTimestamp=$nextDayTimestamp" }
return ExchangeRates(
rates = rates,
timestamp = nextDayTimestamp,
)
}

override fun getSourceId(): String = ExRateSources.NBUZ

private fun buildRatesMap(response: NbuzRatesResponse): Map<String, BigDecimal> {
val rates = mutableMapOf<String, BigDecimal>()
val items =
response.data
?.firstOrNull()
?.rates
.orEmpty()
for (item in items) {
val rateCode = item.rateCode?.trim { it <= ' ' }.orEmpty()
val nominal = item.rateEquivalent.normalizeDecimal().toBigDecimalOrNull()
val rate = item.rateSb.normalizeDecimal().toBigDecimalOrNull()
if (rateCode.isBlank() || nominal == null || rate == null || nominal.compareTo(BigDecimal.ZERO) == 0) {
log.debug { "Skip malformed NBUZ currency record: $item" }
continue
}
rates[rateCode] = rate.divide(nominal)
}
return rates
}

private fun String?.normalizeDecimal(): String =
this
?.replace(" ", "")
?.replace(",", ".")
?.trim()
.orEmpty()
}
11 changes: 11 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ rates:
currencies:
- symbolCode: "KGS"
exponent: 2
nbuzJob:
jobCron: '0 0 0/1 * * ?'
jobKey: 'nbuz-exchange-rate-grabber-master-job'
jobTriggerName: 'nbuz-exchange-rate-grabber-master-job-trigger'
currencies:
- symbolCode: "UZS"
exponent: 2
source:
fixer:
rootUrl: https://api.apilayer.com/fixer/
Expand All @@ -118,3 +125,7 @@ rates:
nbkr:
rootUrl: https://www.nbkr.kg/XML/daily.xml
timeZone: Asia/Bishkek
nbuz:
rootUrl: https://nbu.uz/api/collections/individuals_exchange_rates/entries
locale: ru
timeZone: Asia/Tashkent
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dev.vality.rateboss.client.nbuz

import dev.vality.rateboss.config.TestConfig
import dev.vality.rateboss.source.ExchangeRateSource
import dev.vality.rateboss.source.impl.NbuzExchangeRateSource
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
import java.time.LocalDate

@Disabled("integration test")
@ExtendWith(SpringExtension::class)
@ContextConfiguration(classes = [NbuzApiClient::class, NbuzExchangeRateSource::class])
@Import(TestConfig::class)
class NbuzApiClientTest {
@Autowired
lateinit var nbuzApiClient: NbuzApiClient

@Autowired
lateinit var nbuzExchangeRateSource: ExchangeRateSource

@Test
fun getExchangeRates() {
val response = nbuzApiClient.getExchangeRates(LocalDate.now())

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

@Test
fun getExchangeRatesViaSource() {
val exchangeRates = nbuzExchangeRateSource.getExchangeRate("UZS")

assertNotNull(exchangeRates)
assertTrue(exchangeRates.rates.isNotEmpty())
}
}
15 changes: 15 additions & 0 deletions src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.vality.rateboss.config

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import dev.vality.rateboss.config.properties.*
Expand All @@ -22,6 +23,9 @@ class TestConfig {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build()

@Bean
fun objectMapper(): ObjectMapper = ObjectMapper().registerModule(kotlinModule())

@Bean
fun testRatesProperties(): RatesProperties =
RatesProperties(
Expand Down Expand Up @@ -49,11 +53,22 @@ class TestConfig {
"nbkr-name",
listOf(CurrencyProperties("KGS", 2)),
),
JobDescription(
"nbuz-cron",
"nbuz-key",
"nbuz-name",
listOf(CurrencyProperties("UZS", 2)),
),
RatesSourceProperties(
FixerProperties("url", "key"),
CbrProperties("https://www.cbr.ru/scripts/XML_daily.asp", ZoneId.of("Europe/Moscow")),
NbkzProperties("https://nationalbank.kz/rss/get_rates.cfm", "dd.MM.yyyy", ZoneId.of("Asia/Almaty")),
NbkrProperties("https://www.nbkr.kg/XML/daily.xml", ZoneId.of("Asia/Bishkek")),
NbuzProperties(
"https://nbu.uz/api/collections/individuals_exchange_rates_bankomats/entries",
"ru",
ZoneId.of("Asia/Tashkent"),
),
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() {
scheduler.unscheduleJob(TriggerKey(ratesProperties.fixerJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class FixerExchangeGrabberJobTest : ContainerConfiguration() {
@BeforeEach
fun setUp() {
scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class NbkrExchangeGrabberJobTest : ContainerConfiguration() {
scheduler.unscheduleJob(TriggerKey(ratesProperties.fixerJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName))
scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName))
}

@Test
Expand Down
Loading
Loading