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 assignment #56

Open
wants to merge 1 commit 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
97 changes: 97 additions & 0 deletions forex-mtl/Forex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<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.
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!

13 changes: 13 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ libraryDependencies ++= Seq(
Libraries.fs2,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.sttpClient,
Libraries.caffiene,
Libraries.http4sCirce,
Libraries.circeCore,
Libraries.circeGeneric,
Expand Down
Binary file added forex-mtl/paidy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand Down
13 changes: 9 additions & 4 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
@@ -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

Expand Down
27 changes: 27 additions & 0 deletions forex-mtl/src/main/scala/forex/cache/CaffeineCustomCache.scala
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 6 additions & 0 deletions forex-mtl/src/main/scala/forex/cache/CustomCache.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package forex.cache

trait CustomCache[K, V] {
def put(key: K, value: V): Unit
def get(key: K): Option[V]
}
25 changes: 25 additions & 0 deletions forex-mtl/src/main/scala/forex/cache/rates/RatesCache.scala
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions forex-mtl/src/main/scala/forex/client/HttpClient.scala
Original file line number Diff line number Diff line change
@@ -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]]
}
43 changes: 43 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,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
}
22 changes: 12 additions & 10 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package forex.domain

import cats.Show
import forex.programs.rates.errors.Error.InvalidCurrencyString

sealed trait Currency

Expand All @@ -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"))
}

}
7 changes: 4 additions & 3 deletions forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala
Original file line number Diff line number Diff line change
@@ -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")

}
28 changes: 25 additions & 3 deletions forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down
Loading