Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forex task #47

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ TAGS
tests.iml
# Auto-copied by sbt-microsites
docs/src/main/tut/contributing.md
.sdkmanrc
69 changes: 69 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Forex

A micro-service which proxies requests to a rate service.

## API

`/rates?from=currency&to=currency` - returns rate for provided currencies

Available currencies:

- AUD
- CAD
- CHF
- EUR
- GBP
- NZD
- JPY
- SGD
- USD

## Internals

Internally the service uses in-memory cache with `EXPIRE_PERIOD` ttl. When request comes, it tries to get data from the cache.
If it doesn't have a rate for provided currencies, the service sends requests to the rate API and obtains rates for all
possible pairs of supported currencies and put them into the cache.

## Running

### Prerequisites

You should have installed and running docker

### Starting the app

1. Run image [one-frame](https://hub.docker.com/r/paidyinc/one-frame) by a command:
```shell
docker run -p 8087:8080 paidyinc/one-frame
```
2. Run the app by a command:
```shell
sbt run
```

If you want to set env variables you should use next command(example):
```shell
API_URI=http://localhost:9090 sbt run
```

Available ENV variables:
* API_URI - url to API of rate service. Format is: `http://host:port`
* API_TOKEN - auth token of the rate service API. It is passed in a header `token`
* EXPIRE_PERIOD - cache entry ttl. Format is: `length units`, e.g. `5 minutes`. PLease note this value is being
validated in order to prevent exceeding API requests limit. If you enter too small number, for example, `1 minute` then the service won't start.
It happens because API requests limit is 1000 requests per day and validation looks like: `24 * 60 / EXPIRE_PERIOD (in minutes) < 1000`

## Testing

### Prerequisites

You should have installed and running docker

- simple tests running
```shell
sbt test
```
- running tests with coverage
```shell
sbt coverage test coverageReport coverageAggregate
```
13 changes: 10 additions & 3 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ libraryDependencies ++= Seq(
Libraries.cats,
Libraries.catsEffect,
Libraries.fs2,
Libraries.fs2SttpBackend,
Libraries.sttpCirce,
Libraries.scalaCacheEffect,
Libraries.scalaCacheCaffeine,
Libraries.enumeratum,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sCirce,
Expand All @@ -63,7 +68,9 @@ libraryDependencies ++= Seq(
Libraries.circeParser,
Libraries.pureConfig,
Libraries.logback,
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Libraries.catsScalaCheck % Test
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Libraries.catsScalaCheck % Test,
Libraries.testContainers % Test,
Libraries.catsEffectScalaTest % Test
)
79 changes: 47 additions & 32 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,62 @@ import sbt._
object Dependencies {

object Versions {
val cats = "2.5.0"
val catsEffect = "2.4.1"
val fs2 = "2.5.4"
val http4s = "0.21.22"
val circe = "0.13.0"
val pureConfig = "0.14.1"

val kindProjector = "0.10.3"
val logback = "1.2.3"
val scalaCheck = "1.15.3"
val scalaTest = "3.2.7"
val catsScalaCheck = "0.3.0"
val cats = "2.5.0"
val catsEffect = "2.5.3"
val fs2 = "2.5.4"
val sttpBackend = "3.3.6"
val http4s = "0.21.22"
val circe = "0.13.0"
val pureConfig = "0.14.1"
val scalaCache = "0.28.0"
val enumeratum = "1.7.3"

val kindProjector = "0.10.3"
val logback = "1.2.3"
val scalaCheck = "1.15.3"
val scalaTest = "3.2.7"
val catsScalaCheck = "0.3.0"
val testContainers = "0.40.12"
val scalatestCats = "0.5.4"
}

object Libraries {
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 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 http4sCirce = http4s("http4s-circe")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
lazy val circeGenericExt = circe("circe-generic-extras")
lazy val circeParser = circe("circe-parser")
lazy val pureConfig = "com.github.pureconfig" %% "pureconfig" % Versions.pureConfig
def circe(artifact: String): ModuleID = "io.circe" %% artifact % Versions.circe
def http4s(artifact: String): ModuleID = "org.http4s" %% artifact % Versions.http4s
def scalaCache(artifact: String): ModuleID = "com.github.cb372" %% artifact % Versions.scalaCache
def sttpBackend(artifact: String): ModuleID = "com.softwaremill.sttp.client3" %% artifact % Versions.sttpBackend

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 fs2SttpBackend = sttpBackend("async-http-client-backend-fs2-ce2")
lazy val sttpCirce = sttpBackend("circe")
lazy val enumeratum = "com.beachape" %% "enumeratum" % Versions.enumeratum

lazy val scalaCacheEffect = scalaCache("scalacache-cats-effect")
lazy val scalaCacheCaffeine = scalaCache("scalacache-caffeine")

lazy val http4sDsl = http4s("http4s-dsl")
lazy val http4sServer = http4s("http4s-blaze-server")
lazy val http4sCirce = http4s("http4s-circe")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
lazy val circeGenericExt = circe("circe-generic-extras")
lazy val circeParser = circe("circe-parser")
lazy val pureConfig = "com.github.pureconfig" %% "pureconfig" % Versions.pureConfig

// Compiler plugins
lazy val kindProjector = "org.typelevel" %% "kind-projector" % Versions.kindProjector
lazy val kindProjector = "org.typelevel" %% "kind-projector" % Versions.kindProjector

// Runtime
lazy val logback = "ch.qos.logback" % "logback-classic" % Versions.logback
lazy val logback = "ch.qos.logback" % "logback-classic" % Versions.logback

// Test
lazy val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalaTest
lazy val scalaCheck = "org.scalacheck" %% "scalacheck" % Versions.scalaCheck
lazy val catsScalaCheck = "io.chrisdavenport" %% "cats-scalacheck" % Versions.catsScalaCheck
lazy val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalaTest
lazy val scalaCheck = "org.scalacheck" %% "scalacheck" % Versions.scalaCheck
lazy val catsScalaCheck = "io.chrisdavenport" %% "cats-scalacheck" % Versions.catsScalaCheck
lazy val testContainers = "com.dimafeng" %% "testcontainers-scala-scalatest" % Versions.testContainers
lazy val catsEffectScalaTest = "com.codecommit" %% "cats-effect-testing-scalatest" % Versions.scalatestCats
}

}
8 changes: 5 additions & 3 deletions forex-mtl/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
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.lucidchart" % "sbt-scalafmt-coursier" % "1.16")
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6")
13 changes: 13 additions & 0 deletions forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,18 @@ app {
port = 8080
timeout = 40 seconds
}

storage {
expire-after = "5 minutes"
expire-after = ${?EXPIRE_PERIOD}
api-limit = 1000
}

provider {
uri = "http://0.0.0.0:8087"
uri = ${API_URI}
token = "10dc303535874aeccc86a8251e6992f5"
token = ${?API_TOKEN}
}
}

14 changes: 10 additions & 4 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
package forex

import scala.concurrent.ExecutionContext

import cats.effect._
import forex.config._
import fs2.Stream
import org.http4s.server.blaze.BlazeServerBuilder
import sttp.client3.SttpBackend
import sttp.client3.asynchttpclient.fs2.AsyncHttpClientFs2Backend

object Main extends IOApp {

override def run(args: List[String]): IO[ExitCode] =
new Application[IO].stream(executionContext).compile.drain.as(ExitCode.Success)
(for {
blocker <- Blocker.apply[IO]
backend <- AsyncHttpClientFs2Backend.resource[IO](blocker)
} yield backend).use { backend =>
new Application[IO].stream(executionContext, backend).compile.drain.as(ExitCode.Success)
}

}

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

def stream(ec: ExecutionContext): Stream[F, Unit] =
def stream(ec: ExecutionContext, backend: SttpBackend[F, _]): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)
module = new Module[F](config, backend)
_ <- BlazeServerBuilder[F](ec)
.bindHttp(config.http.port, config.http.host)
.withHttpApp(module.httpApp)
Expand Down
9 changes: 6 additions & 3 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import forex.programs._
import org.http4s._
import org.http4s.implicits._
import org.http4s.server.middleware.{ AutoSlash, Timeout }
import sttp.client3.SttpBackend

