Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@ TAGS
tests.iml
# Auto-copied by sbt-microsites
docs/src/main/tut/contributing.md

# Bloop
.bsp

# VS Code
.vscode/

# Metals
.bloop/
.metals/
metals.sbt

# IDEA
.idea
.idea_modules
/.worksheet/
76 changes: 76 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Forex Proxy Service

A local proxy service for fetching currency exchange rates, built with Scala.This service acts as a middleware for the One-Frame API, providing caching and freshness guarantees to overcome upstream limitations.

## 🚀 Quick Start

### 1. Prerequisites
- **Java 11+**
- **sbt** (Scala Build Tool)
- **Docker** (to run the One-Frame upstream service)

### 2. Run the Upstream Service (One-Frame)
The application requires the One-Frame API to be running locally.
```bash
docker pull paidyinc/one-frame
docker run -p 8080:8080 paidyinc/one-frame
```

### 3. Build and Run the Application
Navigate to the `forex-mtl` directory and start the server:
```bash
cd forex-mtl
sbt run
```
The server starts on `http://localhost:3000`by default. You can change the port in `application.conf`.

## 🛠 Usage

### Get Exchange Rate
Fetch the current exchange rate between two supported currencies.

**Endpoint:** `GET /rates`

**Parameters:**
- `from`: Source currency code (e.g., USD)
- `to`: Target currency code (e.g., JPY)

**Example Request:**
```bash
curl "http://localhost:3000/rates?from=USD&to=JPY"
```

**Example Response:**
```json
{
"from": "USD",
"to": "JPY",
"bid": 0.61,
"ask": 0.82,
"price": 0.71,
"timestamp": "2023-10-27T10:00:00Z"
}
```

## 🧪 Testing
Run the unit tests to verify caching and rate retrieval logic:
```bash
sbt test
```
## ⚙️ Configuration
Settings for the HTTP server, cache TTL, and One-Frame API can be found in:
`src/main/resources/application.conf`

## 🔍 Test the Service via Curl
Run curl command to test the service:
```bash
curl "http://localhost:{your-port}/rates?from=USD&to=JPY"
```

## 💡 Key Features
- **Intelligent Caching**: Implements an in-memory cache with a 5-minute TTL.
- **API Limit Management**: Designed to support 10,000+ requests/day by minimizing calls to the One-Frame API (which has a 1,000 request/day limit).
- **Rate Freshness**: Automatically refreshes rates older than 5 minutes to ensure data accuracy.
- **Descriptive Errors**: Provides clear feedback for invalid currencies or upstream failures.


7 changes: 4 additions & 3 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Dependencies._
name := "forex"
version := "1.0.1"

scalaVersion := "2.13.12"
scalaVersion := "2.13.14"
scalacOptions ++= Seq(
"-deprecation", // Emit warning and location for usages of deprecated APIs.
"-encoding",
Expand Down Expand Up @@ -54,6 +54,7 @@ libraryDependencies ++= Seq(
Libraries.cats,
Libraries.catsEffect,
Libraries.fs2,
Libraries.http4sClient,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sCirce,
Expand All @@ -63,7 +64,7 @@ libraryDependencies ++= Seq(
Libraries.circeParser,
Libraries.pureConfig,
Libraries.logback,
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Libraries.scalaTest % Test,
Libraries.scalaCheck % Test,
Libraries.catsScalaCheck % Test
)
3 changes: 2 additions & 1 deletion forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ object Dependencies {
val circe = "0.14.2"
val pureConfig = "0.17.4"

val kindProjector = "0.13.2"
val kindProjector = "0.13.3"
val logback = "1.2.3"
val scalaCheck = "1.15.3"
val scalaTest = "3.2.7"
Expand All @@ -28,6 +28,7 @@ object Dependencies {
lazy val http4sDsl = http4s("http4s-dsl")
lazy val http4sServer = http4s("http4s-blaze-server")
lazy val http4sCirce = http4s("http4s-circe")
lazy val http4sClient = http4s("http4s-blaze-client")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
lazy val circeGenericExt = circe("circe-generic-extras")
Expand Down
2 changes: 1 addition & 1 deletion forex-mtl/project/build.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sbt.version=1.8.0
sbt.version=1.10.0
7 changes: 6 additions & 1 deletion forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
app {
http {
host = "0.0.0.0"
port = 8080
port = 3000
timeout = 40 seconds
}
one-frame {
base-url = "http://localhost:8080"
token = "10dc303535874aeccc86a8251e6992f5"
ttl = 5 minutes
}
}

9 changes: 5 additions & 4 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,23 @@ 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)
}

class Application[F[_]: ConcurrentEffect: Timer] {

def stream(ec: ExecutionContext): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)
client <- Stream.resource(BlazeClientBuilder[F](ec).resource)
module = new Module[F](config, client)
_ <- BlazeServerBuilder[F](ec)
.bindHttp(config.http.port, config.http.host)
.bindHttp(config.http.port, "0.0.0.0")
.withHttpApp(module.httpApp)
.serve
} yield ()
Expand Down
30 changes: 20 additions & 10 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
package forex

import cats.effect.{ Concurrent, Timer }
import cats.effect.{Concurrent, Timer}
import forex.config.ApplicationConfig
import forex.http.rates.RatesHttpRoutes
import forex.services._
import forex.services.cache.CacheService
import forex.programs._
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]
import org.http4s.server.middleware.{AutoSlash, Timeout}
import org.http4s.client.Client
import cats.effect.IO

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

