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

Develop - Paidy Forex Assignment #57

Open
wants to merge 9 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
125 changes: 125 additions & 0 deletions forex-mtl/Forex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<img src="/paidy.png?raw=true" width=300 style="background-color:white;">

# 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.
[Please click here for the F.A.Q.](./README.md)


### Assumption
1. Token will always remain the same.
2. Rate should not be older than 5 minutes.
3. Currently, the input is only for 2 valid currencies.
4. 1 API token = 10,000 successful requests per day

### Limitation
1. The One-Frame service supports a maximum of 1000 requests per day for any given authentication token.

### Suggested Solution
1. I initially though of making single calls for fetching exchange rate for a pair of currencies, quickly understood this would not work with our limitation.
2. So instead fetch all currencies every 4 minutes => 15 requests per hour => 360 requests per 24 hours (per day).
3. I have used a cache to hold the values with an expiry of 4 minutes, since we do not want to have any exchange rate older than 5 minutes, having a buffer of 1 minute extra will not cause any issues, because we are still managing the existing limitations.

### Output
![img.png](img.png)

### Running

You need to download and install sbt for this application to run.

#### Pre-requisite
Scala 2.13.12
SBT 1.8.0
Java 11 (I have developed and tested using Java 11)

Once you have sbt installed, type/run the following command in the terminal:

```bash
sbt run
or
Run via IDE
```

