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
71 changes: 71 additions & 0 deletions ForexRate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Forex Rate Proxy Service

## Overview

This project is a local proxy service for retrieving foreign exchange rates.
It acts as an internal abstraction layer over a third-party Forex provider
(One-Frame), so downstream services do not need to interact directly with
external APIs.

The service is built using Scala, cats-effect, http4s, and circe, and follows
a functional and modular design.


## Key Requirements Addressed

- Retrieve exchange rates for supported currency pairs
- Ensure returned rates are no older than 5 minutes
- Support at least 10,000 requests per day using a single API token
- Work around the One-Frame service limitation of 1,000 requests per day
- Provide clear and descriptive error responses
- Handle third-party failures gracefully


## Architecture & Design

The application follows a layered design:

- **HTTP Layer**
Exposes a REST API for retrieving exchange rates.

- **Program Layer**
Orchestrates application logic and error mapping.

- **Service Layer**
Contains business logic, caching, and integration with third-party services.

- **Interpreter Layer**
Implements the One-Frame API client.

### Key Design Decisions

#### Caching
To work around the One-Frame rate limit (1,000 requests/day), the service
implements an in-memory cache with a configurable TTL (5 minutes).

- Cached rates are reused for identical currency pairs
- Fresh data is fetched once the cache expires
- This allows the service to safely handle 10,000+ requests per day

#### Functional Programming
- All side effects are modeled using `F[_]` and `cats-effect`
- No blocking calls are used
- Errors are represented explicitly using algebraic data types

#### Explicit JSON Parsing
Instead of relying on implicit HTTP JSON decoders, responses from One-Frame
are parsed explicitly from raw JSON strings. This provides clearer error
handling and avoids fragile implicit resolution, especially when working
with cats-effect 2 and http4s 0.22.

## Api Url
Get : /rates?from=USD&to=EUR

## Response
{
"from": "USD",
"to": "EUR",
"price": 0.71810472617368925,
"timestamp": "2026-01-25T06:11:05.003Z"
}

1 change: 1 addition & 0 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ libraryDependencies ++= Seq(
Libraries.fs2,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sClient,
Libraries.http4sCirce,
Libraries.circeCore,
Libraries.circeGeneric,
Expand Down
1 change: 1 addition & 0 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object Dependencies {

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
8 changes: 7 additions & 1 deletion forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
app {
http {
host = "0.0.0.0"
port = 8080
port = 8081
timeout = 40 seconds
}

one-frame {
base-url = "http://localhost:8080"
token = "10dc303535874aeccc86a8251e6992f5"
ttl = 5 minutes
}
}

19 changes: 14 additions & 5 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import cats.effect._
import forex.config._
import fs2.Stream
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.blaze.client.BlazeClientBuilder

object Main extends IOApp {

override def run(args: List[String]): IO[ExitCode] =
new Application[IO].stream(executionContext).compile.drain.as(ExitCode.Success)
new Application[IO].stream(ExecutionContext.global).compile.drain.as(ExitCode.Success)

}

Expand All @@ -18,11 +19,19 @@ class Application[F[_]: ConcurrentEffect: Timer] {
def stream(ec: ExecutionContext): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)

// create http4s client as a Resource
client <- Stream.resource(
BlazeClientBuilder[F](ec).resource
)

// pass client to Module
module = new Module[F](config, client)

_ <- 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
} yield ()

}
50 changes: 28 additions & 22 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
@@ -1,37 +1,43 @@
package forex

import cats.effect.{ Concurrent, Timer }
import cats.effect.{ ConcurrentEffect, Timer }
import forex.config.ApplicationConfig
import forex.http.rates.RatesHttpRoutes
import forex.services._
import forex.programs._
import forex.services._
import forex.services.rates.cache.InMemoryRateCache
import forex.services.rates.interpreters.OneFrameLive
import org.http4s._
import org.http4s.implicits._
import org.http4s.client.Client
import org.http4s.server.middleware.{ AutoSlash, Timeout }
import forex.services.rates.RatesServices

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

private val ratesService: RatesService[F] = RatesServices.dummy[F]

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

private val ratesHttpRoutes: HttpRoutes[F] = new RatesHttpRoutes[F](ratesProgram).routes
class Module[F[_]: ConcurrentEffect: Timer](
config: ApplicationConfig,
client: Client[F] // ✅ inject client
) {

type PartialMiddleware = HttpRoutes[F] => HttpRoutes[F]
type TotalMiddleware = HttpApp[F] => HttpApp[F]
private val cache =
new InMemoryRateCache[F](config.oneFrame.ttl)

private val routesMiddleware: PartialMiddleware = {
{ http: HttpRoutes[F] =>
AutoSlash(http)
}
}
private val oneFrame =
new OneFrameLive[F](
client,
Uri.unsafeFromString(config.oneFrame.baseUrl),
config.oneFrame.token
)

private val appMiddleware: TotalMiddleware = { http: HttpApp[F] =>
Timeout(config.http.timeout)(http)
}
private val ratesService: RatesService[F] =
RatesServices.live[F](oneFrame, cache)

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

val httpApp: HttpApp[F] = appMiddleware(routesMiddleware(http).orNotFound)
private val routes: HttpRoutes[F] =
new RatesHttpRoutes[F](ratesProgram).routes

val httpApp: HttpApp[F] =
Timeout(config.http.timeout)(
AutoSlash(routes).orNotFound
)
}
19 changes: 13 additions & 6 deletions forex-mtl/src/main/scala/forex/config/ApplicationConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ package forex.config
import scala.concurrent.duration.FiniteDuration