private val ratesService: RatesService[F] = {
// cache service instantiated in IO context
val cache = CacheService[IO].unsafeRunSync().asInstanceOf[CacheService[F]]
RatesServices.live[F](config, client, cache)
}

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

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

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

private val routesMiddleware: PartialMiddleware = {
{ http: HttpRoutes[F] =>
Expand All @@ -34,4 +44,4 @@ class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {

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

}
}
7 changes: 7 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,17 @@ import scala.concurrent.duration.FiniteDuration

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

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

case class OneFrameConfig(
baseUrl: String,
token: String,
ttl: FiniteDuration
)
2 changes: 2 additions & 0 deletions forex-mtl/src/main/scala/forex/domain/Rate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package forex.domain
case class Rate(
pair: Rate.Pair,
price: Price,
bid: Price,
ask: Price,
Comment on lines +6 to +7
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added bid and ask fields to better match with response from OneFrame

timestamp: Timestamp
)

Expand Down
16 changes: 16 additions & 0 deletions forex-mtl/src/main/scala/forex/http/rates/Converters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@ import forex.domain._
object Converters {
import Protocol._

// Response body for GET /rates?from={currencyPair}&to={currencyPair}
private[rates] implicit class GetApiResponseOps(val rate: Rate) extends AnyVal {
def asGetApiResponse: GetApiResponse =
GetApiResponse(
from = rate.pair.from,
to = rate.pair.to,
price = rate.price,
bid = rate.bid,
ask = rate.ask,
timestamp = rate.timestamp
)
}

// Response body for POST /rates
private[rates] implicit class PostApiResponseOps(val rate: Rate) extends AnyVal {
def asPostApiResponse(amount: BigDecimal): PostApiResponse =
PostApiResponse(
from = rate.pair.from,
to = rate.pair.to,
amount = amount,
exchangeRate = rate.price,
convertedAmount = amount * rate.price.value,
timestamp = rate.timestamp
)
}
Expand Down
67 changes: 64 additions & 3 deletions forex-mtl/src/main/scala/forex/http/rates/Protocol.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,64 @@ import forex.domain.Rate.Pair
import forex.domain._
import io.circe._
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto.deriveConfiguredEncoder
import io.circe.generic.extras.semiauto.{
deriveConfiguredDecoder,
deriveConfiguredEncoder
}

object Protocol {

implicit val configuration: Configuration = Configuration.default.withSnakeCaseMemberNames
implicit val configuration: Configuration =
Configuration.default

// Request body for GET /rates
final case class GetApiRequest(
from: Currency,
to: Currency
)

// Response body for GET /rates
final case class GetApiResponse(
from: Currency,
to: Currency,
price: Price,
bid: Price,
ask: Price,
timestamp: Timestamp
)

// Request body for POST /rates
final case class PostApiRequest(
from: Currency,
to: Currency,
amount: BigDecimal
)

// Response body for POST /rates
final case class PostApiResponse(
from: Currency,
to: Currency,
amount: BigDecimal,
exchangeRate: Price,
convertedAmount: BigDecimal,
timestamp: Timestamp
)
// Response body for errors
final case class ErrorResponse(
message: String
)

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

implicit val currencyDecoder: Decoder[Currency] =
Decoder.decodeString.emap { s =>
Comment on lines +58 to +59
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added custom Decoder that validates currency strings against supported values in One Frame

scala.util
.Try(Currency.fromString(s))
.toEither
.left
.map(_ => s"Invalid currency: $s")
}

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

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

implicit val postRequestDecoder: Decoder[PostApiRequest] =
deriveConfiguredDecoder[PostApiRequest]
.emap { request =>
val errors = scala.collection.mutable.ListBuffer[String]()

// Validate amount is not negative
if (request.amount < 0) {
errors += s"Field 'amount' must be a positive number"
}

// checks if there are any errors in the list
if (errors.nonEmpty) {
Left(errors.mkString("; "))
} else {
Right(request)
}
}

implicit val postResponseEncoder: Encoder[PostApiResponse] =
deriveConfiguredEncoder[PostApiResponse]

implicit val errorResponseEncoder: Encoder[ErrorResponse] =
deriveConfiguredEncoder[ErrorResponse]

}
Loading