Skip to content

Commit

Permalink
Merge pull request #47 from psuzn/forex-rate
Browse files Browse the repository at this point in the history
Forex api
  • Loading branch information
psuzn authored Jan 3, 2024
2 parents 3a552ff + e0233a0 commit 08cdb08
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 22 deletions.
1 change: 1 addition & 0 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
49 changes: 28 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)


| <a href="https://play.google.com/store/apps/details?id=me.sujanpoudel.playdeals"> <img src="media/badge-get-on-google-play.png" width="200" alt="Get it on Google play"> </a> | <a href="https://github.com/psuzn/app-deals/releases/latest"> <img src="media/badge-download-apk.png" width="160" alt="Download Apk"> </a> |
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------:|

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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> {
FirebaseOptions.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions backend/src/main/kotlin/me/sujanpoudel/playdeals/api/ForexRate.kt
Original file line number Diff line number Diff line change
@@ -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<GetForexUseCase>(),
toContext = { },
toInput = { }
) {
ctx.json(jsonResponse(data = it))
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ConversionRate>
)

data class ConversionRate(val currency: String, val rate: Float)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,16 +15,22 @@ class BackgroundJobsVerticle(
) : CoroutineVerticle(), DIAware {

private val jobRequestScheduler by instance<JobRequestScheduler>()
private val keyValuesRepository by instance<KeyValuesRepository>()

override suspend fun start() {
direct.instance<JobRunrConfiguration.JobRunrConfigurationResult>()
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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ForexFetcher.Request>(), DIAware {

private val backgroundJobsVerticle by instance<BackgroundJobsVerticle>()

private val webClient by lazy {
WebClient.create(
backgroundJobsVerticle.vertx,
WebClientOptions().setSsl(false).setDefaultHost("api.exchangeratesapi.io")
)
}

private val repository by instance<KeyValuesRepository>()

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))
Original file line number Diff line number Diff line change
@@ -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<Unit, ForexRate?> {

private val appDealsRepository by di.instance<KeyValuesRepository>()

override suspend fun doExecute(input: Unit): ForexRate? {
return appDealsRepository.getForexRate()
}
}
Original file line number Diff line number Diff line change
@@ -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<KeyValuesRepository>()

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
}
}
2 changes: 2 additions & 0 deletions helm/backend/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down

0 comments on commit 08cdb08

Please sign in to comment.