Skip to content

Commit

Permalink
Introduce tagless final encoding (closes #158)
Browse files Browse the repository at this point in the history
  • Loading branch information
BenFradet committed Mar 29, 2019
1 parent 21805b8 commit 1f14b94
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 143 deletions.
86 changes: 43 additions & 43 deletions src/main/scala/com.snowplowanalytics/forex/Forex.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import java.math.{BigDecimal, RoundingMode}

import scala.util.{Failure, Success, Try}

import cats.{Monad, Eval}
import cats.effect.Sync
import cats.implicits._
import cats.data.{EitherT, OptionT}
import cats.implicits._
import org.joda.money._

import errors._
Expand All @@ -29,14 +30,24 @@ import model._
/** Companion object to get Forex object */
object Forex {

def getForex[F[_]: Sync: ZonedClock](config: ForexConfig): F[Forex[F]] =
OerClient
.getClient[F](config)
.map(client => Forex(config, client))

def unsafeGetForex(config: ForexConfig)(implicit C: ZonedClock[Eval]): Eval[Forex[Eval]] =
OerClient
.getClient[Eval](config)
.map(client => Forex(config, client))

/**
* Fields for calculating currency rates conversions.
* Usually the currency rate has 6 decimal places
*/
val commonScale = 6
protected[forex] val commonScale = 6

/** Helper method to get the ratio of from:to in BigDecimal type */
def getForexRate(
protected[forex] def getForexRate(
fromCurrIsBaseCurr: Boolean,
baseOverFrom: BigDecimal,
baseOverTo: BigDecimal
Expand All @@ -47,11 +58,6 @@ object Forex {
} else {
baseOverTo
}

def getForex[F[_]: Sync](config: ForexConfig): F[Forex[F]] =
OerClient
.getClient[F](config)
.map(client => Forex(config, client))
}

/**
Expand All @@ -63,7 +69,7 @@ object Forex {
* @param config A configurator for Forex object
* @param client Passed down client that does actual work
*/
case class Forex[F[_]](config: ForexConfig, client: OerClient[F]) {
final case class Forex[F[_]: Monad: ZonedClock](config: ForexConfig, client: OerClient[F]) {

def rate: ForexLookupTo[F] = ForexLookupTo(1, config.baseCurrency, config, client)

Expand Down Expand Up @@ -107,7 +113,7 @@ case class Forex[F[_]](config: ForexConfig, client: OerClient[F]) {
* @param config Forex config
* @param client Passed down client that does actual work
*/
case class ForexLookupTo[F[_]](
final case class ForexLookupTo[F[_]: Monad: ZonedClock](
conversionAmount: Double,
fromCurr: CurrencyUnit,
config: ForexConfig,
Expand All @@ -119,7 +125,7 @@ case class ForexLookupTo[F[_]](
* @param toCurr Target currency
* @return ForexLookupWhen object which is final part of the fluent interface
*/
def to(toCurr: CurrencyUnit)(implicit F: Sync[F]): ForexLookupWhen[F] =
def to(toCurr: CurrencyUnit): ForexLookupWhen[F] =
ForexLookupWhen(conversionAmount, fromCurr, toCurr, config, client)
}

Expand All @@ -132,13 +138,13 @@ case class ForexLookupTo[F[_]](
* @param config Forex config
* @param client Passed down client that does actual work
*/
case class ForexLookupWhen[F[_]: Sync](
final case class ForexLookupWhen[F[_]: Monad](
conversionAmount: Double,
fromCurr: CurrencyUnit,
toCurr: CurrencyUnit,
config: ForexConfig,
client: OerClient[F]
) {
)(implicit C: ZonedClock[F]) {
// convert `conversionAmt` into BigDecimal representation for its later usage in BigMoney
val conversionAmt = new BigDecimal(conversionAmount)

Expand All @@ -148,25 +154,24 @@ case class ForexLookupWhen[F[_]: Sync](
* failed
*/
def now: F[Either[OerResponseError, Money]] = {
val fromF = EitherT(client.getLiveCurrencyValue(fromCurr))
val toF = EitherT(client.getLiveCurrencyValue(toCurr))

fromF
.product(toF)
.flatMapF {
case (fromRate, toRate) =>
val fromCurrIsBaseCurr = fromCurr == config.baseCurrency
val rate = Forex.getForexRate(fromCurrIsBaseCurr, fromRate, toRate)
// Note that if `fromCurr` is not the same as the base currency,
// then we need to add the <fromCurr, toCurr> pair into the cache in particular,
// because only <baseCurrency, toCurr> were added earlier
client.nowishCache
.filter(_ => fromCurr != config.baseCurrency)
.traverse(cache => Sync[F].delay(ZonedDateTime.now).map(dateTime => (cache, dateTime)))
.flatMap(_.traverse { case (cache, timeStamp) => cache.put((fromCurr, toCurr), (timeStamp, rate)) })
.map(_ => returnMoneyOrJodaError(rate))
}
.value
val product = for {
fromRate <- EitherT(client.getLiveCurrencyValue(fromCurr))
toRate <- EitherT(client.getLiveCurrencyValue(toCurr))
} yield (fromRate, toRate)

(product.flatMapF {
case (fromRate, toRate) =>
val fromCurrIsBaseCurr = fromCurr == config.baseCurrency
val rate = Forex.getForexRate(fromCurrIsBaseCurr, fromRate, toRate)
// Note that if `fromCurr` is not the same as the base currency,
// then we need to add the <fromCurr, toCurr> pair into the cache in particular,
// because only <baseCurrency, toCurr> were added earlier
client.nowishCache
.filter(_ => fromCurr != config.baseCurrency)
.traverse(cache => C.now().map(dateTime => (cache, dateTime)))
.flatMap(_.traverse { case (cache, timeStamp) => cache.put((fromCurr, toCurr), (timeStamp, rate)) })
.map(_ => returnMoneyOrJodaError(rate))
}).value
}

/**
Expand All @@ -177,17 +182,12 @@ case class ForexLookupWhen[F[_]: Sync](
* @return Money representation in target currency or OerResponseError object if API request
* failed
*/
def nowish: F[Either[OerResponseError, Money]] =
OptionT
.fromOption[F](client.nowishCache)
.flatMap(_ => lookupNowishCache(fromCurr, toCurr))
.withFilter {
case (time, _) =>
val nowishTime = ZonedDateTime.now.minusSeconds(config.nowishSecs.toLong)
nowishTime.isBefore(time) || nowishTime.equals(time)
}
.map { case (_, rate) => returnMoneyOrJodaError(rate) }
.getOrElseF(now)
def nowish: F[Either[OerResponseError, Money]] = (for {
(time, rate) <- lookupNowishCache(fromCurr, toCurr)
nowishTime <- OptionT.liftF(C.now().map(_.minusSeconds(config.nowishSecs.toLong)))
if (nowishTime.isBefore(time) || nowishTime.equals(time))
res = returnMoneyOrJodaError(rate)
} yield res).getOrElseF(now)

private def lookupNowishCache(
fromCurr: CurrencyUnit,
Expand Down
60 changes: 32 additions & 28 deletions src/main/scala/com.snowplowanalytics/forex/OerClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ package com.snowplowanalytics.forex
import java.time.{LocalDateTime, ZoneId, ZonedDateTime}
import java.math.{BigDecimal => JBigDecimal}

import cats.effect.Sync
import cats.Monad
import cats.implicits._
import cats.data.EitherT
import org.joda.money.CurrencyUnit
Expand All @@ -31,12 +31,11 @@ import responses._
* @param nowishCache - user defined nowishCache
* @param eodCache - user defined eodCache
*/
case class OerClient[F[_]: Sync](
case class OerClient[F[_]: Monad](
config: ForexConfig,
nowishCache: Option[NowishCache[F]] = None,
eodCache: Option[EodCache[F]] = None,
transport: Transport[F]
) {
eodCache: Option[EodCache[F]] = None
)(implicit T: Transport[F]) {

private val endpoint = "openexchangerates.org/api/"

Expand Down Expand Up @@ -70,7 +69,7 @@ case class OerClient[F[_]: Sync](
*/
def getLiveCurrencyValue(currency: CurrencyUnit): F[ApiRequestResult] = {
val action = for {
response <- EitherT(transport.receive(endpoint, latest))
response <- EitherT(T.receive(endpoint, latest))
liveCurrency <- EitherT(extractLiveCurrency(response, currency))
} yield liveCurrency
action.value
Expand All @@ -80,7 +79,7 @@ case class OerClient[F[_]: Sync](
response: OerResponse,
currency: CurrencyUnit
): F[ApiRequestResult] = {
val cacheAction = nowishCache.traverse { cache =>
val cacheActions = nowishCache.traverse { cache =>
config.accountLevel match {
// If the user is using Developer account,
// then base currency returned from the API is USD.
Expand Down Expand Up @@ -111,7 +110,7 @@ case class OerClient[F[_]: Sync](
}
}
}
cacheAction.map { _ =>
cacheActions.map { _ =>
response.rates
.get(currency)
.map(_.bigDecimal)
Expand Down Expand Up @@ -149,7 +148,7 @@ case class OerClient[F[_]: Sync](
} else {
val historicalLink = buildHistoricalLink(date)
val action = for {
response <- EitherT(transport.receive(endpoint, historicalLink))
response <- EitherT(T.receive(endpoint, historicalLink))
currency <- EitherT(extractHistoricalCurrency(response, currency, date))
} yield currency

Expand All @@ -170,7 +169,7 @@ case class OerClient[F[_]: Sync](
// to target currency and user-defined base currency
case DeveloperAccount =>
val usdOverBase = response.rates(config.baseCurrency)
Sync[F].delay(response.rates.foreach {
response.rates.toList.traverse_ {
case (currentCurrency, usdOverCurr) =>
val keyPair = (config.baseCurrency, currentCurrency, date)
val fromCurrIsBaseCurr = config.baseCurrency == CurrencyUnit.USD
Expand All @@ -182,25 +181,25 @@ case class OerClient[F[_]: Sync](
usdOverCurr.bigDecimal
)
)
})
}
// For Enterprise and Unlimited users, OER allows them to configure the base currencies.
// So the exchange rate returned from the API is between target currency and the base
// currency they defined.
case _ =>
Sync[F].delay(response.rates.foreach {
response.rates.toList.traverse_ {
case (currentCurrency, currencyValue) =>
val keyPair = (config.baseCurrency, currentCurrency, date)
cache.put(keyPair, currencyValue.bigDecimal)
})
}
}
}

cacheAction.map(
_ =>
response.rates
.get(currency)
.map(_.bigDecimal)
.toRight(OerResponseError(s"Currency not found in the API, invalid currency $currency", IllegalCurrency)))
cacheAction.map { _ =>
response.rates
.get(currency)
.map(_.bigDecimal)
.toRight(OerResponseError(s"Currency not found in the API, invalid currency $currency", IllegalCurrency))
}
}
}

Expand All @@ -212,11 +211,17 @@ case class OerClient[F[_]: Sync](
object OerClient {

/** Creates a client with a cache and sensible default ForexConfig */
def getClient[F[_]: Sync](appId: String, accountLevel: AccountType): F[OerClient[F]] =
def getClient[F[_]: Monad: Transport](
appId: String,
accountLevel: AccountType
)(
implicit CLM1: CreateLruMap[F, NowishCacheKey, NowishCacheValue],
CLM2: CreateLruMap[F, EodCacheKey, EodCacheValue]
): F[OerClient[F]] =
getClient[F](ForexConfig(appId = appId, accountLevel = accountLevel))

/** Getter for clients, creating the caches as defined in the config */
def getClient[F[_]: Sync](
def getClient[F[_]: Monad: Transport](
config: ForexConfig
)(
implicit CLM1: CreateLruMap[F, NowishCacheKey, NowishCacheValue],
Expand All @@ -226,26 +231,25 @@ object OerClient {
if (config.nowishCacheSize > 0) {
CLM1.create(config.nowishCacheSize).map(_.some)
} else {
Sync[F].pure(Option.empty[NowishCache[F]])
Monad[F].pure(Option.empty[NowishCache[F]])
}

val eodCacheF =
if (config.eodCacheSize > 0) {
CLM2.create(config.eodCacheSize).map(_.some)
} else {
Sync[F].pure(Option.empty[EodCache[F]])
Monad[F].pure(Option.empty[EodCache[F]])
}

(nowishCacheF, eodCacheF).mapN {
case (nowish, eod) =>
new OerClient[F](config, nowishCache = nowish, eodCache = eod, Transport.httpTransport[F])
new OerClient[F](config, nowishCache = nowish, eodCache = eod)
}
}

def getClient[F[_]: Sync](
def getClient[F[_]: Monad: Transport](
config: ForexConfig,
nowishCache: Option[NowishCache[F]],
eodCache: Option[EodCache[F]],
transport: Transport[F]
): OerClient[F] = new OerClient[F](config, nowishCache, eodCache, transport)
eodCache: Option[EodCache[F]]
): OerClient[F] = new OerClient[F](config, nowishCache, eodCache)
}
4 changes: 2 additions & 2 deletions src/main/scala/com.snowplowanalytics/forex/Transport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ object Transport {
* Http Transport leveraging cats-effect's Sync.
* @return a Sync Transport
*/
def httpTransport[F[_]: Sync]: Transport[F] = new Transport[F] {
implicit def httpTransport[F[_]: Sync]: Transport[F] = new Transport[F] {

implicit val interpreter = ApacheInterpreter[F]

Expand All @@ -56,7 +56,7 @@ object Transport {
* Unsafe http Transport to use in cases where you have to do side-effects (e.g. spark or beam).
* @return an Eval Transport
*/
def unsafeHttpTransport: Transport[Eval] = new Transport[Eval] {
implicit def unsafeHttpTransport: Transport[Eval] = new Transport[Eval] {

implicit val interpreter = ApacheInterpreter[IO]

Expand Down
32 changes: 32 additions & 0 deletions src/main/scala/com.snowplowanalytics/forex/ZonedClock.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2015-2019 Snowplow Analytics Ltd. All rights reserved.
*
* This program is licensed to you under the Apache License Version 2.0,
* and you may not use this file except in compliance with the Apache License Version 2.0.
* You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the Apache License Version 2.0 is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
*/
package com.snowplowanalytics.forex

import java.time.ZonedDateTime

import cats.Eval
import cats.effect.Sync

trait ZonedClock[F[_]] {
def now(): F[ZonedDateTime]
}

object ZonedClock {
implicit def zonedClock[F[_]: Sync]: ZonedClock[F] = new ZonedClock[F] {
def now(): F[ZonedDateTime] = Sync[F].delay(ZonedDateTime.now)
}

implicit def unsafeZonedClock: ZonedClock[Eval] = new ZonedClock[Eval] {
def now(): Eval[ZonedDateTime] = Eval.now(ZonedDateTime.now)
}
}
Loading

0 comments on commit 1f14b94

Please sign in to comment.