From 636e90492a1030dca42d2dc2e8b385bbc363b787 Mon Sep 17 00:00:00 2001 From: Manuj Garg Date: Mon, 11 Dec 2023 00:03:52 +0530 Subject: [PATCH] forex assignment --- forex-mtl/Forex.md | 97 ++++++++++++++++++ forex-mtl/README.md | 13 +++ forex-mtl/build.sbt | 2 + forex-mtl/paidy.png | Bin 0 -> 39376 bytes forex-mtl/project/Dependencies.scala | 5 + forex-mtl/src/main/scala/forex/Module.scala | 13 ++- .../forex/cache/CaffeineCustomCache.scala | 27 +++++ .../main/scala/forex/cache/CustomCache.scala | 6 ++ .../scala/forex/cache/rates/RatesCache.scala | 25 +++++ .../main/scala/forex/client/HttpClient.scala | 8 ++ .../forex/client/OneFrameHttpClient.scala | 43 ++++++++ .../main/scala/forex/domain/Currency.scala | 22 ++-- .../scala/forex/http/rates/QueryParams.scala | 7 +- .../forex/http/rates/RatesHttpRoutes.scala | 28 ++++- .../scala/forex/programs/rates/Program.scala | 36 +++++-- .../scala/forex/programs/rates/errors.scala | 4 + .../rates/{algebra.scala => Algebra.scala} | 0 .../forex/services/rates/Interpreters.scala | 3 +- .../scala/forex/services/rates/errors.scala | 7 +- .../rates/interpreters/OneFrameClient.scala | 53 ++++++++++ project/build.properties | 1 + 21 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 forex-mtl/Forex.md create mode 100644 forex-mtl/README.md create mode 100644 forex-mtl/paidy.png create mode 100644 forex-mtl/src/main/scala/forex/cache/CaffeineCustomCache.scala create mode 100644 forex-mtl/src/main/scala/forex/cache/CustomCache.scala create mode 100644 forex-mtl/src/main/scala/forex/cache/rates/RatesCache.scala create mode 100644 forex-mtl/src/main/scala/forex/client/HttpClient.scala create mode 100644 forex-mtl/src/main/scala/forex/client/OneFrameHttpClient.scala rename forex-mtl/src/main/scala/forex/services/rates/{algebra.scala => Algebra.scala} (100%) create mode 100644 forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameClient.scala create mode 100644 project/build.properties diff --git a/forex-mtl/Forex.md b/forex-mtl/Forex.md new file mode 100644 index 00000000..0d5916db --- /dev/null +++ b/forex-mtl/Forex.md @@ -0,0 +1,97 @@ + + +# Paidy Take-Home Coding Exercises + +## What to expect? +We understand that your time is valuable, and in anyone's busy schedule solving these exercises may constitute a fairly substantial chunk of time, so we really appreciate any effort you put in to helping us build a solid team. + +## What we are looking for? +**Keep it simple**. Read the requirements and restrictions carefully and focus on solving the problem. + +**Treat it like production code**. That is, develop your software in the same way that you would for any code that is intended to be deployed to production. These may be toy exercises, but we really would like to get an idea of how you build code on a day-to-day basis. + +## How to submit? +You can do this however you see fit - you can email us a tarball, a pointer to download your code from somewhere or just a link to a source control repository. Make sure your submission includes a small **README**, documenting any assumptions, simplifications and/or choices you made, as well as a short description of how to run the code and/or tests. Finally, to help us review your code, please split your commit history in sensible chunks (at least separate the initial provided code from your personal additions). + +# A local proxy for Forex rates + +Build a local proxy for getting Currency Exchange Rates + +## Requirements + +[Forex](forex-mtl) is a simple application that acts as a local proxy for getting exchange rates. It's a service that can be consumed by other internal services to get the exchange rate between a set of currencies, so they don't have to care about the specifics of third-party providers. + +We provide you with an initial scaffold for the application with some dummy interpretations/implementations. For starters we would like you to try and understand the structure of the application, so you can use this as the base to address the following use case: + +* The service returns an exchange rate when provided with 2 supported currencies +* The rate should not be older than 5 minutes +* The service should support at least 10,000 successful requests per day with 1 API token + +Please note the following drawback of the [One-Frame service](https://hub.docker.com/r/paidyinc/one-frame): + +> The One-Frame service supports a maximum of 1000 requests per day for any given authentication token. + +## Guidance + +In practice, this should require the following points: + +1. Create a `live` interpreter for the `oneframe` service. This should consume the [One-Frame API](https://hub.docker.com/r/paidyinc/one-frame). + +2. Adapt the `rates` processes (if necessary) to make sure you cover the requirements of the use case, and work around possible limitations of the third-party provider. + +3. Make sure the service's own API gets updated to reflect the changes you made in point 1 & 2. + +Some notes: +- Don't feel limited by the existing dependencies; you can include others. +- The algebras/interfaces provided act as an example/starting point. Feel free to add to improve or built on it when needed. +- The `rates` processes currently only use a single service. Don't feel limited, and do add others if you see fit. +- It's great for downstream users of the service (your colleagues) if the api returns descriptive errors in case something goes wrong. +- Feel free to fix any unsafe methods you might encounter. + +Some of the traits/specifics we are looking for using this exercise: + +- How can you navigate through an existing codebase; +- How easily do you pick up concepts, techniques and/or libraries you might not have encountered/used before; +- How do you work with third-party APIs that might not be (as) complete (as we would wish them to be); +- How do you work around restrictions; +- What design choices do you make; +- How do you think beyond the happy path. + +### The One-Frame service + +#### How to run locally + +* Pull the docker image with `docker pull paidyinc/one-frame` +* Run the service locally on port 8080 with `docker run -p 8080:8080 paidyinc/one-frame` + +#### Usage +__API__ + +The One-Frame API offers two different APIs, for this exercise please use the `GET /rates` one. + +`GET /rates?pair={currency_pair_0}&pair={currency_pair_1}&...pair={currency_pair_n}` + +pair: Required query parameter that is the concatenation of two different currency codes, e.g. `USDJPY`. One or more pairs per request are allowed. + +token: Header required for authentication. `10dc303535874aeccc86a8251e6992f5` is the only accepted value in the current implementation. + +__Example cURL request__ +``` +$ curl -H "token: 10dc303535874aeccc86a8251e6992f5" 'localhost:8080/rates?pair=USDJPY' + +[{"from":"USD","to":"JPY","bid":0.61,"ask":0.82,"price":0.71,"time_stamp":"2019-01-01T00:00:00.000"}] +``` + +## F.A.Q. +1) _Is it OK to share your solutions publicly?_ +Yes, the questions are not prescriptive, the process and discussion around the code is the valuable part. You do the work, you own the code. Given we are asking you to give up your time, it is entirely reasonable for you to keep and use your solution as you see fit. + +2) _Should I do X?_ +For any value of X, it is up to you, we intentionally leave the problem a little open-ended and will leave it up to you to provide us with what you see as important. Just remember to keep it simple. If it's a feature that is going to take you a couple of days, it's not essential. + +3) _Something is ambiguous, and I don't know what to do?_ +The first thing is: don't get stuck. We really don't want to trip you up intentionally, we are just attempting to see how you approach problems. That said, there are intentional ambiguities in the specifications, mainly to see how you fill in those gaps, and how you make design choices. +If you really feel stuck, our first preference is for you to make a decision and document it with your submission - in this case there is really no wrong answer. If you feel it is not possible to do this, just send us an email and we will try to clarify or correct the question for you. + +Good luck! + diff --git a/forex-mtl/README.md b/forex-mtl/README.md new file mode 100644 index 00000000..b6891789 --- /dev/null +++ b/forex-mtl/README.md @@ -0,0 +1,13 @@ +Assumptions +1. As this is an internally used service, token has been set in service only. Otherwise, depending on the use case each client might need their own token. +2. Have implemented cache assuming there is only one instance of this service is running. Otherwise, would need a centralized cache like redis. + +Implementation +1. Have used Caffeine in memory cache, check RatesCache class. For each pair caching the response in "rate:$from:$to" key for 5 min. This way, we can get a valid response from this proxy a lot more than 10000 per day. +2. For a pair of keys only (2 req/5 min) or (288 req/day) are possible and with optimization mentioned below (1 req/5 min) or (144 req/day). We are managing 9 currencies and in that case 9C2 or 36 unique currency pairs are possible. This can lead to 36 unique req every 5 min without any cache hit. Which means in this very specific case our token would expire for the day before we are able to hit 10000 req. An option in that case could be showing stale data. +3. In case of no cache hit, hitting the one frame API using OneFrameHttpClient. + +Possible improvements and points of discussion +1. Implementation of One token HTTP client is synchronous. As there is not much scale to be considered for this activity, it should be fine. Might be different in production. +2. One possible optimization to reduce the workload to downstream service even more(possibly by half) is trying to fetch from cache for both "rate:$from$to" as well as "rate$to:$from" key. We can take inverse of price in case we get a hit for "rate$to:$from". +3. As this is my first time writing scala, I have spent a lot of time on this assignment. Have skipped some error cases like token getting expired. \ No newline at end of file diff --git a/forex-mtl/build.sbt b/forex-mtl/build.sbt index 8994026f..833135c5 100644 --- a/forex-mtl/build.sbt +++ b/forex-mtl/build.sbt @@ -56,6 +56,8 @@ libraryDependencies ++= Seq( Libraries.fs2, Libraries.http4sDsl, Libraries.http4sServer, + Libraries.sttpClient, + Libraries.caffiene, Libraries.http4sCirce, Libraries.circeCore, Libraries.circeGeneric, diff --git a/forex-mtl/paidy.png b/forex-mtl/paidy.png new file mode 100644 index 0000000000000000000000000000000000000000..9be392bd95ecf5ff5f47c3718d59c4105712160a GIT binary patch literal 39376 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

