Skip to content
Open
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
28 changes: 28 additions & 0 deletions forex-mtl/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
FROM hseeberger/scala-sbt:17.0.2_1.6.2_3.1.1 AS builder

WORKDIR /app

# Копируем sbt-конфиги отдельно, чтобы кэшировать зависимости
COPY . .

# Скачиваем зависимости (это ускоряет последующие билды)
RUN sbt update

# Копируем остальной проект

# Собираем fat-jar с помощью sbt-assembly
RUN sbt clean assembly


# ==== Stage 2: run ====
FROM eclipse-temurin:17-jre-alpine

WORKDIR /app

# Копируем собранный jar из builder stage
COPY --from=builder /app/target/scala-*/forex-assembly*.jar app.jar

EXPOSE 8081

# Запуск приложения
ENTRYPOINT ["java", "-jar", "app.jar"]
29 changes: 29 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Briefly — the solution idea (key points)

Abstract rate providers via RatesProvider (traits/interfaces): OneFrameInterpreter (live), + the ability to easily add fallback provider(s).

Caching: local in-memory cache (Caffeine or Guava) with a TTL of 5 minutes => if a pair is requested again, the service serves it from the cache without going to One-Frame. This makes it easy to exceed 1000 requests/day under real traffic (depending on the number of unique pairs).

Prefetch / background refresh: A background task (scheduler) that periodically (e.g., every 4-5 minutes) downloads/updates the most popular pairs (configurable top N). This significantly reduces the number of live calls.

Fallbacks / multi-provider: If One-Frame is exhausted, use an alternative provider (e.g., exchangerate.host or another public API). The task states that other dependencies can be included, so this is a safe path.

Security and diagnostics: metrics (counts of external calls, cache hits/misses), descriptive errors (400/404/5xx), timeouts, and retry (exponential backoff) on failures.

Why this satisfies the requirements

rate <= 5 minutes: cache TTL = 5 minutes ensures freshness.

10,000 successful requests/day with 1 token: If many clients request the same pairs (which is true for internal services), a cache + batched OneFrame requests + prefetch will yield significant gains. A worst-case scenario (10,000 unique pairs) will require a fallback to other providers or a contract with One-Frame (to increase the limit) – more on that below.

How to run:

You need run docker file:

docker compose up --build

after that you can make request in format:

curl --location 'http://127.0.0.1:8081/rates?from=USD&to=EUR'

or simply using postman
5 changes: 4 additions & 1 deletion forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ libraryDependencies ++= Seq(
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sCirce,
Libraries.http4sClient,
Libraries.circeCore,
Libraries.circeGeneric,
Libraries.circeGenericExt,
Expand All @@ -65,5 +66,7 @@ libraryDependencies ++= Seq(
Libraries.logback,
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Libraries.catsScalaCheck % Test
Libraries.catsScalaCheck % Test,

"redis.clients" % "jedis" % "4.3.1"
)
31 changes: 31 additions & 0 deletions forex-mtl/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
version: "3.9"

services:
# --- Redis ---
redis:
image: redis:6.2
container_name: redis
ports:
- "6379:6379"
restart: always

# --- One-Frame Mock Service ---
oneframe:
image: paidyinc/one-frame:latest
container_name: oneframe
ports:
- "8080:8080"
restart: always

# --- Scala Forex Application ---
app:
build:
context: .
dockerfile: Dockerfile
container_name: forex-app
depends_on:
- redis
- oneframe
ports:
- "8081:8081"
restart: on-failure
3 changes: 2 additions & 1 deletion forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ object Dependencies {
def circe(artifact: String): ModuleID = "io.circe" %% artifact % Versions.circe
def http4s(artifact: String): ModuleID = "org.http4s" %% artifact % Versions.http4s

lazy val cats = "org.typelevel" %% "cats-core" % Versions.cats
lazy val cats = "org.typelevel" %% "cats-core" % Versions.cats
lazy val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect
lazy val fs2 = "co.fs2" %% "fs2-core" % Versions.fs2

lazy val http4sDsl = http4s("http4s-dsl")
lazy val http4sServer = http4s("http4s-blaze-server")
lazy val http4sClient = http4s("http4s-blaze-client")
lazy val http4sCirce = http4s("http4s-circe")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
Expand Down
1 change: 1 addition & 0 deletions forex-mtl/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % "1.16")
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")
27 changes: 25 additions & 2 deletions forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
app {
http {
host = "0.0.0.0"
port = 8080
port = 8081
timeout = 40 seconds
}
}

oneframe {
base-url = "http://oneframe:8080"
token = "10dc303535874aeccc86a8251e6992f5"
request-timeout-ms = 5000
batch-size = 50
}

redis {
host = "redis"
port = 6379
ttl-minutes = 5
}

cache {
ttl-minutes = 5
max-size = 10000
}

prefetch {
enabled = true
interval-minutes = 4
hot-pairs = ["EURUSD", "USDJPY", "GBPUSD", "AUDUSD", "USDCAD"]
}
}
30 changes: 24 additions & 6 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@