#### Extensions
1. Token is currently configured via configuration file, would be good to have a scheduler to fetch this and store in memory if needed.
2. I have worked with tag less final implementation via libraries but not directly in code, would be good to understand this better and have better error handling.
5 changes: 5 additions & 0 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ libraryDependencies ++= Seq(
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sCirce,
Libraries.sttpClientCore,
Libraries.sttpClientCirce,
Libraries.sttpClientBackend,
Libraries.scaffiene,
Libraries.circeCore,
Libraries.circeGeneric,
Libraries.circeGenericExt,
Libraries.circeParser,
Libraries.pureConfig,
Libraries.scalaLogging,
Libraries.logback,
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Expand Down
Binary file added forex-mtl/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ object Dependencies {

val kindProjector = "0.13.2"
val logback = "1.2.3"
val scalaLogging = "3.9.2"
val scalaCheck = "1.15.3"
val scalaTest = "3.2.7"
val catsScalaCheck = "0.3.2"
val sttp = "2.3.0"
val scaffeine = "0.28.0"
}

object Libraries {
def circe(artifact: String): ModuleID = "io.circe" %% artifact % Versions.circe
def http4s(artifact: String): ModuleID = "org.http4s" %% artifact % Versions.http4s
def sttp(artifact: String): ModuleID = "com.softwaremill.sttp.client" %% artifact % Versions.sttp

lazy val cats = "org.typelevel" %% "cats-core" % Versions.cats
lazy val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect
Expand All @@ -34,10 +38,19 @@ object Dependencies {
lazy val circeParser = circe("circe-parser")
lazy val pureConfig = "com.github.pureconfig" %% "pureconfig" % Versions.pureConfig

//Sttp client
lazy val sttpClientCore = sttp("core")
lazy val sttpClientCirce = sttp("circe")
lazy val sttpClientBackend = sttp("async-http-client-backend-cats")

//Scala caffeine for caching
lazy val scaffiene = "com.github.cb372" %% "scalacache-caffeine" % Versions.scaffeine

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

// Runtime
lazy val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging
lazy val logback = "ch.qos.logback" % "logback-classic" % Versions.logback

// Test
Expand Down
13 changes: 11 additions & 2 deletions forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
app {
http {
host = "0.0.0.0"
port = 8080
port = 9000
timeout = 40 seconds
}
one-frame{
url = "http://localhost:8080"
token = "10dc303535874aeccc86a8251e6992f5"
}
cache{
one-frame-expiry = 4 #minutes
}
scheduler{
one-frame-refresh = 4 #minutes
}
}

3 changes: 2 additions & 1 deletion forex-mtl/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
Expand All @@ -10,5 +11,5 @@
</root>

<logger name="http4s"/>
</configuration>
</configuration>

7 changes: 4 additions & 3 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ class Application[F[_]: ConcurrentEffect: Timer] {
config <- Config.stream("app")
module = new Module[F](config)
_ <- 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
.concurrently(module.ratesService.scheduleCacheRefresh())
} yield ()

}
16 changes: 13 additions & 3 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
package forex

import cats.effect.{ Concurrent, Timer }
import forex.client.OneFrameClient
import forex.config.ApplicationConfig
import forex.domain.Rate
import forex.http.rates.RatesHttpRoutes
import forex.services._
import forex.programs._
import forex.services._
import org.http4s._
import org.http4s.implicits._
import org.http4s.server.middleware.{ AutoSlash, Timeout }
import scalacache.Cache
import scalacache.caffeine.CaffeineCache
import sttp.client.{ HttpURLConnectionBackend, Identity, NothingT, SttpBackend }

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

private val ratesService: RatesService[F] = RatesServices.dummy[F]
private val rateCache: Cache[Rate] = CaffeineCache[Rate]

private implicit val backend: SttpBackend[Identity, Nothing, NothingT] = HttpURLConnectionBackend()

private lazy val oneFrameClient: OneFrameClient[F] = OneFrameClient.OneFrameHttpClient(config.oneFrame)

val ratesService: RatesService[F] = RatesServices.live[F](oneFrameClient, rateCache, config.cache, config.scheduler)

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

Expand All @@ -33,5 +44,4 @@ class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {
private val http: HttpRoutes[F] = ratesHttpRoutes

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

}
59 changes: 59 additions & 0 deletions forex-mtl/src/main/scala/forex/client/OneFrameHttpClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package forex.client

import cats.effect.Async
import cats.implicits._
import com.typesafe.scalalogging.LazyLogging
import forex.config.OneFrameConfig
import forex.domain.OneFrameCurrencyInformation
import forex.domain.Rate.Pair
import forex.services.rates.errors.Error.OneFrameLookupFailed
import forex.services.rates.errors._
import sttp.client._
import sttp.client.circe.asJson

trait OneFrameClient[F[_]] {
def getRates(pairs: Vector[Pair]): F[Either[Error, List[OneFrameCurrencyInformation]]]
}

class OneFrameHttpClient[F[_]: Async](
oneFrameConfig: OneFrameConfig,
implicit val backend: SttpBackend[Identity, Nothing, NothingT]
) extends OneFrameClient[F]
with LazyLogging {

def getRates(pairs: Vector[Pair]): F[Either[Error, List[OneFrameCurrencyInformation]]] = {
val param = pairs.map((pair: Pair) => "pair" -> s"${pair.from}${pair.to}")
val url = uri"${oneFrameConfig.url}/rates?$param"

val request = basicRequest
.get(uri = url)
.contentType("application/json")
.header("token", oneFrameConfig.token)
.response(asJson[List[OneFrameCurrencyInformation]])
.send()
.pure[F]
request.map { request =>
request.body match {
case Right(rates) => rates.asRight[Error]
case Left(error) =>
OneFrameLookupFailed(s" Parsing error - ${error.getMessage}").asLeft[List[OneFrameCurrencyInformation]]
}
}
}.recoverWith {
case t =>
logger.error(s"Failed to get rates with error: ${t.getMessage}")
Async[F].pure(
OneFrameLookupFailed(s"Failed to get rates with error: ${t.getMessage}")
.asLeft[List[OneFrameCurrencyInformation]]
)
}
}

object OneFrameClient {
def apply[F[_]: OneFrameClient]: OneFrameClient[F] = implicitly

implicit def OneFrameHttpClient[F[_]: Async](config: OneFrameConfig)(
implicit backend: SttpBackend[Identity, Nothing, NothingT]
): OneFrameClient[F] =
new OneFrameHttpClient[F](config, backend)
}
17 changes: 15 additions & 2 deletions forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@ package forex.config

import scala.concurrent.duration.FiniteDuration

case class ApplicationConfig(
final case class ApplicationConfig(
http: HttpConfig,
oneFrame: OneFrameConfig,
cache: CacheConfig,
scheduler: SchedulerConfig
)

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

final case class OneFrameConfig(url: String, token: String)

final case class CacheConfig(
oneFrameExpiry: Int
)

final case class SchedulerConfig(
oneFrameRefresh: Int
)
8 changes: 8 additions & 0 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,12 @@ object Currency {
case "USD" => USD
}

private val currencies: Vector[Currency] = Vector(AUD, CAD, CHF, EUR, GBP, NZD, JPY, SGD, USD)
val allPairs: Vector[(Currency, Currency)] = {
currencies
.combinations(2)
.flatMap(_.permutations)
.collect { case Seq(from: Currency, to: Currency) => (from, to) }
.toVector
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package forex.domain

import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}

final case class OneFrameCurrencyInformation(from: String, to: String, bid: Double, ask: Double, price: Double, time_stamp: String)

object OneFrameCurrencyInformation {
implicit val oneFrameCurrencyInformationDecoder: Decoder[OneFrameCurrencyInformation] = deriveDecoder
implicit val oneFrameCurrencyInformationEncoder: Encoder[OneFrameCurrencyInformation] = deriveEncoder
}
2 changes: 1 addition & 1 deletion forex-mtl/src/main/scala/forex/domain/Price.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package forex.domain

case class Price(value: BigDecimal) extends AnyVal
final case class Price(value: BigDecimal) extends AnyVal

object Price {
def apply(value: Integer): Price =
Expand Down
Loading