Skip to content
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
161 changes: 161 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# forex-mtl

A Forex rate proxy service built with Scala, Cats-Effect 2, and Http4s.
It exposes a single HTTP endpoint that returns exchange rates by fetching from
the [One-Frame](https://hub.docker.com/r/paidyinc/one-frame) service and caching results locally.

---

## How to Run

### Prerequisites

- JDK 11+
- SBT 1.8+
- Docker (for the One-Frame upstream service)

### 1. Start the One-Frame service

```bash
docker run -p 8080:8080 paidyinc/one-frame
```

### 2. Start the proxy

```bash
sbt run
```

The proxy starts on `http://localhost:8000`.

### 3. Run the tests

```bash
sbt test
```

---

## API

### `GET /rates?from={currency}&to={currency}`

Returns the exchange rate between two currencies.

**Supported currencies:** `AUD`, `CAD`, `CHF`, `EUR`, `GBP`, `NZD`, `JPY`, `SGD`, `USD`

#### Example — success

```bash
curl "http://localhost:8000/rates?from=USD&to=JPY"
```

```json
{
"from": "USD",
"to": "JPY",
"price": 0.6934349309,
"timestamp": "2021-01-01T00:00:00Z"
}
```

#### Example — same currency

```bash
curl "http://localhost:8000/rates?from=USD&to=USD"
```

```json
{
"from": "USD",
"to": "USD",
"price": 1.0,
"timestamp": "..."
}
```

### Error responses

| Scenario | Status | Body |
|-----------------------------------------|--------|----------------------------------------------------------------------|
| Missing `from` or `to` | `400` | `{"error": "Missing required query parameter: from/to"}` |
| Unsupported currency (e.g. from=XXX) | `400` | `{"error": "[from] Unsupported Currency : XXX"}` |
| One-Frame token invalid / misconfigured | `500` | `{"error": "Service token invalid"}` |
| One-Frame unreachable or timed out | `502` | `{"error": "Rate service unavailable"}` |
| One-Frame quota exceeded | `502` | `{"error": "Rate service temporarily unavailable, try again later"}` |

---

## Configuration

All config lives in `src/main/resources/application.conf`:

```hocon
app {
http {
...
}
rate-service {
...
}
}
```

> **Note:** The API token is checked into the repo for reviewer convenience. In production it should come from an
> environment variable or secrets manager.

---

## Design Decisions & Assumptions

### Caching strategy

One-Frame allows at most **1000 requests/day**. To satisfy both this limit and the **<5 minute data freshness**
requirement, the proxy batch-fetches all 72 currency pairs (9 × 8) in a single request and stores the result in an
in-memory `Ref`.

- **TTL:** 4 minutes → at most `(60 / 4) × 24 = 360` upstream calls/day (~2.8× headroom)
- **Cache scope:** all pairs in one shot — a single cache miss refreshes rates for every pair simultaneously

> *Note: In a distributed system, we would typically rely on an external caching solution like Redis.*

### Same-currency pairs

`GET /rates?from=USD&to=USD` returns `price: 1.0` rather than an error, as this is mathematically correct and avoids an
unnecessary upstream call (One-Frame returns an empty list for same-currency pairs).

### Error mapping

One-Frame **always returns HTTP 200 OK**, even for errors. Error information is in the JSON body as `{"error": "..."}`.
The
proxy decodes the body in two passes — first as a rate list, then as an error object — and maps each case to an
appropriate HTTP status:

- `Forbidden` → our misconfiguration → `500`
- `Quota reached` / network failure → upstream problem → `502`
- Invalid / missing query params → client error → `400`

### Timeout budget

Total client timeout is `40s`. The upstream call budget is `40s × 70% / 2 retries = 14s`, leaving headroom for
middleware overhead and 1 retry.

### Supported currencies

> *Note: The OneFrame service supports a lot more Currencies than the 9 listed in the assignment. For simplicity, I have
not added them.*

All the 9 currencies listed in the assignment are supported. The `Currency` case objects are `private` — callers
interact with them only through `Currency.fromString` (returns `Either`) and `Show[Currency]`.

### Concurrency Considerations

To keep it simple. I have not explicitly implemented thread-safety, which is needed to accommodate for concurrency
in a multithreaded environment.

Common considerations include:

#### 1. Cache Stampede

If two requests arrive simultaneously after TTL expiry, both will trigger an upstream fetch. A production fix might use
a `Deferred[F, ...]` to let one request fetch while others wait (request coalescing)
4 changes: 3 additions & 1 deletion forex-mtl/build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Dependencies._
import Dependencies.*

name := "forex"
version := "1.0.1"
Expand Down Expand Up @@ -53,9 +53,11 @@ libraryDependencies ++= Seq(
compilerPlugin(Libraries.kindProjector),
Libraries.cats,
Libraries.catsEffect,
Libraries.log4cats,
Libraries.fs2,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sClient,
Libraries.http4sCirce,
Libraries.circeCore,
Libraries.circeGeneric,
Expand Down
11 changes: 7 additions & 4 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import sbt._
import sbt.*

object Dependencies {

object Versions {
val cats = "2.6.1"
val catsEffect = "2.5.1"
val log4cats = "1.3.1"
val fs2 = "2.5.4"
val http4s = "0.22.15"
val circe = "0.14.2"
Expand All @@ -21,12 +22,14 @@ object Dependencies {
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 cats = "org.typelevel" %% "cats-core" % Versions.cats
lazy val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect
lazy val log4cats = "org.typelevel" %% "log4cats-slf4j" % Versions.log4cats
lazy val fs2 = "co.fs2" %% "fs2-core" % Versions.fs2

lazy val http4sDsl = http4s("http4s-dsl")
lazy val http4sServer = http4s("http4s-blaze-server")
lazy val http4sClient = http4s("http4s-blaze-client")
lazy val http4sCirce = http4s("http4s-circe")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
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 = 8000
timeout = 40 seconds
}
}
rate-service {
host = "0.0.0.0"
port = 8080
# assigning 70% of total timeout budget with +1 retry (40s*70%/2)
timeout = 14 seconds
retry = 1

# WARN!: Ideally, api tokens will never be checked into VCS, but keeping it here for the assignment review
token = "10dc303535874aeccc86a8251e6992f5"
}
}
5 changes: 3 additions & 2 deletions forex-mtl/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
</appender>

<root level="INFO">
<appender-ref ref="STDOUT" />
<appender-ref ref="STDOUT"/>
</root>

<logger name="http4s"/>
</configuration>
<logger name="forex" level="DEBUG"/>
</configuration>

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

import scala.concurrent.ExecutionContext
import cats.effect._
import forex.config._
import forex.services.RatesServices
import fs2.Stream
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder

import scala.concurrent.ExecutionContext

object Main extends IOApp {

override def run(args: List[String]): IO[ExitCode] =
Expand All @@ -18,7 +21,12 @@ class Application[F[_]: ConcurrentEffect: Timer] {
def stream(ec: ExecutionContext): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)
client <- BlazeClientBuilder[F](ec)
.withRequestTimeout(config.rateService.timeout)
.withRetries(config.rateService.retry)
.stream
service <- Stream.eval(RatesServices.live[F](client, config.rateService))
module = new Module[F](config, service)
_ <- BlazeServerBuilder[F](ec)
.bindHttp(config.http.port, config.http.host)
.withHttpApp(module.httpApp)
Expand Down
8 changes: 3 additions & 5 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import org.http4s._
import org.http4s.implicits._
import org.http4s.server.middleware.{ AutoSlash, Timeout }

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

private val ratesService: RatesService[F] = RatesServices.dummy[F]
class Module[F[_]: Concurrent: Timer](config: ApplicationConfig, ratesService: RatesService[F]) {

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

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

type PartialMiddleware = HttpRoutes[F] => HttpRoutes[F]
type TotalMiddleware = HttpApp[F] => HttpApp[F]
private type PartialMiddleware = HttpRoutes[F] => HttpRoutes[F]
private type TotalMiddleware = HttpApp[F] => HttpApp[F]

private val routesMiddleware: PartialMiddleware = {
{ http: HttpRoutes[F] =>
Expand Down
9 changes: 9 additions & 0 deletions forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ import scala.concurrent.duration.FiniteDuration

case class ApplicationConfig(
http: HttpConfig,
rateService: RateServiceConfig
)

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

case class RateServiceConfig(
host: String,
port: Int,
timeout: FiniteDuration,
retry: Int,
token: String
)
41 changes: 22 additions & 19 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import cats.Show
sealed trait Currency

object Currency {
case object AUD extends Currency
case object CAD extends Currency
case object CHF extends Currency
case object EUR extends Currency
case object GBP extends Currency
case object NZD extends Currency
case object JPY extends Currency
case object SGD extends Currency
case object USD extends Currency
private case object AUD extends Currency
private case object CAD extends Currency
private case object CHF extends Currency
private case object EUR extends Currency
private case object GBP extends Currency
private case object NZD extends Currency
private case object JPY extends Currency
private case object SGD extends Currency
private case object USD extends Currency

val allCurrencyList: List[Currency] = List(AUD, CAD, CHF, EUR, GBP, NZD, JPY, SGD, USD)

implicit val show: Show[Currency] = Show.show {
case AUD => "AUD"
Expand All @@ -27,16 +29,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[String, 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 other => Left(s"Currency code {$other} not implemented yet!")
}

}
4 changes: 4 additions & 0 deletions forex-mtl/src/main/scala/forex/http/rates/Protocol.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ object Protocol {
timestamp: Timestamp
)

final case class ApiError(error: String)

implicit val currencyEncoder: Encoder[Currency] =
Encoder.instance[Currency] { show.show _ andThen Json.fromString }

Expand All @@ -36,4 +38,6 @@ object Protocol {
implicit val responseEncoder: Encoder[GetApiResponse] =
deriveConfiguredEncoder[GetApiResponse]

implicit val apiErrorEncoder: Encoder[ApiError] = deriveConfiguredEncoder[ApiError]

}
Loading