package forex


import cats.effect.implicits._
import scala.concurrent.ExecutionContext
import cats.effect._
import forex.config._
import forex.services.rates.interpreters.RatesCacheJob
import fs2.Stream
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.blaze.client.BlazeClientBuilder
import redis.clients.jedis.JedisPool


object Main extends IOApp {

Expand All @@ -13,16 +20,27 @@ object Main extends IOApp {

}


class Application[F[_]: ConcurrentEffect: Timer] {


def stream(ec: ExecutionContext): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)
client <- Stream.resource(BlazeClientBuilder[F](ec).resource)
redisCmds <-
Stream.resource(Resource.make(Sync[F].delay(new JedisPool(config.redis.host, config.redis.port)))(pool =>
Sync[F].delay(pool.close())
))
oneFrameClient = new forex.services.rates.interpreters.OneFrameLive[F](config.oneframe.baseUrl, config.oneframe.token, client, 3)
ttl = config.redis.ttlMinutes * 60L
ratesService = new forex.services.rates.interpreters.RedisCached[F](oneFrameClient, redisCmds, ttl)
job = new RatesCacheJob[F](oneFrameClient, ratesService)
module = new Module[F](config, Some(ratesService))
_ <- Stream.eval(job.start.start)
_ <- BlazeServerBuilder[F](ec)
.bindHttp(config.http.port, config.http.host)
.withHttpApp(module.httpApp)
.serve
.bindHttp(config.http.port, config.http.host)
.withHttpApp(module.httpApp)
.serve
} yield ()

}
}
7 changes: 4 additions & 3 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import org.http4s._
import org.http4s.implicits._
import org.http4s.server.middleware.{ AutoSlash, Timeout }

class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {
class Module[F[_]: Concurrent: Timer](config: ApplicationConfig, ratesServiceOpt: Option[RatesService[F]] = None) {

private val ratesService: RatesService[F] = RatesServices.dummy[F]
// TODO: wire live OneFrame + Redis cache. Currently using dummy
private val ratesService: RatesService[F] = ratesServiceOpt.getOrElse(RatesServices.dummy[F])

private val ratesProgram: RatesProgram[F] = RatesProgram[F](ratesService)

Expand All @@ -34,4 +35,4 @@ class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {

val httpApp: HttpApp[F] = appMiddleware(routesMiddleware(http).orNotFound)

}
}
28 changes: 28 additions & 0 deletions forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@ import scala.concurrent.duration.FiniteDuration

case class ApplicationConfig(
http: HttpConfig,
oneframe: OneFrameConfig,
redis: RedisConfig,
cache: CacheConfig,
prefetch: PrefetchConfig
)

case class HttpConfig(
host: String,
port: Int,
timeout: FiniteDuration
)

case class OneFrameConfig(
baseUrl: String,
token: String,
requestTimeoutMs: Int,
batchSize: Int
)

case class RedisConfig(
host: String,
port: Int,
ttlMinutes: Int
)

case class CacheConfig(
ttlMinutes: Int,
maxSize: Int
)

case class PrefetchConfig(
enabled: Boolean,
intervalMinutes: Int,
hotPairs: List[String]
)
2 changes: 2 additions & 0 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ object Currency {
case object SGD extends Currency
case object USD extends Currency

val all: List[Currency] = List(AUD, CAD, CHF, EUR, GBP, NZD, JPY, SGD, USD)

implicit val show: Show[Currency] = Show.show {
case AUD => "AUD"
case CAD => "CAD"
Expand Down
2 changes: 1 addition & 1 deletion forex-mtl/src/main/scala/forex/programs/rates/errors.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package forex.programs.rates

import forex.services.rates.errors.{ Error => RatesServiceError }
import forex.services.rates.errors.{ CustomError => RatesServiceError }

object errors {

Expand Down
3 changes: 2 additions & 1 deletion forex-mtl/src/main/scala/forex/services/rates/algebra.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import forex.domain.Rate
import errors._

trait Algebra[F[_]] {
def get(pair: Rate.Pair): F[Error Either Rate]
def get(pair: Rate.Pair): F[CustomError Either Rate]
// def getSeq(pair: Seq[Rate.Pair]): F[CustomError Either Seq[Rate]]
}
6 changes: 3 additions & 3 deletions forex-mtl/src/main/scala/forex/services/rates/errors.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package forex.services.rates

object errors {

sealed trait Error
object Error {
final case class OneFrameLookupFailed(msg: String) extends Error
sealed trait CustomError
object CustomError {
final case class OneFrameLookupFailed(msg: String) extends CustomError
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import forex.services.rates.errors._

class OneFrameDummy[F[_]: Applicative] extends Algebra[F] {

override def get(pair: Rate.Pair): F[Error Either Rate] =
Rate(pair, Price(BigDecimal(100)), Timestamp.now).asRight[Error].pure[F]
override def get(pair: Rate.Pair): F[CustomError Either Rate] =
Rate(pair, Price(BigDecimal(100)), Timestamp.now).asRight[CustomError].pure[F]

}
Loading