case class ApplicationConfig(
http: HttpConfig,
)
http: HttpConfig,
oneFrame: OneFrameConfig
)

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

case class OneFrameConfig(
baseUrl: String,
token: String,
ttl: FiniteDuration
)
24 changes: 13 additions & 11 deletions forex-mtl/src/main/scala/forex/domain/Currency.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ 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): Option[Currency] =
s.toUpperCase match {
case "AUD" => Some(AUD)
case "CAD" => Some(CAD)
case "CHF" => Some(CHF)
case "EUR" => Some(EUR)
case "GBP" => Some(GBP)
case "NZD" => Some(NZD)
case "JPY" => Some(JPY)
case "SGD" => Some(SGD)
case "USD" => Some(USD)
case _ => None
}

}
11 changes: 11 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 @@ -27,6 +27,17 @@ object Protocol {
implicit val currencyEncoder: Encoder[Currency] =
Encoder.instance[Currency] { show.show _ andThen Json.fromString }

import io.circe.Decoder

implicit val currencyDecoder: Decoder[Currency] =
Decoder.decodeString.emap { s =>
Currency.fromString(s) match {
case Some(currency) => Right(currency)
case None => Left(s"Invalid currency: $s")
}
}


implicit val pairEncoder: Encoder[Pair] =
deriveConfiguredEncoder[Pair]

Expand Down
13 changes: 11 additions & 2 deletions forex-mtl/src/main/scala/forex/http/rates/QueryParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ import org.http4s.dsl.impl.QueryParamDecoderMatcher

object QueryParams {

private[http] implicit val currencyQueryParam: QueryParamDecoder[Currency] =
QueryParamDecoder[String].map(Currency.fromString)
import org.http4s.ParseFailure

implicit val currencyQueryParam: QueryParamDecoder[Currency] =
QueryParamDecoder[String].emap { value =>
Currency.fromString(value)
.toRight(ParseFailure(
"Invalid currency",
s"Unsupported currency: $value"
))
}


object FromQueryParam extends QueryParamDecoderMatcher[Currency]("from")
object ToQueryParam extends QueryParamDecoderMatcher[Currency]("to")
Expand Down
31 changes: 23 additions & 8 deletions forex-mtl/src/main/scala/forex/http/rates/RatesHttpRoutes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package forex.http
package rates

import cats.effect.Sync
import cats.syntax.flatMap._
import forex.programs.RatesProgram
import forex.programs.rates.{ Protocol => RatesProgramProtocol }
import org.http4s.HttpRoutes
Expand All @@ -11,19 +10,35 @@ import org.http4s.server.Router

class RatesHttpRoutes[F[_]: Sync](rates: RatesProgram[F]) extends Http4sDsl[F] {

import Converters._, QueryParams._, Protocol._
import cats.syntax.flatMap._
import Converters._
import QueryParams._
import Protocol._
import forex.programs.rates.errors.Error

private[http] val prefixPath = "/rates"

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)
rates.get(RatesProgramProtocol.GetRatesRequest(from, to)).flatMap {
case Right(rate) =>
Ok(rate.asGetApiResponse)

case Left(err) =>
err match {
case Error.RateLookupFailed(msg) =>
ServiceUnavailable(msg)

case Error.RateNotFound(msg) =>
NotFound(msg)

case Error.InvalidCurrency(msg) =>
BadRequest(msg)
}
}
}

val routes: HttpRoutes[F] = Router(
prefixPath -> httpRoutes
)

val routes: HttpRoutes[F] =
Router(prefixPath -> httpRoutes)
}

15 changes: 13 additions & 2 deletions forex-mtl/src/main/scala/forex/programs/rates/errors.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,23 @@ 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
final case class RateNotFound(msg: String) extends Error
final case class InvalidCurrency(msg: String) extends Error
}


def toProgramError(error: RatesServiceError): Error = error match {
case RatesServiceError.OneFrameLookupFailed(msg) => Error.RateLookupFailed(msg)
case RatesServiceError.OneFrameLookupFailed(msg) =>
Error.RateLookupFailed(msg)

case RatesServiceError.RateNotFound(msg) =>
Error.RateNotFound(msg)
}

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package forex.services.rates

import cats.Applicative
import interpreters._
import forex.services.rates.interpreters.OneFrameDummy

object Interpreters {
def dummy[F[_]: Applicative]: Algebra[F] = new OneFrameDummy[F]()
def dummy[F[_]: Applicative]: Algebra[F] =
new OneFrameDummy[F]()
}
Loading