!tO*)iXL65B<3Q`;i;0!C6Q-LZ8CY)Gms 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 literal 0 HcmV?d00001 diff --git a/forex-mtl/project/Dependencies.scala b/forex-mtl/project/Dependencies.scala index 423210a1..1c4c2de4 100644 --- a/forex-mtl/project/Dependencies.scala +++ b/forex-mtl/project/Dependencies.scala @@ -7,8 +7,10 @@ object Dependencies { val catsEffect = "2.5.1" val fs2 = "2.5.4" val http4s = "0.22.15" + val sttp = "4.0.0-M1" val circe = "0.14.2" val pureConfig = "0.17.4" + val caffeine = "3.0.4" val kindProjector = "0.13.2" val logback = "1.2.3" @@ -23,7 +25,10 @@ object Dependencies { lazy val cats = "org.typelevel" %% "cats-core" % Versions.cats lazy val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect + lazy val caffiene = "com.github.ben-manes.caffeine" % "caffeine" % Versions.caffeine + lazy val fs2 = "co.fs2" %% "fs2-core" % Versions.fs2 + lazy val sttpClient = "com.softwaremill.sttp.client4" %% "core" % Versions.sttp lazy val http4sDsl = http4s("http4s-dsl") lazy val http4sServer = http4s("http4s-blaze-server") diff --git a/forex-mtl/src/main/scala/forex/Module.scala b/forex-mtl/src/main/scala/forex/Module.scala index 3bc47d58..bd90313b 100644 --- a/forex-mtl/src/main/scala/forex/Module.scala +++ b/forex-mtl/src/main/scala/forex/Module.scala @@ -1,19 +1,24 @@ package forex -import cats.effect.{ Concurrent, Timer } +import cats.effect.{Concurrent, Timer} +import forex.cache.rates.RatesCache +import forex.client.OneFrameHttpClient import forex.config.ApplicationConfig import forex.http.rates.RatesHttpRoutes import forex.services._ import forex.programs._ import org.http4s._ import org.http4s.implicits._ -import org.http4s.server.middleware.{ AutoSlash, Timeout } +import org.http4s.server.middleware.{AutoSlash, Timeout} class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) { + private val oneFrameHttpClient: OneFrameHttpClient = OneFrameHttpClient.getInstance - private val ratesService: RatesService[F] = RatesServices.dummy[F] + private val ratesService: RatesService[F] = RatesServices.oneFrameClient[F](oneFrameHttpClient) - private val ratesProgram: RatesProgram[F] = RatesProgram[F](ratesService) + private val ratesCache: RatesCache = RatesCache.getInstance + + private val ratesProgram: RatesProgram[F] = RatesProgram[F](ratesService, ratesCache) private val ratesHttpRoutes: HttpRoutes[F] = new RatesHttpRoutes[F](ratesProgram).routes diff --git a/forex-mtl/src/main/scala/forex/cache/CaffeineCustomCache.scala b/forex-mtl/src/main/scala/forex/cache/CaffeineCustomCache.scala new file mode 100644 index 00000000..af7c9df9 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/cache/CaffeineCustomCache.scala @@ -0,0 +1,27 @@ +package forex.cache + +import com.github.benmanes.caffeine.cache._ + +import java.util.concurrent.TimeUnit + +class CaffeineCustomCache[K, V](private val capacity: Long = 10000, private val ttl: Long = 1) extends CustomCache[K, V] { + private val cache: Cache[K, V] = Caffeine + .newBuilder() + .maximumSize(capacity) + .expireAfterWrite(ttl, TimeUnit.SECONDS) + .build() + + override def put(key: K, value: V): Unit = { + cache.put(key, value) + } + + override def get(key: K): Option[V] = { + Option(cache.getIfPresent(key)) + } +} + + +object CaffeineCustomCache { + def apply[K, V](capacity: Long, ttl: Long): CaffeineCustomCache[K, V] = + new CaffeineCustomCache[K, V](capacity, ttl) +} \ No newline at end of file diff --git a/forex-mtl/src/main/scala/forex/cache/CustomCache.scala b/forex-mtl/src/main/scala/forex/cache/CustomCache.scala new file mode 100644 index 00000000..c9073755 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/cache/CustomCache.scala @@ -0,0 +1,6 @@ +package forex.cache + +trait CustomCache[K, V] { + def put(key: K, value: V): Unit + def get(key: K): Option[V] +} diff --git a/forex-mtl/src/main/scala/forex/cache/rates/RatesCache.scala b/forex-mtl/src/main/scala/forex/cache/rates/RatesCache.scala new file mode 100644 index 00000000..378e4f05 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/cache/rates/RatesCache.scala @@ -0,0 +1,25 @@ +package forex.cache.rates + +import forex.cache.CaffeineCustomCache +import forex.cache.rates.RatesCache.getRateKey +import forex.domain.Rate + +class RatesCache private (private val cache: CaffeineCustomCache[String, Rate]) { + def getRate(from: String, to: String): Option[Rate] = { + cache.get(getRateKey(from, to)) + } + + def setRate(from: String, to: String, rate: Rate): Unit = { + cache.put(getRateKey(from, to), rate) + } +} + +object RatesCache { + private val instance: RatesCache = new RatesCache(CaffeineCustomCache[String, Rate](10000, 300)) + + private def getRateKey(from: String, to: String): String = { + s"rate:$from:$to" + } + + def getInstance: RatesCache = instance +} diff --git a/forex-mtl/src/main/scala/forex/client/HttpClient.scala b/forex-mtl/src/main/scala/forex/client/HttpClient.scala new file mode 100644 index 00000000..1d35b498 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/client/HttpClient.scala @@ -0,0 +1,8 @@ +package forex.client + +import sttp.client4.Response +import sttp.model.Uri + +trait HttpClient { + def getApiResponse(url: Uri): Either[String, Response[String]] +} diff --git a/forex-mtl/src/main/scala/forex/client/OneFrameHttpClient.scala b/forex-mtl/src/main/scala/forex/client/OneFrameHttpClient.scala new file mode 100644 index 00000000..d727b7b1 --- /dev/null +++ b/forex-mtl/src/main/scala/forex/client/OneFrameHttpClient.scala @@ -0,0 +1,43 @@ +package forex.client + +import cats.implicits.toShow +import forex.domain.Rate.Pair +import sttp.model.Uri +import sttp.client4.Response +import sttp.client4.quick.quickRequest +import sttp.client4.quick._ +import scala.util.{Try, Success, Failure} + +class OneFrameHttpClient extends HttpClient { + def getApiResponse(uri: Uri): Either[String, Response[String]] = { + Try { + quickRequest + .get(uri) + .header("Content-Type", "application/json") + .header("token", "10dc303535874aeccc86a8251e6992f5") + .send() + } match { + case Success(response) => + Right(response) + case Failure(exception) => + Left(s"Error calling backend service: ${exception.getMessage}") + } + } + + def getRates(pair: Pair): Either[String, Response[String]] = { + getApiResponse(getRatesUri(pair)) + } + + def getRatesUri(pair: Pair): Uri = { + val param = pair.to.show + pair.from.show + uri"http://localhost:8081/rates?pair=$param" + } +} + +object OneFrameHttpClient { + private def apply: OneFrameHttpClient = new OneFrameHttpClient() + + private val instance: OneFrameHttpClient = apply + + def getInstance: OneFrameHttpClient = instance +} diff --git a/forex-mtl/src/main/scala/forex/domain/Currency.scala b/forex-mtl/src/main/scala/forex/domain/Currency.scala index a6f2857d..dc84d182 100644 --- a/forex-mtl/src/main/scala/forex/domain/Currency.scala +++ b/forex-mtl/src/main/scala/forex/domain/Currency.scala @@ -1,6 +1,7 @@ package forex.domain import cats.Show +import forex.programs.rates.errors.Error.InvalidCurrencyString sealed trait Currency @@ -27,16 +28,17 @@ object Currency { case USD => "USD" } - def fromString(s: String): Currency = s.toUpperCase match { - case "AUD" => AUD - case "CAD" => CAD - case "CHF" => CHF - case "EUR" => EUR - case "GBP" => GBP - case "NZD" => NZD - case "JPY" => JPY - case "SGD" => SGD - case "USD" => USD + def fromString(s: String): Either[InvalidCurrencyString, Currency] = s.toUpperCase match { + case "AUD" => Right(AUD) + case "CAD" => Right(CAD) + case "CHF" => Right(CHF) + case "EUR" => Right(EUR) + case "GBP" => Right(GBP) + case "NZD" => Right(NZD) + case "JPY" => Right(JPY) + case "SGD" => Right(SGD) + case "USD" => Right(USD) + case _ => Left(InvalidCurrencyString(s"Invalid currency String: $s")) } } 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..be5ab803 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,16 @@ package forex.http.rates import forex.domain.Currency +import forex.programs.rates.errors.Error.InvalidCurrencyString import org.http4s.QueryParamDecoder import org.http4s.dsl.impl.QueryParamDecoderMatcher object QueryParams { - private[http] implicit val currencyQueryParam: QueryParamDecoder[Currency] = + private[http] implicit val currencyQueryParam: QueryParamDecoder[Either[InvalidCurrencyString, Currency]] = QueryParamDecoder[String].map(Currency.fromString) - object FromQueryParam extends QueryParamDecoderMatcher[Currency]("from") - object ToQueryParam extends QueryParamDecoderMatcher[Currency]("to") + object FromQueryParam extends QueryParamDecoderMatcher[Either[InvalidCurrencyString, Currency]]("from") + object ToQueryParam extends QueryParamDecoderMatcher[Either[InvalidCurrencyString, 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..26e6dfa2 100644 --- a/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala +++ b/forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala @@ -2,9 +2,11 @@ package forex.http package rates import cats.effect.Sync +import cats.implicits.catsSyntaxApplicativeError import cats.syntax.flatMap._ import forex.programs.RatesProgram -import forex.programs.rates.{ Protocol => RatesProgramProtocol } +import forex.programs.rates.errors.Error.{RateInvalidString, RateLookupFailed} +import forex.programs.rates.{Protocol => RatesProgramProtocol} import org.http4s.HttpRoutes import org.http4s.dsl.Http4sDsl import org.http4s.server.Router @@ -17,8 +19,28 @@ class RatesHttpRoutes[F[_]: Sync](rates: RatesProgram[F]) extends Http4sDsl[F] { 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) match { + case (Right(from), Right(to)) => + rates.get(RatesProgramProtocol.GetRatesRequest(from, to)) + .flatMap(Sync[F].fromEither) + .flatMap { rate => + Ok(rate.asGetApiResponse) + } + .handleErrorWith { + case e: RateLookupFailed => + // Handle your specific error here and provide a custom response + InternalServerError(s"Error: ${e.msg}") + + case e: RateInvalidString => + // Handle other types of errors and provide a generic error response + InternalServerError(s"Internal Server Error: ${e.msg}") + } + + case (Left(error), _) => + BadRequest(error.msg) + + case (_, Left(error)) => + BadRequest(error.msg) } } 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..f68ad388 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,42 @@ package forex.programs.rates -import cats.Functor +import cats.Applicative import cats.data.EitherT +import cats.implicits._ import errors._ +import forex.cache.rates.RatesCache import forex.domain._ import forex.services.RatesService -class Program[F[_]: Functor]( - ratesService: RatesService[F] +class Program[F[_]: Applicative]( + ratesService: RatesService[F], + ratesCache: RatesCache ) 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 - + override def get(request: Protocol.GetRatesRequest): F[Error Either Rate] = { + ratesCache + .getRate(request.from.show, request.to.show) + .fold( + EitherT( + ratesService.get( + Rate.Pair(request.from, request.to) + ) + ).map(rate => { + ratesCache.setRate(request.from.show, request.to.show, rate) + rate + }) + .leftMap(toProgramError).value + )( + rate => Applicative[F].pure(rate.asRight[Error]) + ) + } } object Program { - def apply[F[_]: Functor]( - ratesService: RatesService[F] - ): Algebra[F] = new Program[F](ratesService) + def apply[F[_]: Applicative]( + ratesService: RatesService[F], + ratesCache: RatesCache + ): Algebra[F] = new Program[F](ratesService, ratesCache) } 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..985805eb 100644 --- a/forex-mtl/src/main/scala/forex/programs/rates/errors.scala +++ b/forex-mtl/src/main/scala/forex/programs/rates/errors.scala @@ -7,9 +7,13 @@ object errors { sealed trait Error extends Exception object Error { final case class RateLookupFailed(msg: String) extends Error + final case class RateInvalidString(msg: String) extends Error + final case class InvalidCurrencyString(msg: String) extends Error } def toProgramError(error: RatesServiceError): Error = error match { case RatesServiceError.OneFrameLookupFailed(msg) => Error.RateLookupFailed(msg) + case RatesServiceError.JsonParsingFailed(msg) => Error.RateInvalidString(msg) + case RatesServiceError.JsonDecodingFailed(msg) => Error.RateInvalidString(msg) } } 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 100% rename from forex-mtl/src/main/scala/forex/services/rates/algebra.scala rename to forex-mtl/src/main/scala/forex/services/rates/Algebra.scala 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..6f863928 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,9 @@ package forex.services.rates import cats.Applicative +import forex.client.OneFrameHttpClient import interpreters._ object Interpreters { - def dummy[F[_]: Applicative]: Algebra[F] = new OneFrameDummy[F]() + def oneFrameClient[F[_]: Applicative](oneFrameHttpClient: OneFrameHttpClient): Algebra[F] = new OneFrameClient[F](oneFrameHttpClient) } 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..5e3ee4af 100644 --- a/forex-mtl/src/main/scala/forex/services/rates/errors.scala +++ b/forex-mtl/src/main/scala/forex/services/rates/errors.scala @@ -2,9 +2,14 @@ package forex.services.rates object errors { - sealed trait Error + sealed trait Error extends Throwable object Error { final case class OneFrameLookupFailed(msg: String) extends Error + + final case class JsonParsingFailed(msg: String) extends Error + + final case class JsonDecodingFailed(str: String) extends Error + final case class InvalidCurrency(str: String) extends Error } } diff --git a/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameClient.scala b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameClient.scala new file mode 100644 index 00000000..0ebc9e2c --- /dev/null +++ b/forex-mtl/src/main/scala/forex/services/rates/interpreters/OneFrameClient.scala @@ -0,0 +1,53 @@ +package forex.services.rates.interpreters + +import forex.services.rates.Algebra +import cats.Applicative +import cats.implicits.{catsSyntaxEitherId, toBifunctorOps} +import forex.client.OneFrameHttpClient +import forex.domain.{Price, Rate, Timestamp} +import io.circe.parser._ +import forex.services.rates.errors._ +import io.circe.{Decoder, HCursor} + +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +case class CurrencyInfo(from: String, to: String, bid: Double, ask: Double, price: Double, time_stamp: String) + +class OneFrameClient[F[_]: Applicative](oneFrameHttpClient: OneFrameHttpClient) extends Algebra[F] { + override def get(pair: Rate.Pair): F[Error Either Rate] = { + getApiResponse(pair) + } + + def getApiResponse(pair: Rate.Pair): F[Error Either Rate] = { + val res = oneFrameHttpClient.getRates(pair) + + res.fold( + e => Applicative[F].pure(Error.OneFrameLookupFailed(e).asLeft[Rate]), + r => { + val parsedResult: Either[Error, Rate] = for { + json <- parse(r.body).leftMap(err => Error.JsonParsingFailed(err.getMessage)) + element <- json.as[List[CurrencyInfo]].leftMap(err => Error.JsonDecodingFailed(err.getMessage())) + .map(x => x.head) + } yield Rate( + pair, + Price(BigDecimal(element.price)), + new Timestamp(OffsetDateTime.parse(element.time_stamp, DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + ) + + Applicative[F].pure(parsedResult) + } + ) + } + + // Define a Circe Decoder for the case class + implicit val currencyInfoDecoder: Decoder[CurrencyInfo] = (c: HCursor) => + for { + from <- c.downField("from").as[String] + to <- c.downField("to").as[String] + bid <- c.downField("bid").as[Double] + ask <- c.downField("ask").as[Double] + price <- c.downField("price").as[Double] + time_stamp <- c.downField("time_stamp").as[String] + } yield CurrencyInfo(from, to, bid, ask, price, time_stamp) +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 00000000..c8fcab54 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.2