diff --git a/.gitignore b/.gitignore index cc9152f8..20142f72 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ TAGS tests.iml # Auto-copied by sbt-microsites docs/src/main/tut/contributing.md +.sdkmanrc diff --git a/forex-mtl/README.md b/forex-mtl/README.md new file mode 100644 index 00000000..d722fe1d --- /dev/null +++ b/forex-mtl/README.md @@ -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 + ``` diff --git a/forex-mtl/build.sbt b/forex-mtl/build.sbt index 70ff17f3..b939bfdb 100644 --- a/forex-mtl/build.sbt +++ b/forex-mtl/build.sbt @@ -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, @@ -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 ) diff --git a/forex-mtl/project/Dependencies.scala b/forex-mtl/project/Dependencies.scala index 6d8336c6..b3385fc1 100644 --- a/forex-mtl/project/Dependencies.scala +++ b/forex-mtl/project/Dependencies.scala @@ -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 } } diff --git a/forex-mtl/project/plugins.sbt b/forex-mtl/project/plugins.sbt index f4715a30..7291b7f2 100644 --- a/forex-mtl/project/plugins.sbt +++ b/forex-mtl/project/plugins.sbt @@ -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") diff --git a/forex-mtl/src/main/resources/application.conf b/forex-mtl/src/main/resources/application.conf index b2af6efd..255f4eea 100644 --- a/forex-mtl/src/main/resources/application.conf +++ b/forex-mtl/src/main/resources/application.conf @@ -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} + } } diff --git a/forex-mtl/src/main/scala/forex/Main.scala b/forex-mtl/src/main/scala/forex/Main.scala index 41c8ba61..f6a17798 100644 --- a/forex-mtl/src/main/scala/forex/Main.scala +++ b/forex-mtl/src/main/scala/forex/Main.scala @@ -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) diff --git a/forex-mtl/src/main/scala/forex/Module.scala b/forex-mtl/src/main/scala/forex/Module.scala index 3bc47d58..2c6d2dd6 100644 --- a/forex-mtl/src/main/scala/forex/Module.scala +++ b/forex-mtl/src/main/scala/forex/Module.scala @@ -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 diff --git a/forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala b/forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala index eff0fad7..ed897988 100644 --- a/forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala +++ b/forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala @@ -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, diff --git a/forex-mtl/src/main/scala/forex/config/Config.scala b/forex-mtl/src/main/scala/forex/config/Config.scala index 0181788e..3ad5231c 100644 --- a/forex-mtl/src/main/scala/forex/config/Config.scala +++ b/forex-mtl/src/main/scala/forex/config/Config.scala @@ -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])) } diff --git a/forex-mtl/src/main/scala/forex/domain/Currency.scala b/forex-mtl/src/main/scala/forex/domain/Currency.scala index a6f2857d..8ba041f4 100644 --- a/forex-mtl/src/main/scala/forex/domain/Currency.scala +++ b/forex-mtl/src/main/scala/forex/domain/Currency.scala @@ -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 @@ -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" diff --git a/forex-mtl/src/main/scala/forex/domain/Price.scala b/forex-mtl/src/main/scala/forex/domain/Price.scala index 7faea8c5..ab618e6a 100644 --- a/forex-mtl/src/main/scala/forex/domain/Price.scala +++ b/forex-mtl/src/main/scala/forex/domain/Price.scala @@ -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)) } diff --git a/forex-mtl/src/main/scala/forex/domain/Rate.scala b/forex-mtl/src/main/scala/forex/domain/Rate.scala index 4a444003..23550892 100644 --- a/forex-mtl/src/main/scala/forex/domain/Rate.scala +++ b/forex-mtl/src/main/scala/forex/domain/Rate.scala @@ -1,5 +1,8 @@ package forex.domain +import cats.Show +import cats.syntax.all._ + case class Rate( pair: Rate.Pair, price: Price, @@ -7,6 +10,10 @@ case class Rate( ) object Rate { + object Pair { + implicit def show: Show[Pair] = pair => pair.from.show + pair.to.show + } + final case class Pair( from: Currency, to: Currency diff --git a/forex-mtl/src/main/scala/forex/http/package.scala b/forex-mtl/src/main/scala/forex/http/package.scala index 1ffafa5d..1705f9fd 100644 --- a/forex-mtl/src/main/scala/forex/http/package.scala +++ b/forex-mtl/src/main/scala/forex/http/package.scala @@ -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] } diff --git a/forex-mtl/src/main/scala/forex/http/rates/Protocol.scala b/forex-mtl/src/main/scala/forex/http/rates/Protocol.scala index 75391f9d..59ef1354 100644 --- a/forex-mtl/src/main/scala/forex/http/rates/Protocol.scala +++ b/forex-mtl/src/main/scala/forex/http/rates/Protocol.scala @@ -1,22 +1,16 @@ -package forex.http -package rates +package forex.http.rates import forex.domain.Currency.show import forex.domain.Rate.Pair import forex.domain._ import io.circe._ import io.circe.generic.extras.Configuration -import io.circe.generic.extras.semiauto.deriveConfiguredEncoder +import io.circe.generic.extras.semiauto.deriveConfiguredCodec object Protocol { implicit val configuration: Configuration = Configuration.default.withSnakeCaseMemberNames - final case class GetApiRequest( - from: Currency, - to: Currency - ) - final case class GetApiResponse( from: Currency, to: Currency, @@ -24,16 +18,24 @@ object Protocol { timestamp: Timestamp ) - implicit val currencyEncoder: Encoder[Currency] = - Encoder.instance[Currency] { show.show _ andThen Json.fromString } + implicit val timestampEncoder: Codec[Timestamp] = + deriveConfiguredCodec[Timestamp] + + implicit val priceEncoder: Codec[Price] = deriveConfiguredCodec[Price] + + implicit val currencyEncoder: Codec[Currency] = + Codec.from( + Decoder.decodeString.map(Currency.fromString), + Encoder.instance[Currency] { show.show _ andThen Json.fromString } + ) - implicit val pairEncoder: Encoder[Pair] = - deriveConfiguredEncoder[Pair] + implicit val pairEncoder: Codec[Pair] = + deriveConfiguredCodec[Pair] - implicit val rateEncoder: Encoder[Rate] = - deriveConfiguredEncoder[Rate] + implicit val rateEncoder: Codec[Rate] = + deriveConfiguredCodec[Rate] - implicit val responseEncoder: Encoder[GetApiResponse] = - deriveConfiguredEncoder[GetApiResponse] + implicit val responseEncoder: Codec[GetApiResponse] = + deriveConfiguredCodec[GetApiResponse] } diff --git a/forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala b/forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala index b19ed4ce..6ef3823f 100644 --- a/forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala +++ b/forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala @@ -1,15 +1,22 @@ package forex.http.rates +import cats.implicits._ import forex.domain.Currency -import org.http4s.QueryParamDecoder -import org.http4s.dsl.impl.QueryParamDecoderMatcher +import org.http4s.{ ParseFailure, QueryParamDecoder } +import org.http4s.dsl.io.ValidatingQueryParamDecoderMatcher + +import scala.util.Try object QueryParams { private[http] implicit val currencyQueryParam: QueryParamDecoder[Currency] = - QueryParamDecoder[String].map(Currency.fromString) + QueryParamDecoder[String].emapValidatedNel { value => + Try(Currency.fromString(value)).toValidated + .leftMap(_ => ParseFailure(value, "Wrong parameter value")) + .toValidatedNel + } - object FromQueryParam extends QueryParamDecoderMatcher[Currency]("from") - object ToQueryParam extends QueryParamDecoderMatcher[Currency]("to") + object FromQueryParam extends ValidatingQueryParamDecoderMatcher[Currency]("from") + object ToQueryParam extends ValidatingQueryParamDecoderMatcher[Currency]("to") } diff --git a/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala b/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala index d91dcffb..cd09831d 100644 --- a/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala +++ b/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala @@ -1,11 +1,12 @@ -package forex.http -package rates +package forex.http.rates +import cats.implicits._ import cats.effect.Sync -import cats.syntax.flatMap._ +import cats.data.Validated._ +import forex.domain.{ Currency, Price, Timestamp } import forex.programs.RatesProgram import forex.programs.rates.{ Protocol => RatesProgramProtocol } -import org.http4s.HttpRoutes +import org.http4s.{ HttpRoutes, Response } import org.http4s.dsl.Http4sDsl import org.http4s.server.Router @@ -15,11 +16,25 @@ class RatesHttpRoutes[F[_]: Sync](rates: RatesProgram[F]) extends Http4sDsl[F] { private[http] val prefixPath = "/rates" + private val equalResponse: Currency => F[Response[F]] = curr => + Ok(GetApiResponse(curr, curr, Price(1), Timestamp.now)) + private val httpRoutes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root :? FromQueryParam(from) +& ToQueryParam(to) => - rates.get(RatesProgramProtocol.GetRatesRequest(from, to)).flatMap(Sync[F].fromEither).flatMap { rate => - Ok(rate.asGetApiResponse) - } + (from, to).tupled.fold( + err => BadRequest(err.map(_.message).mkString_("\n")), + validated => { + val (from, to) = validated + if (from == to) equalResponse(to) + else + rates + .get(RatesProgramProtocol.GetRatesRequest(from, to)) + .flatMap { + case Right(rate) => Ok(rate.asGetApiResponse) + case Left(err) => UnprocessableEntity(err.msg) + } + } + ) } val routes: HttpRoutes[F] = Router( diff --git a/forex-mtl/src/main/scala/forex/programs/rates/Program.scala b/forex-mtl/src/main/scala/forex/programs/rates/Program.scala index 528ee1f9..9fabd92e 100644 --- a/forex-mtl/src/main/scala/forex/programs/rates/Program.scala +++ b/forex-mtl/src/main/scala/forex/programs/rates/Program.scala @@ -1,24 +1,44 @@ package forex.programs.rates -import cats.Functor +import cats._ +import cats.implicits._ import cats.data.EitherT import errors._ import forex.domain._ -import forex.services.RatesService +import forex.services.{ RatesService, StorageService } -class Program[F[_]: Functor]( - ratesService: RatesService[F] +class Program[F[_]: Monad]( + ratesService: RatesService[F], + storageService: StorageService[F] ) extends Algebra[F] { override def get(request: Protocol.GetRatesRequest): F[Error Either Rate] = - EitherT(ratesService.get(Rate.Pair(request.from, request.to))).leftMap(toProgramError(_)).value + getOrRequest(Rate.Pair(request.from, request.to)) + private def getOrRequest(pair: Rate.Pair): F[Error Either Rate] = + storageService + .get(pair) + .flatMap { + case Some(r) => r.asRight[Error].pure[F] + case None => + EitherT(ratesService.getAll) + .leftMap(toProgramError) + .flatMap { rates => + EitherT.fromOptionF( + storageService.putAll(rates) *> + rates.find(_.pair == pair).pure[F], + Error.RateLookupFailed(s"Could not find a rate for the pair ${pair.show}"): Error + ) + } + .value + } } object Program { - def apply[F[_]: Functor]( - ratesService: RatesService[F] - ): Algebra[F] = new Program[F](ratesService) + def apply[F[_]: Monad]( + ratesService: RatesService[F], + storageService: StorageService[F] + ): Algebra[F] = new Program[F](ratesService, storageService) } diff --git a/forex-mtl/src/main/scala/forex/programs/rates/errors.scala b/forex-mtl/src/main/scala/forex/programs/rates/errors.scala index 39496b13..8dde174b 100644 --- a/forex-mtl/src/main/scala/forex/programs/rates/errors.scala +++ b/forex-mtl/src/main/scala/forex/programs/rates/errors.scala @@ -4,12 +4,16 @@ import forex.services.rates.errors.{ Error => RatesServiceError } object errors { - sealed trait Error extends Exception + sealed trait Error extends Exception { + def msg: String + } object Error { final case class RateLookupFailed(msg: String) extends Error } def toProgramError(error: RatesServiceError): Error = error match { case RatesServiceError.OneFrameLookupFailed(msg) => Error.RateLookupFailed(msg) + case RatesServiceError.ApiRequestFailed(msg) => Error.RateLookupFailed(msg) + case RatesServiceError.ParseFailure(msg) => Error.RateLookupFailed(msg) } } diff --git a/forex-mtl/src/main/scala/forex/services/package.scala b/forex-mtl/src/main/scala/forex/services/package.scala index aed4912d..8fec667e 100644 --- a/forex-mtl/src/main/scala/forex/services/package.scala +++ b/forex-mtl/src/main/scala/forex/services/package.scala @@ -1,6 +1,8 @@ package forex package object services { - type RatesService[F[_]] = rates.Algebra[F] - final val RatesServices = rates.Interpreters + type RatesService[F[_]] = rates.Algebra[F] + type StorageService[F[_]] = storage.Algebra[F] + final val RatesServices = rates.Interpreters + final val StorageService = storage.Interpreters } diff --git a/forex-mtl/src/main/scala/forex/services/rates/algebra.scala b/forex-mtl/src/main/scala/forex/services/rates/Algebra.scala similarity index 77% rename from forex-mtl/src/main/scala/forex/services/rates/algebra.scala rename to forex-mtl/src/main/scala/forex/services/rates/Algebra.scala index 8966dce5..2e0b9f50 100644 --- a/forex-mtl/src/main/scala/forex/services/rates/algebra.scala +++ b/forex-mtl/src/main/scala/forex/services/rates/Algebra.scala @@ -5,4 +5,5 @@ import errors._ trait Algebra[F[_]] { def get(pair: Rate.Pair): F[Error Either Rate] + def getAll: F[Error Either List[Rate]] } diff --git a/forex-mtl/src/main/scala/forex/services/rates/Interpreters.scala b/forex-mtl/src/main/scala/forex/services/rates/Interpreters.scala index e523ffab..03837aa8 100644 --- a/forex-mtl/src/main/scala/forex/services/rates/Interpreters.scala +++ b/forex-mtl/src/main/scala/forex/services/rates/Interpreters.scala @@ -1,8 +1,13 @@ package forex.services.rates -import cats.Applicative +import cats.{ Applicative, Monad } +import forex.config.ProviderConfig import interpreters._ +import sttp.client3.SttpBackend object Interpreters { def dummy[F[_]: Applicative]: Algebra[F] = new OneFrameDummy[F]() + + def live[F[_]: Monad](config: ProviderConfig, backend: SttpBackend[F, _]): Algebra[F] = + new OneFrameLive[F](config, backend) } diff --git a/forex-mtl/src/main/scala/forex/services/rates/errors.scala b/forex-mtl/src/main/scala/forex/services/rates/errors.scala index 0584dcf4..6b7b1305 100644 --- a/forex-mtl/src/main/scala/forex/services/rates/errors.scala +++ b/forex-mtl/src/main/scala/forex/services/rates/errors.scala @@ -5,6 +5,8 @@ object errors { sealed trait Error object Error { final case class OneFrameLookupFailed(msg: String) extends Error + final case class ApiRequestFailed(msg: String) extends Error + final case class ParseFailure(msg: String) extends Error } } diff --git a/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameDummy.scala b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameDummy.scala index 37a3f50c..c7c0a2ab 100644 --- a/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameDummy.scala +++ b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameDummy.scala @@ -7,9 +7,11 @@ import cats.syntax.either._ import forex.domain.{ Price, Rate, Timestamp } import forex.services.rates.errors._ +//TODO: delete 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 getAll: F[Either[Error, List[Rate]]] = ??? } diff --git a/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameLive.scala b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameLive.scala new file mode 100644 index 00000000..7bf34491 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameLive.scala @@ -0,0 +1,55 @@ +package forex.services.rates.interpreters + +import cats.Monad +import cats.data.EitherT +import cats.implicits._ +import forex.config.ProviderConfig +import forex.domain._ +import forex.services.rates +import forex.services.rates.errors +import forex.services.rates.interpreters.Implicits._ +import io.circe.Decoder +import sttp.client3._ +import sttp.client3.circe._ +import sttp.model.Uri + +class OneFrameLive[F[_]: Monad](config: ProviderConfig, backend: SttpBackend[F, _]) extends rates.Algebra[F] { + import Protocol._ + + private val tokenHeader = "token" + + private val pairs = for { + from <- Currency.values + to <- Currency.values if from != to + } yield Rate.Pair(from, to) + + private val baseUri = uri"${config.uri}/rates" + + private val getAllUri = baseUri.withParams(pairs.map(p => ("pair", p.show)): _*) + + override def get(pair: Rate.Pair): F[Either[errors.Error, Rate]] = + (for { + rates <- EitherT(sendRequest[List[OneFrameResponse]](baseUri.withParam("pair", pair.show))) + responseRate <- EitherT.fromOption[F]( + rates.headOption, + errors.Error.OneFrameLookupFailed(s"Failed to get rate for ${pair.show}"): errors.Error + ) + rate <- EitherT(responseRate.toRate[F]) + } yield rate).value + + override def getAll: F[Either[errors.Error, List[Rate]]] = + (for { + response <- EitherT(sendRequest[List[OneFrameResponse]](getAllUri)) + parsedRates <- EitherT(response.toRates[F]) + } yield parsedRates).value + + private def sendRequest[T: Decoder](uri: Uri): F[Either[errors.Error, T]] = + basicRequest + .header(tokenHeader, config.token) + .get(uri) + .response(asJson[T]) + .send(backend) + .map { response => + response.body.leftMap(e => errors.Error.ApiRequestFailed(e.getMessage)) + } +} diff --git a/forex-mtl/src/main/scala/forex/services/rates/interpreters/Protocol.scala b/forex-mtl/src/main/scala/forex/services/rates/interpreters/Protocol.scala new file mode 100644 index 00000000..3533ff07 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/rates/interpreters/Protocol.scala @@ -0,0 +1,19 @@ +package forex.services.rates.interpreters + +import forex.domain._ +import io.circe._ +import io.circe.generic.extras.Configuration +import io.circe.generic.extras.semiauto.deriveConfiguredDecoder + +object Protocol { + implicit val configuration: Configuration = Configuration.default.withSnakeCaseMemberNames + + implicit val timestampEncoder: Decoder[Timestamp] = + deriveConfiguredDecoder[Timestamp] + + implicit val priceEncoder: Decoder[Price] = deriveConfiguredDecoder[Price] + + implicit val currencyEncoder: Decoder[Currency] = Decoder.decodeString.map(Currency.fromString) + + implicit val reponseDecoder: Decoder[OneFrameResponse] = deriveConfiguredDecoder[OneFrameResponse] +} diff --git a/forex-mtl/src/main/scala/forex/services/rates/interpreters/package.scala b/forex-mtl/src/main/scala/forex/services/rates/interpreters/package.scala new file mode 100644 index 00000000..57a4a829 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/rates/interpreters/package.scala @@ -0,0 +1,37 @@ +package forex.services.rates + +import forex.domain.{ Currency, Price, Rate, Timestamp } + +import cats.implicits._ +import java.time.{ Instant, OffsetDateTime } +import scala.util.Try + +package interpreters { + + import cats.Applicative + + import java.time.ZoneId + + case class OneFrameResponse(from: Currency, to: Currency, price: Double, timeStamp: String) { + def toRate[F[_]: Applicative]: F[errors.Error Either Rate] = + Try(toRateUnsafe).toEither + .leftMap(e => errors.Error.ParseFailure(s"Failed to convert response to Rate: ${e.getMessage}"): errors.Error) + .pure[F] + + def toRateUnsafe: Rate = { + val parsedTime = OffsetDateTime.ofInstant(Instant.parse(timeStamp), ZoneId.systemDefault()) + Rate(Rate.Pair(from, to), Price(price), Timestamp(parsedTime)) + } + } + + object Implicits { + implicit class OneFrameResponseOps(private val data: List[OneFrameResponse]) extends AnyVal { + def toRates[F[_]: Applicative]: F[errors.Error Either List[Rate]] = + Try { + data.map(_.toRateUnsafe) + }.toEither + .leftMap(e => errors.Error.ParseFailure(s"Failed to convert response to Rate: ${e.getMessage}"): errors.Error) + .pure[F] + } + } +} diff --git a/forex-mtl/src/main/scala/forex/services/storage/Algebra.scala b/forex-mtl/src/main/scala/forex/services/storage/Algebra.scala new file mode 100644 index 00000000..bebdfaa0 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/storage/Algebra.scala @@ -0,0 +1,8 @@ +package forex.services.storage + +import forex.domain.Rate + +trait Algebra[F[_]] { + def get(pair: Rate.Pair): F[Option[Rate]] + def putAll(rates: List[Rate]): F[Unit] +} diff --git a/forex-mtl/src/main/scala/forex/services/storage/Interpreters.scala b/forex-mtl/src/main/scala/forex/services/storage/Interpreters.scala new file mode 100644 index 00000000..a284deaa --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/storage/Interpreters.scala @@ -0,0 +1,9 @@ +package forex.services.storage + +import cats.effect.Async +import forex.config.StorageConfig +import forex.services.storage.interpreters.InMemoryCache + +object Interpreters { + def inMemory[F[_]: Async](config: StorageConfig): Algebra[F] = new InMemoryCache[F](config) +} diff --git a/forex-mtl/src/main/scala/forex/services/storage/errors.scala b/forex-mtl/src/main/scala/forex/services/storage/errors.scala new file mode 100644 index 00000000..521c9da3 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/storage/errors.scala @@ -0,0 +1,12 @@ +package forex.services.storage + +object errors { + + sealed trait Error { + def msg: String + } + object Error { + final case class PairLookupFailed(msg: String) extends Error + } + +} diff --git a/forex-mtl/src/main/scala/forex/services/storage/interpreters/InMemoryCache.scala b/forex-mtl/src/main/scala/forex/services/storage/interpreters/InMemoryCache.scala new file mode 100644 index 00000000..dfb1fa21 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/storage/interpreters/InMemoryCache.scala @@ -0,0 +1,30 @@ +package forex.services.storage.interpreters + +import cats.effect.Async +import cats.syntax.all._ +import forex.domain.Rate +import forex.services.storage.Algebra +import scalacache.Entry +import scalacache.caffeine._ +import scalacache.Mode +import com.github.benmanes.caffeine.cache.Caffeine +import forex.config.StorageConfig + +class InMemoryCache[F[_]: Async](config: StorageConfig) extends Algebra[F] { + + implicit val mode: Mode[F] = scalacache.CatsEffect.modes.async[F] + + private val underlyingCaffeineCache = Caffeine + .newBuilder() + .expireAfterWrite(config.expireAfter.length, config.expireAfter.unit) + .build[String, Entry[Rate]]() + + private val cache: CaffeineCache[Rate] = CaffeineCache(underlyingCaffeineCache) + + override def get(pair: Rate.Pair): F[Option[Rate]] = cache.doGet(pair) + + override def putAll(rates: List[Rate]): F[Unit] = + rates.traverse(rate => cache.doPut(rate.pair, rate, Some(config.expireAfter))).void + + private implicit def pairToString(pair: Rate.Pair): String = pair.show +} diff --git a/forex-mtl/src/test/scala/forex/services/TestData.scala b/forex-mtl/src/test/scala/forex/services/TestData.scala new file mode 100644 index 00000000..03f9784a --- /dev/null +++ b/forex-mtl/src/test/scala/forex/services/TestData.scala @@ -0,0 +1,13 @@ +package forex.services + +import forex.domain.{ Currency, Price, Rate, Timestamp } + +import scala.util.Random + +trait TestData { + def genRates = + for { + from <- Currency.values + to <- Currency.values if to != from + } yield Rate(Rate.Pair(from, to), Price(Random.nextDouble()), Timestamp.now) +} diff --git a/forex-mtl/src/test/scala/forex/services/integration/ModuleSpec.scala b/forex-mtl/src/test/scala/forex/services/integration/ModuleSpec.scala new file mode 100644 index 00000000..85b069d9 --- /dev/null +++ b/forex-mtl/src/test/scala/forex/services/integration/ModuleSpec.scala @@ -0,0 +1,101 @@ +package forex.services.integration + +import cats.effect.{ Blocker, IO } +import cats.effect.testing.scalatest.AsyncIOSpec +import com.dimafeng.testcontainers.scalatest.TestContainerForAll +import com.dimafeng.testcontainers.GenericContainer +import forex.config.{ ApplicationConfig, HttpConfig, ProviderConfig, StorageConfig } +import forex.domain.{ Currency, Price } +import org.http4s.implicits._ +import org.http4s.{ Method, Request, Status } +import sttp.client3.asynchttpclient.fs2.AsyncHttpClientFs2Backend +import org.scalatest.EitherValues +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike + +import scala.concurrent.duration._ + +class ModuleSpec extends AsyncWordSpecLike with AsyncIOSpec with Matchers with EitherValues with TestContainerForAll { + + import forex.http.rates.Protocol._ + + override val containerDef: GenericContainer.Def[GenericContainer] = GenericContainer.Def( + "paidyinc/one-frame", + exposedPorts = Seq(8080) + ) + + private def providerConfig(port: Int) = + ProviderConfig(s"http://localhost:$port", "10dc303535874aeccc86a8251e6992f5") + + "Module" should { + "return an error on wrong request" in { + withContainers { container => + val config = ApplicationConfig( + HttpConfig("localhost", 8080, 40.seconds), + StorageConfig(3.minutes, 1000), + providerConfig(container.mappedPort(8080)) + ) + (for { + blocker <- Blocker.apply[IO] + backend <- AsyncHttpClientFs2Backend.resource[IO](blocker) + } yield backend).use { backend => + val module = new forex.Module[IO](config, backend) + + module.httpApp.run(Request(method = Method.GET, uri = uri"/rates/?from=UAH&to=UAH")).map { response => + response.status shouldBe Status.BadRequest + } + } + } + } + + "return rate for valid currencies" in { + withContainers { container => + val config = ApplicationConfig( + HttpConfig("localhost", 8080, 40.seconds), + StorageConfig(3.minutes, 1000), + providerConfig(container.mappedPort(8080)) + ) + (for { + blocker <- Blocker.apply[IO] + backend <- AsyncHttpClientFs2Backend.resource[IO](blocker) + } yield backend).use { backend => + val module = new forex.Module[IO](config, backend) + + module.httpApp.run(Request(method = Method.GET, uri = uri"/rates/?from=USD&to=JPY")).flatMap { response => + response.status shouldBe Status.Ok + + response.as[GetApiResponse].map { data => + data.from shouldBe Currency.USD + data.to shouldBe Currency.JPY + } + } + } + } + } + + "return 1 for equal currencies without request" in { + val config = ApplicationConfig( + HttpConfig("localhost", 8080, 40.seconds), + StorageConfig(3.minutes, 1000), + providerConfig(0) + ) + (for { + blocker <- Blocker.apply[IO] + backend <- AsyncHttpClientFs2Backend.resource[IO](blocker) + } yield backend).use { backend => + val module = new forex.Module[IO](config, backend) + + module.httpApp.run(Request(method = Method.GET, uri = uri"/rates/?from=USD&to=USD")).flatMap { response => + response.status shouldBe Status.Ok + + response.as[GetApiResponse].map { data => + data.from shouldBe Currency.USD + data.to shouldBe Currency.USD + data.price shouldBe Price(1) + } + } + } + } + } + +} diff --git a/forex-mtl/src/test/scala/forex/services/programs/ProgramSpec.scala b/forex-mtl/src/test/scala/forex/services/programs/ProgramSpec.scala new file mode 100644 index 00000000..8c26e5a8 --- /dev/null +++ b/forex-mtl/src/test/scala/forex/services/programs/ProgramSpec.scala @@ -0,0 +1,89 @@ +package forex.services.programs + +import cats.effect.IO +import cats.effect.testing.scalatest.AsyncIOSpec +import cats.implicits._ +import forex.config.{ ProviderConfig, StorageConfig } +import forex.domain._ +import forex.programs.rates.errors.Error.RateLookupFailed +import forex.programs.rates.{ Program, Protocol } +import forex.services.rates.interpreters.OneFrameResponse +import forex.services.{ RatesServices, StorageService, TestData } +import org.scalatest.{ EitherValues, OptionValues } +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike +import sttp.client3.ResponseException +import sttp.client3.asynchttpclient.fs2.AsyncHttpClientFs2Backend + +import scala.concurrent.duration._ + +class ProgramSpec + extends AsyncWordSpecLike + with AsyncIOSpec + with Matchers + with OptionValues + with EitherValues + with TestData { + "Program" should { + "return error if rate is missing" in { + val defaultConfig = StorageConfig(5.minutes, 1000) + val backend = AsyncHttpClientFs2Backend + .stub[IO] + .whenRequestMatches(_.uri.paramsSeq.exists { + case (key, _) => key == "pair" + }) + .thenRespond(List.empty[Rate].asRight[ResponseException[_, _]]) + + val cache = StorageService.inMemory[IO](defaultConfig) + val service = RatesServices.live(ProviderConfig("localhost:8080", "token"), backend) + + val program = Program[IO](service, cache) + + program.get(Protocol.GetRatesRequest(Currency.CAD, Currency.JPY)).map { result => + result shouldBe Symbol("Left") + + result.left.value shouldBe a[RateLookupFailed] + } + } + + "send request to get all rates once" in { + val rates = genRates.toList + var reqCount = 0 + val backend = AsyncHttpClientFs2Backend + .stub[IO] + .whenRequestMatches(_.uri.paramsSeq.exists { + case (key, _) => key == "pair" + }) + .thenRespond { + reqCount += 1 + rates + .map( + r => + OneFrameResponse( + r.pair.from, + r.pair.to, + r.price.value.doubleValue, + r.timestamp.value.toInstant.toString + ) + ) + .asRight[ResponseException[_, _]] + } + + val cache = StorageService.inMemory[IO](StorageConfig(5.minutes, 1000)) + val service = RatesServices.live(ProviderConfig("localhost:8080", "token"), backend) + + val program = Program[IO](service, cache) + + for { + req1 <- program.get(Protocol.GetRatesRequest(Currency.CAD, Currency.JPY)) + req2 <- program.get(Protocol.GetRatesRequest(Currency.CAD, Currency.JPY)) + } yield { + val expected = rates.find(_.pair == Rate.Pair(Currency.CAD, Currency.JPY)).value + + req1.value shouldBe expected + req2.value shouldBe expected + reqCount shouldBe 1 + } + } + } +} diff --git a/forex-mtl/src/test/scala/forex/services/rates/OneFrameLiveSpec.scala b/forex-mtl/src/test/scala/forex/services/rates/OneFrameLiveSpec.scala new file mode 100644 index 00000000..3a3d9a78 --- /dev/null +++ b/forex-mtl/src/test/scala/forex/services/rates/OneFrameLiveSpec.scala @@ -0,0 +1,96 @@ +package forex.services.rates + +import cats.effect.IO +import cats.implicits._ +import cats.effect.testing.scalatest.AsyncIOSpec +import forex.config.ProviderConfig +import forex.domain._ +import forex.services.TestData +import forex.services.rates.interpreters.OneFrameResponse +import org.scalatest.{ EitherValues, OptionValues } +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike +import sttp.client3.{ HttpError, ResponseException } +import sttp.client3.asynchttpclient.fs2.AsyncHttpClientFs2Backend +import sttp.model.StatusCode + +import java.time.Instant + +class OneFrameLiveSpec + extends AsyncWordSpecLike + with AsyncIOSpec + with Matchers + with OptionValues + with EitherValues + with TestData { + + "OneFrameLive" should { + "return an error on wrong request" in { + val backend = AsyncHttpClientFs2Backend + .stub[IO] + .whenAnyRequest + .thenRespond(HttpError("Bad Request", StatusCode.BadRequest).asLeft[Rate]) + + val service = Interpreters.live(ProviderConfig("localhost:8080", "token"), backend) + + service.get(Rate.Pair(Currency.GBP, Currency.USD)).map { result => + result shouldBe Symbol("Left") + + result.left.value shouldBe a[errors.Error.ApiRequestFailed] + } + } + + "return a value for a pair" in { + val pair = Rate.Pair(Currency.GBP, Currency.USD) + val backend = AsyncHttpClientFs2Backend + .stub[IO] + .whenRequestMatches(_.uri.paramsSeq.exists { + case (key, value) => key == "pair" && value == pair.show + }) + .thenRespond( + List(OneFrameResponse(pair.from, pair.to, 10d, Instant.now().toString)).asRight[ResponseException[_, _]] + ) + + val service = Interpreters.live(ProviderConfig("localhost:8080", "token"), backend) + + service.get(pair).map { result => + result shouldBe Symbol("Right") + + result.value.price shouldBe Price(10) + } + } + + "return values for all pairs" in { + val rates = genRates + + val backend = AsyncHttpClientFs2Backend + .stub[IO] + .whenRequestMatches(_.uri.paramsSeq.exists { + case (key, _) => key == "pair" + }) + .thenRespond( + rates + .map( + r => + OneFrameResponse( + r.pair.from, + r.pair.to, + r.price.value.doubleValue, + r.timestamp.value.toInstant.toString + ) + ) + .toList + .asRight[ResponseException[_, _]] + ) + + val service = Interpreters.live(ProviderConfig("localhost:8080", "token"), backend) + + service.getAll.map { result => + result shouldBe Symbol("Right") + + result.value should contain theSameElementsAs rates + } + } + } + +} diff --git a/forex-mtl/src/test/scala/forex/services/storage/InMemoryCacheSpec.scala b/forex-mtl/src/test/scala/forex/services/storage/InMemoryCacheSpec.scala new file mode 100644 index 00000000..1f338fb7 --- /dev/null +++ b/forex-mtl/src/test/scala/forex/services/storage/InMemoryCacheSpec.scala @@ -0,0 +1,49 @@ +package forex.services.storage + +import cats.effect.IO +import cats.effect.testing.scalatest.AsyncIOSpec +import forex.config.StorageConfig +import forex.domain.{ Currency, Price, Rate, Timestamp } +import org.scalatest.{ EitherValues, OptionValues } +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpecLike + +import scala.concurrent.duration._ + +class InMemoryCacheSpec extends AsyncWordSpecLike with AsyncIOSpec with Matchers with EitherValues with OptionValues { + + private val defaultConfig = StorageConfig(5.minutes, 1000) + + "InMemoryCache" should { + "return an error on missing pair" in { + val cache = Interpreters.inMemory[IO](defaultConfig) + + val pair = Rate.Pair(Currency.CAD, Currency.EUR) + + cache.get(pair).map { result => + result shouldBe None + } + } + + "return a value for pair" in { + val cache = Interpreters.inMemory[IO](defaultConfig) + val rate = Rate(Rate.Pair(Currency.JPY, Currency.CAD), Price(1), Timestamp.now) + for { + putResult <- cache.putAll(List(rate)) + getResult <- cache.get(Rate.Pair(Currency.JPY, Currency.CAD)) + } yield { + putResult shouldBe () + getResult.value shouldBe rate + } + } + + "invalidate cache after a timeout" in { + val cache = Interpreters.inMemory[IO](StorageConfig(50.millis, 10000000)) + for { + _ <- cache.putAll(List(Rate(Rate.Pair(Currency.JPY, Currency.CAD), Price(1), Timestamp.now))) + _ <- IO.sleep(100.millis) + res <- cache.get(Rate.Pair(Currency.JPY, Currency.CAD)) + } yield res shouldBe None + } + } +}