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) - | Get it on Google play | Download Apk | |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------:| @@ -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}}