class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {
class Module[F[_]: Concurrent: Timer](config: ApplicationConfig, backend: SttpBackend[F, _]) {

private val ratesService: RatesService[F] = RatesServices.dummy[F]
private val storageService: StorageService[F] = StorageService.inMemory[F](config.storage)

private val ratesProgram: RatesProgram[F] = RatesProgram[F](ratesService)
private val ratesService: RatesService[F] = RatesServices.live[F](config.provider, backend)

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

private val ratesHttpRoutes: HttpRoutes[F] = new RatesHttpRoutes[F](ratesProgram).routes

Expand Down
16 changes: 16 additions & 0 deletions forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
package forex.config

import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration

case class ApplicationConfig(
http: HttpConfig,
storage: StorageConfig,
provider: ProviderConfig
)

case class StorageConfig(expireAfter: FiniteDuration, apiLimit: Int) {

/**
* Ensures that we don't exceed API queries limit with provided pull period
*/
require(
24 * 60 / expireAfter.toUnit(TimeUnit.MINUTES) < apiLimit,
s"The service might exceed API limit with an expire period ${expireAfter.toString()}"
)
}

case class ProviderConfig(uri: String, token: String)

case class HttpConfig(
host: String,
port: Int,
Expand Down
10 changes: 4 additions & 6 deletions forex-mtl/src/main/scala/forex/config/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import pureconfig.generic.auto._
object Config {

/**
* @param path the property path inside the default configuration
*/
def stream[F[_]: Sync](path: String): Stream[F, ApplicationConfig] = {
Stream.eval(Sync[F].delay(
ConfigSource.default.at(path).loadOrThrow[ApplicationConfig]))
}
* @param path the property path inside the default configuration
*/
def stream[F[_]: Sync](path: String): Stream[F, ApplicationConfig] =
Stream.eval(Sync[F].delay(ConfigSource.default.at(path).loadOrThrow[ApplicationConfig]))

}
7 changes: 5 additions & 2 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package forex.domain

import cats.Show
import enumeratum._

sealed trait Currency
sealed trait Currency extends EnumEntry

object Currency {
object Currency extends Enum[Currency] {
case object AUD extends Currency
case object CAD extends Currency
case object CHF extends Currency
Expand All @@ -15,6 +16,8 @@ object Currency {
case object SGD extends Currency
case object USD extends Currency

val values = findValues

implicit val show: Show[Currency] = Show.show {
case AUD => "AUD"
case CAD => "CAD"
Expand Down
4 changes: 2 additions & 2 deletions forex-mtl/src/main/scala/forex/domain/Price.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ package forex.domain
case class Price(value: BigDecimal) extends AnyVal

object Price {
def apply(value: Integer): Price =
Price(BigDecimal(value))
def apply(value: Int): Price = Price(BigDecimal(value))
def apply(value: Double): Price = Price(BigDecimal(value))
}
7 changes: 7 additions & 0 deletions forex-mtl/src/main/scala/forex/domain/Rate.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package forex.domain

import cats.Show
import cats.syntax.all._

case class Rate(
pair: Rate.Pair,
price: Price,
timestamp: Timestamp
)

object Rate {
object Pair {
implicit def show: Show[Pair] = pair => pair.from.show + pair.to.show
}

final case class Pair(
from: Currency,
to: Currency
Expand Down
2 changes: 1 addition & 1 deletion forex-mtl/src/main/scala/forex/http/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ package object http {
implicit def enumDecoder[A: EnumerationDecoder]: Decoder[A] = implicitly

implicit def jsonDecoder[A <: Product: Decoder, F[_]: Sync]: EntityDecoder[F, A] = jsonOf[F, A]
implicit def jsonEncoder[A <: Product: Encoder, F[_]]: EntityEncoder[F, A] = jsonEncoderOf[F, A]
implicit def jsonEncoder[A <: Product: Encoder, F[_]]: EntityEncoder[F, A] = jsonEncoderOf[F, A]

}
Loading