diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml
index 0420b64..ea65f3e 100644
--- a/.github/workflows/cd.yaml
+++ b/.github/workflows/cd.yaml
@@ -62,6 +62,7 @@ jobs:
DB_NAME: ${{ secrets.DB_NAME }}
DASHBOARD: ${{ secrets.DASHBOARD }}
FIREBASE_ADMIN_AUTH_CREDENTIALS: ${{ secrets.FIREBASE_ADMIN_AUTH_CREDENTIALS }}
+ FOREX_API_KEY: ${{ secrets.FOREX_API_KEY }}
DASHBOARD_USER: ${{ secrets.DASHBOARD_USER }}
DASHBOARD_PASS: ${{ secrets.DASHBOARD_PASS }}
diff --git a/README.md b/README.md
index 216181d..044bae1 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,21 @@
# Play Deals
-[![Static Badge](https://img.shields.io/badge/Android-black?logo=android&logoColor=white&color=%234889f5)](https://play.google.com/store/apps/details?id=me.sujanpoudel.playdeals)
-[![Static Badge](https://img.shields.io/badge/IOS-grey?logo=apple)](https://github.com/psuzn/app-deals/releases/latest)
-[![Static Badge](https://img.shields.io/badge/macOS-black?logo=apple)](https://github.com/psuzn/app-deals/releases/latest)
-[![Static Badge](https://img.shields.io/badge/Windows-green?logo=windows&color=blue)](https://github.com/psuzn/app-deals/releases/latest)
-[![Static Badge](https://img.shields.io/badge/Linux-white?logo=linux&logoColor=white&color=grey)](https://github.com/psuzn/app-deals/releases/latest)
+
+[![Static Badge](https://img.shields.io/badge/Android-black?logo=android&logoColor=white&color=%234889f5)](https://play.google.com/store/apps/details?id=me.sujanpoudel.playdeals)
+
+[![Static Badge](https://img.shields.io/badge/IOS-grey?logo=apple)](https://github.com/psuzn/app-deals/releases/latest)
+
+[![Static Badge](https://img.shields.io/badge/macOS-black?logo=apple)](https://github.com/psuzn/app-deals/releases/latest)
+
+[![Static Badge](https://img.shields.io/badge/Windows-green?logo=windows&color=blue)](https://github.com/psuzn/app-deals/releases/latest)
+
+[![Static Badge](https://img.shields.io/badge/Linux-white?logo=linux&logoColor=white&color=grey)](https://github.com/psuzn/app-deals/releases/latest)
+
![Static Badge](https://img.shields.io/badge/License-GPL--v3-brightgreen)
[![play-deals-backend 1.0 CI](https://github.com/psuzn/play-deals-backend/actions/workflows/ci.yaml/badge.svg)](https://github.com/psuzn/play-deals-backend/actions/workflows/ci.yaml)
![Feature](./media/feature-graphic.jpeg)
-
|
|
|
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------:|
@@ -41,21 +46,23 @@ Configuration can be done by passing environment variables listed below:
> 1. run `just dev-run` from terminal, OR
> 2. Install [Envfile](https://plugins.jetbrains.com/plugin/7861-envfile) plugin for IntelliJ and run using IntelliJ
-| ENV_VAR | REQUIRED | DEFAULT | EXAMPLE | NOTES |
-|------------------|----------|---------------|--------------|:--------------------------------------------------------------------|
-| `DB_HOST` | `Y` | | `localhost` | |
-| `DB_USERNAME` | `Y` | | `whatever` | |
-| `DB_PASSWORD` | `N` | `password` | `whatever` | |
-| `DB_PORT` | `N` | `5432` | `6868` | |
-| `DB_NAME` | `N` | `play_deals` | `whatever` | |
-| `DB_POOL_SIZE` | `N` | `5` | `6` | |
-| `ENV` | `N` | `PRODUCTION` | `PRODUCTION` | one of `PRODUCTION or DEVELOPMENT or TEST ` |
-| `APP_PORT` | `N` | `8888` | `9999` | |
-| `POSTGRES_IMAGE` | `N` | `postgres:14` | | Useful for testing new versions of postgres. Used only in test code |
-| `DASHBOARD` | `N` | `true` | `false` | Whether to enable or not the Jobrunr dashboard |
-| `DASHBOARD_USER` | `N` | `admin` | `whatever` | Jobrunr dashboard login credential |
-| `DASHBOARD_PASS` | `N` | `admin` | `whatever` | Jobrunr dashboard login credential |
-| `CORS` | `N` | `*` | `whatever` | origins allowed for CORS |
+| ENV_VAR | REQUIRED | DEFAULT | EXAMPLE | NOTES |
+|-----------------------------------|----------|---------------|--------------|:-----------------------------------------------------------------------|
+| `DB_HOST` | `Y` | | `localhost` | |
+| `DB_USERNAME` | `Y` | | `whatever` | |
+| `FIREBASE_ADMIN_AUTH_CREDENTIALS` | `Y` | | `whatever` | Firebase admin auth credentials |
+| `FOREX_API_KEY` | `Y` | | `whatever` | Api key for [https://exchangeratesapi.io](https://exchangeratesapi.io) |
+| `DB_PASSWORD` | `N` | `password` | `whatever` | |
+| `DB_PORT` | `N` | `5432` | `6868` | |
+| `DB_NAME` | `N` | `play_deals` | `whatever` | |
+| `DB_POOL_SIZE` | `N` | `5` | `6` | |
+| `ENV` | `N` | `PRODUCTION` | `PRODUCTION` | one of `PRODUCTION or DEVELOPMENT or TEST ` |
+| `APP_PORT` | `N` | `8888` | `9999` | |
+| `POSTGRES_IMAGE` | `N` | `postgres:14` | | Useful for testing new versions of postgres. Used only in test code |
+| `DASHBOARD` | `N` | `true` | `false` | Whether to enable or not the Jobrunr dashboard |
+| `DASHBOARD_USER` | `N` | `admin` | `whatever` | Jobrunr dashboard login credential |
+| `DASHBOARD_PASS` | `N` | `admin` | `whatever` | Jobrunr dashboard login credential |
+| `CORS` | `N` | `*` | `whatever` | origins allowed for CORS |
## License
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/DIConfigurer.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/DIConfigurer.kt
index 1e20e73..cc1b7fa 100644
--- a/backend/src/main/kotlin/me/sujanpoudel/playdeals/DIConfigurer.kt
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/DIConfigurer.kt
@@ -19,6 +19,7 @@ import me.sujanpoudel.playdeals.jobs.AndroidAppExpiryCheckScheduler
import me.sujanpoudel.playdeals.jobs.AppDetailScrapper
import me.sujanpoudel.playdeals.jobs.BackgroundJobsVerticle
import me.sujanpoudel.playdeals.jobs.DealSummarizer
+import me.sujanpoudel.playdeals.jobs.ForexFetcher
import me.sujanpoudel.playdeals.jobs.RedditPostsScrapper
import me.sujanpoudel.playdeals.repositories.DealRepository
import me.sujanpoudel.playdeals.repositories.KeyValuesRepository
@@ -28,6 +29,7 @@ import me.sujanpoudel.playdeals.repositories.persistent.PersistentKeyValuesRepos
import me.sujanpoudel.playdeals.services.MessagingService
import me.sujanpoudel.playdeals.usecases.DBHealthUseCase
import me.sujanpoudel.playdeals.usecases.GetDealsUseCase
+import me.sujanpoudel.playdeals.usecases.GetForexUseCase
import me.sujanpoudel.playdeals.usecases.NewDealUseCase
import org.flywaydb.core.Flyway
import org.jobrunr.configuration.JobRunr
@@ -155,10 +157,17 @@ fun configureDI(
requestScheduler = instance()
)
}
+ bindSingleton {
+ ForexFetcher(
+ di = di,
+ conf = instance()
+ )
+ }
bindSingleton { DBHealthUseCase(di) }
bindSingleton { GetDealsUseCase(di) }
bindSingleton { NewDealUseCase(di) }
+ bindSingleton { GetForexUseCase(di) }
bindSingleton {
FirebaseOptions.builder()
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ApiVerticle.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ApiVerticle.kt
index 9be11bc..36e6a9a 100644
--- a/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ApiVerticle.kt
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ApiVerticle.kt
@@ -21,6 +21,7 @@ class ApiVerticle(
router.route().handler(CorsHandler.create().addRelativeOrigin(config.api.cors))
router.route("/health/*").subRouter(healthApi(di, vertx))
router.route("/api/deals/*").subRouter(appDealsApi(di, vertx))
+ router.route("/api/forex/*").subRouter(forexRateApi(di, vertx))
vertx.createHttpServer()
.requestHandler(router)
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ForexRate.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ForexRate.kt
new file mode 100644
index 0000000..91bf83f
--- /dev/null
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ForexRate.kt
@@ -0,0 +1,25 @@
+package me.sujanpoudel.playdeals.api
+
+import io.vertx.ext.web.Router
+import me.sujanpoudel.playdeals.common.coHandler
+import me.sujanpoudel.playdeals.common.jsonResponse
+import me.sujanpoudel.playdeals.usecases.GetForexUseCase
+import me.sujanpoudel.playdeals.usecases.executeUseCase
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+fun forexRateApi(
+ di: DirectDI,
+ vertx: io.vertx.core.Vertx
+): Router = Router.router(vertx).apply {
+ get()
+ .coHandler { ctx ->
+ ctx.executeUseCase(
+ useCase = di.instance(),
+ toContext = { },
+ toInput = { }
+ ) {
+ ctx.json(jsonResponse(data = it))
+ }
+ }
+}
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/domain/ForexRate.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/domain/ForexRate.kt
new file mode 100644
index 0000000..286e35f
--- /dev/null
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/domain/ForexRate.kt
@@ -0,0 +1,11 @@
+package me.sujanpoudel.playdeals.domain
+
+import java.time.OffsetDateTime
+
+// Rates are USD based
+data class ForexRate(
+ val timestamp: OffsetDateTime,
+ val rates: List
+)
+
+data class ConversionRate(val currency: String, val rate: Float)
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/BackgroundJobsVerticle.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/BackgroundJobsVerticle.kt
index afdb2dd..2b46299 100644
--- a/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/BackgroundJobsVerticle.kt
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/BackgroundJobsVerticle.kt
@@ -1,6 +1,7 @@
package me.sujanpoudel.playdeals.jobs
import io.vertx.kotlin.coroutines.CoroutineVerticle
+import me.sujanpoudel.playdeals.repositories.KeyValuesRepository
import org.jobrunr.configuration.JobRunr
import org.jobrunr.configuration.JobRunrConfiguration
import org.jobrunr.scheduling.JobRequestScheduler
@@ -14,16 +15,22 @@ class BackgroundJobsVerticle(
) : CoroutineVerticle(), DIAware {
private val jobRequestScheduler by instance()
+ private val keyValuesRepository by instance()
override suspend fun start() {
direct.instance()
setupRecurringJobs()
}
- private fun setupRecurringJobs() {
+ private suspend fun setupRecurringJobs() {
jobRequestScheduler.createRecurrently(RedditPostsScrapper.Request())
jobRequestScheduler.createRecurrently(AndroidAppExpiryCheckScheduler.Request())
jobRequestScheduler.createRecurrently(DealSummarizer.Request())
+ jobRequestScheduler.createRecurrently(ForexFetcher.Request())
+
+ if (keyValuesRepository.getForexRate() == null) {
+ jobRequestScheduler.enqueue(ForexFetcher.Request.immediate())
+ }
}
override suspend fun stop() {
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/ForexFetcher.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/ForexFetcher.kt
new file mode 100644
index 0000000..5d87dd3
--- /dev/null
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/jobs/ForexFetcher.kt
@@ -0,0 +1,91 @@
+package me.sujanpoudel.playdeals.jobs
+
+import io.vertx.core.json.Json
+import io.vertx.ext.web.client.WebClient
+import io.vertx.ext.web.client.WebClientOptions
+import io.vertx.kotlin.coroutines.await
+import me.sujanpoudel.playdeals.Conf
+import me.sujanpoudel.playdeals.common.SIMPLE_NAME
+import me.sujanpoudel.playdeals.common.loggingExecutionTime
+import me.sujanpoudel.playdeals.domain.ConversionRate
+import me.sujanpoudel.playdeals.domain.ForexRate
+import me.sujanpoudel.playdeals.logger
+import me.sujanpoudel.playdeals.repositories.KeyValuesRepository
+import org.jobrunr.jobs.lambdas.JobRequest
+import org.jobrunr.scheduling.RecurringJobBuilder
+import org.kodein.di.DI
+import org.kodein.di.DIAware
+import org.kodein.di.instance
+import java.time.Duration
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.util.UUID
+
+class ForexFetcher(
+ override val di: DI,
+ private val conf: Conf
+) : CoJobRequestHandler(), DIAware {
+
+ private val backgroundJobsVerticle by instance()
+
+ private val webClient by lazy {
+ WebClient.create(
+ backgroundJobsVerticle.vertx,
+ WebClientOptions().setSsl(false).setDefaultHost("api.exchangeratesapi.io")
+ )
+ }
+
+ private val repository by instance()
+
+ override suspend fun handleRequest(jobRequest: Request): Unit = loggingExecutionTime(
+ "$SIMPLE_NAME:: handleRequest"
+ ) {
+ val rates = getForexRates()
+ logger.info("got ${rates.rates.size} forex rate")
+ repository.saveForexRate(rates)
+ }
+
+ private suspend fun getForexRates(): ForexRate {
+ val response = webClient.get("/v1/latest?access_key=${conf.forexApiKey}&format=1&base=EUR")
+ .send()
+ .await()
+ .bodyAsString()
+ .let {
+ Json.decodeValue(it) as io.vertx.core.json.JsonObject
+ }
+
+ val epochSeconds = response.getLong("timestamp")
+ val usdRate = response.getJsonObject("rates").getNumber("USD").toFloat()
+
+ return ForexRate(
+ timestamp = OffsetDateTime.ofInstant(java.time.Instant.ofEpochSecond(epochSeconds), ZoneOffset.UTC),
+ rates = response.getJsonObject("rates").map {
+ ConversionRate(it.key, (it.value as Number).toFloat() / usdRate)
+ }
+ )
+ }
+
+ class Request private constructor() : JobRequest {
+ override fun getJobRequestHandler() = ForexFetcher::class.java
+
+ companion object {
+ private val JOB_ID: UUID = UUID.nameUUIDFromBytes("ForexFetch".toByteArray())
+ operator fun invoke(): RecurringJobBuilder = RecurringJobBuilder.aRecurringJob()
+ .withJobRequest(Request())
+ .withName("ForexFetch")
+ .withId(JOB_ID.toString())
+ .withDuration(Duration.ofDays(1))
+ .withAmountOfRetries(3)
+
+ fun immediate(): JobRequest = Request()
+ }
+ }
+}
+
+private const val KEY_FOREX_RATE = "FOREX_RATE"
+
+suspend fun KeyValuesRepository.getForexRate(): ForexRate? = get(KEY_FOREX_RATE)?.let {
+ Json.decodeValue(it, ForexRate::class.java)
+}
+
+suspend fun KeyValuesRepository.saveForexRate(forexRate: ForexRate) = set(KEY_FOREX_RATE, Json.encode(forexRate))
diff --git a/backend/src/main/kotlin/me/sujanpoudel/playdeals/usecases/GetForexUseCase.kt b/backend/src/main/kotlin/me/sujanpoudel/playdeals/usecases/GetForexUseCase.kt
new file mode 100644
index 0000000..a9ddfbe
--- /dev/null
+++ b/backend/src/main/kotlin/me/sujanpoudel/playdeals/usecases/GetForexUseCase.kt
@@ -0,0 +1,16 @@
+package me.sujanpoudel.playdeals.usecases
+
+import me.sujanpoudel.playdeals.domain.ForexRate
+import me.sujanpoudel.playdeals.jobs.getForexRate
+import me.sujanpoudel.playdeals.repositories.KeyValuesRepository
+import org.kodein.di.DI
+import org.kodein.di.instance
+
+class GetForexUseCase(di: DI) : UseCase {
+
+ private val appDealsRepository by di.instance()
+
+ override suspend fun doExecute(input: Unit): ForexRate? {
+ return appDealsRepository.getForexRate()
+ }
+}
diff --git a/backend/src/test/kotlin/me/sujanpoudel/playdeals/api/forex/GetForexApiTest.kt b/backend/src/test/kotlin/me/sujanpoudel/playdeals/api/forex/GetForexApiTest.kt
new file mode 100644
index 0000000..5b40a58
--- /dev/null
+++ b/backend/src/test/kotlin/me/sujanpoudel/playdeals/api/forex/GetForexApiTest.kt
@@ -0,0 +1,51 @@
+package me.sujanpoudel.playdeals.api.forex
+
+import io.kotest.matchers.shouldBe
+import io.vertx.core.Vertx
+import io.vertx.core.json.JsonObject
+import io.vertx.kotlin.coroutines.await
+import me.sujanpoudel.playdeals.IntegrationTest
+import me.sujanpoudel.playdeals.domain.ConversionRate
+import me.sujanpoudel.playdeals.domain.ForexRate
+import me.sujanpoudel.playdeals.get
+import me.sujanpoudel.playdeals.jobs.saveForexRate
+import me.sujanpoudel.playdeals.repositories.KeyValuesRepository
+import org.junit.jupiter.api.Test
+import java.time.OffsetDateTime
+
+class GetForexApiTest(vertx: Vertx) : IntegrationTest(vertx) {
+ @Test
+ fun `should return forex if there is data`() = runTest {
+ val repository = di.get()
+
+ val forexRate = ForexRate(OffsetDateTime.now(), listOf(ConversionRate("USD", 1.1f)))
+
+ repository.saveForexRate(forexRate)
+
+ val response = httpClient.get("/api/forex")
+ .send()
+ .await()
+ .bodyAsJsonObject()
+
+ response.getJsonObject("data").also { data ->
+ OffsetDateTime.parse(data.getString("timestamp")).toEpochSecond() shouldBe forexRate.timestamp.toEpochSecond()
+ data.getJsonArray("rates").also { rates ->
+ rates.size() shouldBe 1
+ (rates.first() as JsonObject).also { rate ->
+ rate.getString("currency") shouldBe "USD"
+ rate.getFloat("rate") shouldBe 1.1f
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `should return null if there is no data`() = runTest {
+ val response = httpClient.get("/api/forex")
+ .send()
+ .await()
+ .bodyAsJsonObject()
+
+ response.getJsonObject("data") shouldBe null
+ }
+}
diff --git a/helm/backend/templates/deployment.yaml b/helm/backend/templates/deployment.yaml
index 9d36e2d..3c27230 100644
--- a/helm/backend/templates/deployment.yaml
+++ b/helm/backend/templates/deployment.yaml
@@ -52,6 +52,8 @@ spec:
value: {{ .Values.backgroundTask.dashboardPass | quote }}
- name: "FIREBASE_ADMIN_AUTH_CREDENTIALS"
value: {{ .Values.firebaseAdminAuthCredential | quote }}
+ - name: "FOREX_API_KEY"
+ value: {{ .Values.forexApiKey | quote }}
ports:
- name: api
containerPort: 8888
diff --git a/justfile b/justfile
index 9015e9e..f39a89f 100644
--- a/justfile
+++ b/justfile
@@ -43,6 +43,7 @@ helm-upgrade imageTag=tag:
--set backgroundTask.dashboardUser=$DASHBOARD_USER \
--set backgroundTask.dashboardPass=$DASHBOARD_PASS \
--set firebaseAdminAuthCredential=$FIREBASE_ADMIN_AUTH_CREDENTIALS \
+ --set forexApiKey=$FOREX_API_KEY \
--set image.tag={{imageTag}} \
--set image.repository={{imageRepo}}