fixer (empty) → 0.0.0.0
raw patch · 16 files changed
+1257/−0 lines, 16 filesdep +QuickCheckdep +aesondep +basesetup-changed
Dependencies added: QuickCheck, aeson, base, containers, directory, fixer, genvalidity, genvalidity-containers, genvalidity-hspec, genvalidity-hspec-aeson, genvalidity-text, genvalidity-time, hspec, http-api-data, http-client, mtl, servant, servant-client, stm, text, time, validity, validity-containers, validity-time, yaml
Files
- ChangeLog.md +3/−0
- LICENSE +30/−0
- README.md +64/−0
- Setup.hs +3/−0
- fixer.cabal +100/−0
- src/Fixer.hs +32/−0
- src/Fixer/API.hs +44/−0
- src/Fixer/Cache.hs +278/−0
- src/Fixer/Client.hs +201/−0
- src/Fixer/Types.hs +198/−0
- test/Fixer/Cache/Gen.hs +23/−0
- test/Fixer/CacheSpec.hs +189/−0
- test/Fixer/Types/Gen.hs +36/−0
- test/Fixer/TypesSpec.hs +36/−0
- test/Spec.hs +1/−0
- test/TestImport.hs +19/−0
+ ChangeLog.md view
@@ -0,0 +1,3 @@+# Changelog for fixer++## Unreleased changes
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Tom Sydney Kerckhove (c) 2018++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Tom Sydney Kerckhove nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,64 @@+# Fixer, a Haskell client for http://fixer.io/++## Automatic Transparent Caching++Because historical currency conversion rates do not change post-hoc,+we can cache them indefinitely.+Because of this, and because http://fixer.io/ is a free service,+we should cache the results of the API as much as possible.+This client automatically transparenly caches results so that the real+API is only called when absolutely necessary.++## Example usage:++``` Haskell+main :: IO+main = do+ rates <-+ autoRunFixerClient $+ withFileCache "/tmp/fixer.cache" $+ getAtDate (fromGregorian 2018 01 19) (Just EUR) Nothing+ case rates of+ Left err -> die $ show err+ Right v -> print v+```++```+Rates+ { ratesBase = EUR+ , ratesDate = 2018-01-19+ , ratesRates = fromList+ [ (AUD,Rate {unRate = 6891408149802333 % 4503599627370496})+ , (BGN,Rate {unRate = 550508759450701 % 281474976710656})+ , (BRL,Rate {unRate = 8852275427559447 % 2251799813685248})+ , (CAD,Rate {unRate = 3433093995944529 % 2251799813685248})+ , (CHF,Rate {unRate = 5295332441862229 % 4503599627370496})+ , (CNY,Rate {unRate = 8836175058891597 % 1125899906842624})+ , (CZK,Rate {unRate = 3579376541341057 % 140737488355328})+ , (DKK,Rate {unRate = 8382775166406073 % 1125899906842624})+ , (GBP,Rate {unRate = 3979605810725939 % 4503599627370496})+ , (HKD,Rate {unRate = 5393848683710959 % 562949953421312})+ , (HRK,Rate {unRate = 4185645493678139 % 562949953421312})+ , (HUF,Rate {unRate = 5439503924933427 % 17592186044416})+ , (IDR,Rate {unRate = 16316 % 1})+ , (ILS,Rate {unRate = 2355213720128743 % 562949953421312})+ , (INR,Rate {unRate = 2752790087858127 % 35184372088832})+ , (JPY,Rate {unRate = 4768889792920289 % 35184372088832})+ , (KRW,Rate {unRate = 2873903492680909 % 2199023255552})+ , (MXN,Rate {unRate = 6422133068630327 % 281474976710656})+ , (MYR,Rate {unRate = 5433592950422503 % 1125899906842624})+ , (NOK,Rate {unRate = 5417999236712733 % 562949953421312})+ , (NZD,Rate {unRate = 3790004266413641 % 2251799813685248})+ , (PHP,Rate {unRate = 8745146051423371 % 140737488355328})+ , (PLN,Rate {unRate = 2348402025692345 % 562949953421312})+ , (RON,Rate {unRate = 2623009012971261 % 562949953421312})+ , (RUB,Rate {unRate = 609850721415725 % 8796093022208})+ , (SEK,Rate {unRate = 5535655776977787 % 562949953421312})+ , (SGD,Rate {unRate = 1821480869289997 % 1125899906842624})+ , (THB,Rate {unRate = 2749236466277155 % 70368744177664})+ , (TRY,Rate {unRate = 5241852196287205 % 1125899906842624})+ , (USD,Rate {unRate = 5519161343342543 % 4503599627370496})+ , (ZAR,Rate {unRate = 8418916553415721 % 562949953421312})+ ]+ }+```
+ Setup.hs view
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
+ fixer.cabal view
@@ -0,0 +1,100 @@+-- This file has been generated from package.yaml by hpack version 0.20.0.+--+-- see: https://github.com/sol/hpack+--+-- hash: b15a5d58d322d6a8b4c658cddf630b0d9daa679d2c80805d9d36f9420bbd8b29++name: fixer+version: 0.0.0.0+synopsis: A Haskell client for http://fixer.io/+description: Please see the README on Github at <https://github.com/NorfairKing/fixer#readme>+category: API+homepage: https://github.com/NorfairKing/fixer#readme+bug-reports: https://github.com/NorfairKing/fixer/issues+author: Tom Sydney Kerckhove+maintainer: syd.kerckhove@gmail.com+copyright: Copyright: (c) 2018 Tom Sydney Kerckhove+license: BSD3+license-file: LICENSE+build-type: Simple+cabal-version: >= 1.10++extra-source-files:+ ChangeLog.md+ README.md++source-repository head+ type: git+ location: https://github.com/NorfairKing/fixer++library+ hs-source-dirs:+ src+ build-depends:+ aeson+ , base >=4.7 && <5+ , containers+ , directory+ , http-api-data+ , http-client+ , mtl+ , servant+ , servant-client+ , stm+ , text+ , time+ , validity >=0.4.0.3+ , validity-containers+ , validity-time+ , yaml+ exposed-modules:+ Fixer+ Fixer.Types+ Fixer.API+ Fixer.Client+ Fixer.Cache+ other-modules:+ Paths_fixer+ default-language: Haskell2010++test-suite fixer-test+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ hs-source-dirs:+ test/+ default-extensions: NoImplicitPrelude+ ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall+ build-depends:+ QuickCheck+ , aeson+ , base >=4.9 && <=5+ , containers+ , directory+ , fixer+ , genvalidity >=0.4.0.4+ , genvalidity-containers+ , genvalidity-hspec+ , genvalidity-hspec-aeson+ , genvalidity-text+ , genvalidity-time+ , hspec+ , http-api-data+ , http-client+ , mtl+ , servant+ , servant-client+ , stm+ , text+ , time+ , validity >=0.4.0.3+ , validity-containers+ , validity-time+ , yaml+ other-modules:+ Fixer.Cache.Gen+ Fixer.CacheSpec+ Fixer.Types.Gen+ Fixer.TypesSpec+ TestImport+ Paths_fixer+ default-language: Haskell2010
+ src/Fixer.hs view
@@ -0,0 +1,32 @@+-- | The top-level API for the Haskell Fixer client.+--+-- There is an example usage in the 'README' file.+module Fixer+ ( FClient+ , autoRunFixerClient+ , RatesResult(..)+ , getLatest+ , getAtDate+ , withFileCache+ -- * Types+ , Currency(..)+ , Symbols(..)+ , Rate+ , Rates(..)+ , oneRate+ , mulRate+ , divRate+ , rateToDouble+ -- * Re-exports+ -- ** NonEmpty+ , NonEmpty(..)+ -- ** Date+ , Day+ , fromGregorian+ ) where++import Data.List.NonEmpty (NonEmpty(..))+import Data.Time++import Fixer.Client+import Fixer.Types
+ src/Fixer/API.hs view
@@ -0,0 +1,44 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeOperators #-}++-- | The raw API+module Fixer.API+ ( FixerAPI+ , GetLatest+ , GetAtDate+ , fixerAPI+ , getLatest+ , getAtDate+ ) where++import Data.Proxy+import Data.Time+import Servant.API+import Servant.Client++import Fixer.Types++-- | A 'Proxy' for 'FixerAPI'+fixerAPI :: Proxy FixerAPI+fixerAPI = Proxy++-- | The full API at api.fixer.io+type FixerAPI = GetLatest :<|> GetAtDate++-- | Get latest rates+--+-- @/latest?base=\<base\>&symbols=\<symbols\>@+type GetLatest+ = "latest" :> QueryParam "base" Currency :> QueryParam "symbols" Symbols :> Get '[ JSON] Rates++-- | Get rates on a given date+--+-- @/\<date\>?base=\<base\>&symbols=\<symbols\>@+type GetAtDate+ = Capture "date" Day :> QueryParam "base" Currency :> QueryParam "symbols" Symbols :> Get '[ JSON] Rates++-- | The client function for 'GetLatest'+getLatest :: Maybe Currency -> Maybe Symbols -> ClientM Rates+-- | The client function for 'GetAtDate'+getAtDate :: Day -> Maybe Currency -> Maybe Symbols -> ClientM Rates+getLatest :<|> getAtDate = client fixerAPI
+ src/Fixer/Cache.hs view
@@ -0,0 +1,278 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | Caches for the raw API+module Fixer.Cache+ ( FixerCache(..)+ , insertRates+ , FixerCacheResult(..)+ , lookupRates+ , emptyFixerCache+ , RateCache(..)+ , emptyRateCache+ , insertRatesInCache+ , lookupRatesInCache+ , smartInsertInCache+ , smartLookupRateInCache+ -- Defaults+ , defaultBaseCurrency+ , allSymbolsExcept+ -- Helpers+ , convertToBaseWithRate+ , rawInsertInCache+ , rawLookupInCache+ ) where++import Control.Monad+import Data.Aeson+import qualified Data.List.NonEmpty as NE+import qualified Data.Map as M+import Data.Map (Map)+import Data.Maybe+import qualified Data.Set as S+import Data.Set (Set)+import Data.Time+import Data.Validity+import GHC.Generics (Generic)++import Fixer.Types++-- | A complete cache for the raw API.+--+-- This includes a cache for the rates we get, as well as a cache for the+-- rates we do not get.+data FixerCache = FixerCache+ { fCacheRates :: RateCache+ , fCacheDaysWithoutRates :: Set Day+ } deriving (Show, Eq, Generic)++instance Validity FixerCache++instance FromJSON FixerCache where+ parseJSON =+ withObject "FixerCache" $ \o ->+ FixerCache <$> o .: "rates" <*> o .: "days-without-rates"++instance ToJSON FixerCache where+ toJSON FixerCache {..} =+ object+ [ "rates" .= fCacheRates+ , "days-without-rates" .= fCacheDaysWithoutRates+ ]++-- | Insert a given raw response in a 'FixerCache'+insertRates ::+ Day -- ^ The current date+ -> Day -- ^ The requested date+ -> Rates+ -> FixerCache+ -> FixerCache+insertRates n d r fc =+ if ratesDate r == d+ then let rc' = insertRatesInCache r $ fCacheRates fc+ in fc {fCacheRates = rc'}+ else if d >= n+ then fc+ else let dwr' = S.insert d $ fCacheDaysWithoutRates fc+ in fc {fCacheDaysWithoutRates = dwr'}++-- | The result of looking up rates in a 'FixerCache'+data FixerCacheResult+ = NotInCache+ | CacheDateNotInPast -- ^ Because we requested a date in the future+ | WillNeverExist -- ^ Because it was on a weekend or holiday+ | InCache Rates+ deriving (Show, Eq, Generic)++instance Validity FixerCacheResult++-- | Look up rates in cache+lookupRates ::+ Day -- ^ The current date+ -> Day -- ^ The requested date+ -> Currency+ -> Symbols+ -> FixerCache+ -> FixerCacheResult+lookupRates n d c s FixerCache {..} =+ if d >= n+ then CacheDateNotInPast+ else if S.member d fCacheDaysWithoutRates+ then WillNeverExist+ else case lookupRatesInCache d c s fCacheRates of+ Nothing -> NotInCache+ Just r -> InCache r++-- | The empty 'FixerCache'+emptyFixerCache :: FixerCache+emptyFixerCache =+ FixerCache {fCacheRates = emptyRateCache, fCacheDaysWithoutRates = S.empty}++-- | A cache for currency rates+--+-- This cache uses 'EUR' as the base currency, but will still cache+-- rates appropriately if rates with a different base currency are cached.+newtype RateCache = RateCache+ { unRateCache :: Map Day (Map Currency (Map Currency Rate))+ } deriving (Show, Eq, Generic, FromJSON, ToJSON)++instance Validity RateCache where+ validate RateCache {..} =+ mconcat+ [ unRateCache <?!> "unRateCache"+ , let go :: Map Currency (Map Currency Rate) -> Bool+ go m =+ not . or $+ M.mapWithKey (\c m_ -> isJust (M.lookup c m_)) m+ in all go unRateCache <?@>+ "Does not contain conversions to from a currency to itself"+ ]+ isValid = isValidByValidating++-- | The empty Cache+emptyRateCache :: RateCache+emptyRateCache = RateCache M.empty++-- | Insert a rate into the cache as-is.+--+-- You probably want to be using 'insertRatesInCache' or 'smartInsertInCache' instead.+rawInsertInCache ::+ Day -> Currency -> Currency -> Rate -> RateCache -> RateCache+rawInsertInCache d from to rate (RateCache fc) = RateCache $ M.alter go1 d fc+ where+ go1 :: Maybe (Map Currency (Map Currency Rate))+ -> Maybe (Map Currency (Map Currency Rate))+ go1 Nothing = Just $ M.singleton from $ M.singleton to rate+ go1 (Just c1) = Just $ M.alter go2 from c1+ go2 :: Maybe (Map Currency Rate) -> Maybe (Map Currency Rate)+ go2 Nothing = Just $ M.singleton to rate+ go2 (Just c2) = Just $ M.insert to rate c2++-- | Lookup a rate in the cache as-is.+--+-- You probably want to be using 'smartLookupRateInCache' instead.+rawLookupInCache :: Day -> Currency -> Currency -> RateCache -> Maybe Rate+rawLookupInCache d from to (RateCache fc) =+ M.lookup d fc >>= M.lookup from >>= M.lookup to++-- | The default base currency. Currently this is 'EUR'+defaultBaseCurrency :: Currency+defaultBaseCurrency = EUR++-- | The symbols to get by default, given a base currency.+allSymbolsExcept :: Currency -> Symbols+allSymbolsExcept base =+ Symbols $ NE.fromList $ filter (/= base) [minBound .. maxBound]++-- | Insert a result into the cache.+--+-- This is probably the function you want to use, it does all the smartness.+insertRatesInCache :: Rates -> RateCache -> RateCache+insertRatesInCache rs fc =+ if ratesBase rs == defaultBaseCurrency+ then insertRatesAsIs rs+ -- If we're not already using the base, then we need to see if we can figure out how many+ -- of this base we can get for the default base+ -- We can figure this out in two ways:+ -- 1 if the default base is in the rates+ else case M.lookup defaultBaseCurrency $ ratesRates rs of+ Just r -> insertRatesAtOtherBase r rs+ Nothing+ -- or+ -- 2 if the default base is in the cache+ ->+ case rawLookupInCache+ (ratesDate rs)+ (ratesBase rs)+ defaultBaseCurrency+ fc of+ Just r -> insertRatesAtOtherBase r rs+ Nothing+ -- If we find neither, then we just save in the cache as-is+ -> insertRatesAsIs rs+ where+ insertRatesAsIs :: Rates -> RateCache+ insertRatesAsIs rates =+ M.foldlWithKey (go (ratesBase rates)) fc $ ratesRates rates+ insertRatesAtOtherBase :: Rate -> Rates -> RateCache+ insertRatesAtOtherBase r =+ insertRatesAsIs . convertToBaseWithRate defaultBaseCurrency r+ go :: Currency -> RateCache -> Currency -> Rate -> RateCache+ go base fc_ c r = smartInsertInCache (ratesDate rs) base c r fc_++-- | Insert a rate in a cache, but don't insert it if the from and to currencies are the same.+smartInsertInCache ::+ Day -> Currency -> Currency -> Rate -> RateCache -> RateCache+smartInsertInCache date from to rate fc =+ if from == to+ then fc+ else rawInsertInCache date from to rate fc++-- | Look up multiple rates in a cache.+--+-- This function uses 'smartLookupRateInCache' for each requested symbol.+lookupRatesInCache :: Day -> Currency -> Symbols -> RateCache -> Maybe Rates+lookupRatesInCache date base (Symbols nec) fc =+ Rates base date <$>+ (M.fromList <$>+ mapM+ (\to -> (,) to <$> smartLookupRateInCache date base to fc)+ (NE.filter (/= base) nec))++-- | Look up a rate in a cache.+--+-- This function will try to be smart about what it can find, but will+-- give up after one redirection.+smartLookupRateInCache :: Day -> Currency -> Currency -> RateCache -> Maybe Rate+smartLookupRateInCache date from to fc@(RateCache m) =+ if from == to+ then Just oneRate+ else case rawLookupInCache date from to fc of+ Just r -> pure r+ -- First try to look up at the correct base currency+ -- If that works, return it.+ -- Otherwise, try all the other bases at that day, and convert if necessary.+ Nothing -> do+ dm <- M.lookup date m+ msum $+ M.elems $+ flip M.mapWithKey dm $ \newFrom nfm ->+ lookupVia newFrom from to nfm++lookupVia :: Currency -> Currency -> Currency -> Map Currency Rate -> Maybe Rate+lookupVia newFrom from to nfm = do+ nfr <-+ if newFrom == from+ then Just oneRate+ else M.lookup from nfm+ -- This is the rate at which we can convert from newFrom to from+ -- for each 'from', you get '1/nfr' newFroms+ tr <-+ if newFrom == to+ then Just oneRate+ else M.lookup to nfm+ -- This is the rate at which we can convert from newFrom to to+ pure $ divRate tr nfr++-- | Convert a set of rates to another base currency with the given rate of the new base currency+-- with respect to the old base currency.+-- In the map, we have the info that+-- for 1 base currency, you get s of the currency in the key.+--+-- If we now say that for 1 of the old base currency, you can get+-- r of the new base currency+--+-- This rate means for one of the new base currency, you can get s / r of+-- the currency in the key.+convertToBaseWithRate :: Currency -> Rate -> Rates -> Rates+convertToBaseWithRate new rate rs =+ if ratesBase rs == new+ then rs+ else rs {ratesBase = new, ratesRates = newRates}+ where+ newRates =+ M.map (`divRate` rate) . withOldBase . withoutNewBase $ ratesRates rs+ withOldBase = M.insert (ratesBase rs) oneRate+ withoutNewBase = M.delete new
+ src/Fixer/Client.hs view
@@ -0,0 +1,201 @@+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE DeriveGeneric #-}++-- | The transparently caching client+module Fixer.Client+ ( autoRunFixerClient+ , defaultConfig+ , runFixerClient+ , FClient+ , getLatest+ , getAtDate+ , RatesResult(..)+ , withFileCache+ , readCacheFromFileIfExists+ , flushCacheToFile+ ) where++import Control.Concurrent+import Control.Concurrent.STM+import Control.Monad.Reader+import Data.Maybe+import Data.Time+import Data.Validity+import qualified Data.Yaml as Yaml+import GHC.Generics+import Network.HTTP.Client (defaultManagerSettings, newManager)+import Servant.Client+import System.Directory (doesFileExist)++import qualified Fixer.API as Raw+import Fixer.Cache+import Fixer.Types++-- | A client function+newtype FClient a = FClient+ { runFClient :: ReaderT FEnv ClientM a+ } deriving (Functor, Applicative, Monad, MonadReader FEnv, MonadIO)++data FEnv = FEnv+ { fEnvConfig :: Config+ , fEnvLastFetch :: TVar (Maybe UTCTime)+ , fEnvCache :: TVar FixerCache+ } deriving (Generic)++newtype Config = Config+ { confRateDelay :: Int -- ^ Microseconds+ } deriving (Show, Eq, Generic)++-- | Run a 'FClient' action and figure out the 'ClientEnv' and 'FixerCache'+-- arguments automatically.+--+-- This is probably the function you want to use+autoRunFixerClient :: FClient a -> IO (Either ServantError a)+autoRunFixerClient func = do+ man <- newManager defaultManagerSettings+ burl <- parseBaseUrl "http://api.fixer.io/"+ env <- makeEnv+ runFixerClient env (ClientEnv man burl) func++makeEnv :: IO FEnv+makeEnv = do+ lastFetchVar <- newTVarIO Nothing+ cacheVar <- newTVarIO emptyFixerCache+ pure+ FEnv+ { fEnvConfig = defaultConfig+ , fEnvLastFetch = lastFetchVar+ , fEnvCache = cacheVar+ }++-- | A default configuration for the client: One second of delay between calls+defaultConfig :: Config+defaultConfig =+ Config+ { confRateDelay = 1 * microsecondsPerSecond -- One second+ }++-- | Run a 'FClient' action with full control over the inputs.+runFixerClient :: FEnv -> ClientEnv -> FClient a -> IO (Either ServantError a)+runFixerClient env ce func = runClientM (runReaderT (runFClient func) env) ce++-- | Get the latest rates.+--+-- Note that this function fetches the latest rates, but that does not mean+-- that the latest symbols appeared on the current date. However, there is no+-- way to predict what date the last rates appeared on, so we still look in the+-- cache at the current date. For maximum cache hits, use 'getAtDate' and only+-- look at the past beyond the last three days.+getLatest :: Maybe Currency -> Maybe Symbols -> FClient RatesResult+getLatest mc ms = do+ date <- liftIO $ utctDay <$> getCurrentTime+ withCacheAndRate date mc ms $ Raw.getLatest mc ms++-- | Get the rates at a specific date.+getAtDate :: Day -> Maybe Currency -> Maybe Symbols -> FClient RatesResult+getAtDate date mc ms = withCacheAndRate date mc ms $ Raw.getAtDate date mc ms++microsecondsPerSecond :: Int+microsecondsPerSecond = 1000 * 1000++-- | The result of calling the API the local cache+data RatesResult+ = DateNotInPast -- ^ because you tried to call the API for a future date+ | RateDoesNotExist -- ^ because the date is on a weekend, for example+ | RatesFound Rates+ deriving (Show, Eq, Generic)++instance Validity RatesResult++withCacheAndRate ::+ Day+ -> Maybe Currency+ -> Maybe Symbols+ -> ClientM Rates+ -> FClient RatesResult+withCacheAndRate requestDate mc ms func = do+ let base = fromMaybe defaultBaseCurrency mc+ let symbols = fromMaybe (allSymbolsExcept base) ms+ cacheVar <- asks fEnvCache+ now <- liftIO $ getCurrentTime+ let nowDate = utctDay now+ c <- liftIO $ readTVarIO cacheVar+ case lookupRates nowDate requestDate base symbols c of+ NotInCache -> do+ rates <- rateLimit $ func+ liftIO $+ atomically $+ modifyTVar' cacheVar (insertRates nowDate requestDate rates)+ pure $+ if ratesDate rates == requestDate+ then RatesFound rates+ else if ratesDate rates >= nowDate+ then DateNotInPast+ else RateDoesNotExist+ CacheDateNotInPast -> pure DateNotInPast+ WillNeverExist -> pure RateDoesNotExist+ InCache r -> pure $ RatesFound r++rateLimit :: ClientM a -> FClient a+rateLimit func = do+ now <- liftIO $ getCurrentTime+ lastFetchVar <- asks fEnvLastFetch+ mLastFetch <- liftIO $ readTVarIO lastFetchVar+ delayTime <- asks $ confRateDelay . fEnvConfig+ let timeToWait =+ max 0 $+ case mLastFetch of+ Nothing -> 0+ Just lastFetch ->+ let timeSinceLastFetch = diffUTCTime now lastFetch+ timeSinceLastFetchMicro =+ round $+ (realToFrac timeSinceLastFetch *+ fromIntegral microsecondsPerSecond :: Double)+ in delayTime - timeSinceLastFetchMicro+ -- Wait+ liftIO $ threadDelay timeToWait+ -- Make the request+ res <- FClient $ lift func+ after <- liftIO getCurrentTime+ liftIO $ atomically $ writeTVar lastFetchVar $ Just after+ pure res++-- | Declare that we want to use the given file as a persistent cache.+--+-- Note that 'FClient' will still use a per-run cache if this function is not used.+-- This function only makes sure that the cache is persistent accross runs.+--+-- > withFileCache path func = do+-- > readCacheFromFileIfExists path+-- > r <- func+-- > flushCacheToFile path+-- > pure r+withFileCache :: FilePath -> FClient a -> FClient a+withFileCache path func = do+ readCacheFromFileIfExists path+ r <- func+ flushCacheToFile path+ pure r++-- | Read a persistent cache from the given file if that file exists.+readCacheFromFileIfExists :: FilePath -> FClient ()+readCacheFromFileIfExists fp = do+ fe <- liftIO $ doesFileExist fp+ mfc <-+ if fe+ then liftIO $ Yaml.decodeFile fp+ else pure Nothing+ case mfc of+ Nothing -> pure ()+ Just fc -> do+ cacheVar <- asks fEnvCache+ liftIO $ atomically $ writeTVar cacheVar fc++-- | Flush the currently gathered cache to the given file.+flushCacheToFile :: FilePath -> FClient ()+flushCacheToFile fp = do+ cacheVar <- asks fEnvCache+ liftIO $ do+ cache <- readTVarIO cacheVar+ Yaml.encodeFile fp cache
+ src/Fixer/Types.hs view
@@ -0,0 +1,198 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Types for the API+module Fixer.Types+ ( Rate(..)+ , oneRate+ , mulRate+ , divRate+ , normaliseRate+ , rateToRational+ , rateToDouble+ , Currency(..)+ , Symbols(..)+ , Rates(..)+ ) where++import Control.Applicative+import Data.Aeson as JSON+import Data.Aeson.Types as JSON+import qualified Data.List.NonEmpty as NE+import Data.List.NonEmpty (NonEmpty(..))+import Data.Map (Map)+import qualified Data.Map as M+import Data.Maybe+import Data.Ratio+import qualified Data.Text as T+import Data.Text (Text)+import Data.Time+import Data.Validity+import Data.Validity.Containers ()+import Data.Validity.Time ()+import GHC.Generics (Generic)+import GHC.Natural+import GHC.Real (Ratio(..))+import Servant.API+import Text.Read++-- | A positive and non-0 ratio+newtype Rate = Rate+ { unRate :: Ratio Natural+ } deriving (Show, Eq, Generic)++-- | The identity of 'mulRate'+oneRate :: Rate+oneRate = Rate 1++-- | Multiply two rates+mulRate :: Rate -> Rate -> Rate+mulRate (Rate r1) (Rate r2) = normaliseRate $ Rate (r1 * r2)++-- | Divide one rate by another+divRate :: Rate -> Rate -> Rate+divRate (Rate r1) (Rate r2) = normaliseRate $ Rate (r1 / r2)++-- | Ensure that a rate is internally normalised.+-- This is a requirement for 'Validity'+normaliseRate :: Rate -> Rate+normaliseRate = Rate . fromRational . toRational . unRate++-- | Convert a rate to a 'Ratio Integer' instead of 'Ratio Natural'.+--+-- This is a lossless transformation.+rateToRational :: Rate -> Rational+rateToRational (Rate (num :% den)) = fromIntegral num :% fromIntegral den++-- | Convert a rate to a 'Double'.+--+-- This may not be a lossless transformation+rateToDouble :: Rate -> Double+rateToDouble = fromRational . rateToRational++-- | A rate is valid if:+--+-- * The ratio inside is valid.+-- * The ratio is not zero.+-- * The ratio is normalised.+instance Validity Rate where+ validate r@Rate {..} =+ mconcat+ [ unRate <?!> "unRate"+ , (numerator unRate /= 0) <?@> "ratio is not 0"+ , (normaliseRate r == r) <?@> "is normalised"+ ]+ isValid = isValidByValidating++instance FromJSON Rate where+ parseJSON v =+ (Rate <$> parseJSON v) <|>+ -- First try to parse as a ratio+ (do d <- parseJSON v :: JSON.Parser Double+ -- The api has a precision of 0.0001.+ let r@(num :% den) = toRational d+ if r == 0+ then fail "Failed to parse zero rate"+ else pure $+ normaliseRate $+ Rate $ naturalFromInteger num % naturalFromInteger den)++instance ToJSON Rate where+ toJSON = toJSON . unRate++-- | A sum-type of the supported currencies on fixer.io+data Currency+ = AUD+ | BGN+ | BRL+ | CAD+ | CHF+ | CNY+ | CZK+ | DKK+ | EUR+ | GBP+ | HKD+ | HRK+ | HUF+ | IDR+ | ILS+ | INR+ | JPY+ | KRW+ | MXN+ | MYR+ | NOK+ | NZD+ | PHP+ | PLN+ | RON+ | RUB+ | SEK+ | SGD+ | THB+ | TRY+ | USD+ | ZAR+ deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)++instance Validity Currency++instance ToHttpApiData Currency where+ toUrlPiece = currencyToText++-- | A nonempty list of 'Currency's+newtype Symbols = Symbols+ { unSymbols :: NonEmpty Currency+ } deriving (Show, Eq, Ord, Generic)++instance Validity Symbols++instance ToHttpApiData Symbols where+ toUrlPiece = T.intercalate "," . map toUrlPiece . NE.toList . unSymbols++-- | The raw response of fixer.io+data Rates = Rates+ { ratesBase :: Currency+ , ratesDate :: Day+ , ratesRates :: Map Currency Rate+ } deriving (Show, Eq, Generic)++instance Validity Rates where+ validate Rates {..} =+ mconcat+ [ ratesBase <?!> "ratesBase"+ , ratesDate <?!> "ratesDate"+ , ratesRates <?!> "ratesRates"+ , isNothing (M.lookup ratesBase ratesRates) <?@>+ "does not contain the base rate in the rates."+ ]+ isValid = isValidByValidating++instance FromJSON Currency where+ parseJSON = withText "Currency" currencyTextParser++instance FromJSONKey Currency where+ fromJSONKey = FromJSONKeyTextParser currencyTextParser++currencyTextParser :: Text -> JSON.Parser Currency+currencyTextParser t =+ let s = T.unpack t+ in case readMaybe s of+ Nothing -> fail $ "Not a valid currency: " ++ s+ Just c -> pure c++instance ToJSON Currency where+ toJSON = JSON.String . currencyToText++instance ToJSONKey Currency where+ toJSONKey = toJSONKeyText currencyToText++currencyToText :: Currency -> Text+currencyToText = T.pack . show++instance FromJSON Rates where+ parseJSON =+ withObject "Rates" $ \o ->+ Rates <$> o .: "base" <*> o .: "date" <*> o .: "rates"
+ test/Fixer/Cache/Gen.hs view
@@ -0,0 +1,23 @@+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Fixer.Cache.Gen where++import TestImport++import Fixer.Types.Gen ()++import Fixer.Cache++instance GenUnchecked FixerCache++instance GenValid FixerCache where+ genValid = FixerCache <$> genValid <*> genValid++instance GenUnchecked FixerCacheResult++instance GenValid FixerCacheResult++instance GenUnchecked RateCache++instance GenValid RateCache where+ genValid = (RateCache <$> genValid) `suchThat` isValid
+ test/Fixer/CacheSpec.hs view
@@ -0,0 +1,189 @@+{-# LANGUAGE TypeApplications #-}++module Fixer.CacheSpec+ ( spec+ ) where++import TestImport++import qualified Data.List.NonEmpty as NE+import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.Map as M++import Fixer.Cache+import Fixer.Types++import Fixer.Cache.Gen ()+import Fixer.Types.Gen ()++spec :: Spec+spec = do+ rateCacheSpec+ fixerCacheSpec++rateCacheSpec :: Spec+rateCacheSpec = do+ eqSpecOnValid @RateCache+ genValidSpec @RateCache+ jsonSpecOnValid @RateCache+ describe "rawInsertInCache" $+ it "produces valid caches if the currencies are distinct" $+ forAllValid $ \d ->+ forAllValid $ \c1 ->+ forAll (genValid `suchThat` (/= c1)) $ \c2 ->+ forAllValid $ \r ->+ producesValidsOnValids $ rawInsertInCache d c1 c2 r+ describe "rawLookupInCache" $ do+ it "produces valid rates" $+ producesValidsOnValids3 (uncurry rawLookupInCache)+ it "finds what was just put there" $+ forAllValid $ \d ->+ forAllValid $ \c1 ->+ forAllValid $ \c2 ->+ forAllValid $ \r ->+ forAllValid $ \fc ->+ let fc' = rawInsertInCache d c1 c2 r fc+ in rawLookupInCache d c1 c2 fc' `shouldBe`+ Just r+ describe "defaultBaseCurrency" $+ it "is valid" $ shouldBeValid defaultBaseCurrency+ describe "allSymbols" $+ it "is valid" $ producesValidsOnValids allSymbolsExcept+ describe "insertRatesInCache" $+ it "produces valid caches" $+ forAllValid $ \rates ->+ producesValidsOnValids $ insertRatesInCache rates+ describe "smartLookupRateInCache" $ do+ it "produces valid rates" $+ forAllValid $ \date ->+ forAllValid $ \from ->+ forAllValid $ \to ->+ producesValidsOnValids $+ smartLookupRateInCache date from to+ it+ "finds the same as rawLookupInCache if rawLookupInCache finds a result" $+ forAllValid $ \date ->+ forAllValid $ \from ->+ forAllValid $ \to ->+ equivalentWhenSucceedOnValid+ (rawLookupInCache date from to)+ (smartLookupRateInCache date from to)+ it "succesfully lookups up a rate that is cached in reverse" $+ forAllValid $ \date ->+ forAllValid $ \from ->+ forAllValid $ \to ->+ forAllValid $ \rate ->+ forAllValid $ \fc ->+ unless (from == to) $ do+ let fc' =+ smartInsertInCache+ date+ from+ to+ rate+ fc+ shouldBeValid fc'+ smartLookupRateInCache date to from fc' `shouldBe`+ (Just $ divRate oneRate rate)+ describe "lookupRatesInCache" $ do+ it "produces valid caches" $+ forAllValid $ \date ->+ forAllValid $ \base ->+ forAllValid $ \symbols ->+ producesValidsOnValids $+ lookupRatesInCache date base symbols+ it "works for a cache with a single rate" $+ forAllValid $ \date ->+ forAllValid $ \base ->+ forAll (genValid `suchThat` (/= base)) $ \symbol ->+ forAllValid $ \rate ->+ let cache =+ RateCache+ { unRateCache =+ M.fromList+ [ ( date+ , M.fromList+ [ ( base+ , M.fromList+ [(symbol, rate)])+ ])+ ]+ }+ rates =+ Rates+ { ratesBase = base+ , ratesDate = date+ , ratesRates = M.fromList [(symbol, rate)]+ }+ in lookupRatesInCache+ date+ base+ (Symbols $ symbol :| [])+ cache `shouldBe`+ Just rates+ it+ "finds the rates that were just added with insertRatesInCache when adding at the default base currency" $+ forAllValid $ \rates' ->+ forAllValid $ \fc ->+ let rates =+ rates'+ { ratesBase = defaultBaseCurrency+ , ratesRates =+ M.delete defaultBaseCurrency $+ ratesRates rates'+ }+ fc' = insertRatesInCache rates fc+ in case NE.nonEmpty (M.keys $ ratesRates rates) of+ Nothing -> pure () -- Fine+ Just symbols ->+ lookupRatesInCache+ (ratesDate rates)+ (ratesBase rates)+ (Symbols symbols)+ fc' `shouldBe`+ Just rates+ it "finds the rates that were just added with insertRatesInCache" $+ forAllValid $ \rates ->+ forAllValid $ \fc ->+ let fc' = insertRatesInCache rates fc+ in case NE.nonEmpty (M.keys $ ratesRates rates) of+ Nothing -> pure () -- Fine+ Just symbols ->+ lookupRatesInCache+ (ratesDate rates)+ (ratesBase rates)+ (Symbols symbols)+ fc' `shouldBe`+ Just rates+ describe "convertToBaseWithRate" $ do+ it "produces valid caches" $+ forAllValid $ \base ->+ forAllValid $ \rate ->+ producesValidsOnValids $ convertToBaseWithRate base rate+ it+ "converts the rates such that the new base has a 1-to-rate conversion with the old base" $+ forAllValid $ \base ->+ forAllValid $ \rate ->+ forAllValid $ \rates ->+ unless (base == ratesBase rates) $+ let rates' =+ rates+ { ratesRates =+ M.insert base rate $ ratesRates rates+ }+ rates'' = convertToBaseWithRate base rate rates'+ in M.lookup (ratesBase rates) (ratesRates rates'') `shouldBe`+ Just (divRate oneRate rate)++fixerCacheSpec :: Spec+fixerCacheSpec = do+ eqSpecOnValid @FixerCache+ genValidSpec @FixerCache+ jsonSpecOnValid @FixerCache+ describe "insertRates" $+ it "produces valid caches" $ producesValidsOnValids3 insertRates+ describe "lookupRates" $+ it "produces valid results" $+ forAllValid $ \dn ->+ forAllValid $ \dr ->+ producesValidsOnValids3 $ lookupRates dn dr
+ test/Fixer/Types/Gen.hs view
@@ -0,0 +1,36 @@+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Fixer.Types.Gen where++import TestImport++import Numeric.Natural++import Fixer.Types++-- Until genvalidity has these.+instance GenUnchecked Natural where+ genUnchecked = (fromInteger . abs) <$> genValid -- Cannot even generate wrong ones+ shrinkUnchecked _ = []++instance GenValid Natural where+ genValid = (fromInteger . abs) <$> genValid++instance GenUnchecked Rate++instance GenValid Rate where+ genValid = (Rate <$> genValid) `suchThat` isValid++instance GenUnchecked Currency++instance GenValid Currency++instance GenUnchecked Symbols++instance GenValid Symbols where+ genValid = Symbols <$> genValid++instance GenUnchecked Rates++instance GenValid Rates where+ genValid = (Rates <$> genValid <*> genValid <*> genValid) `suchThat` isValid
+ test/Fixer/TypesSpec.hs view
@@ -0,0 +1,36 @@+{-# LANGUAGE TypeApplications #-}++module Fixer.TypesSpec+ ( spec+ ) where++import TestImport++import Data.Ratio++import Fixer.Types++import Fixer.Types.Gen ()++spec :: Spec+spec = do+ eqSpec @Rate+ genValidSpec @Rate+ jsonSpecOnValid @Rate+ describe "Rate" $ do+ describe "oneRate" $ it "is a valid rate" $ shouldBeValid oneRate+ specify "0 % x is an invalid rate" $+ forAllValid $ \n -> Rate (0 % (n + 1)) `shouldNotSatisfy` isValid+ describe "normaliseRate" $+ it "produces valid rates" $ producesValidsOnValids normaliseRate+ describe "mulRate" $+ it "produces valid rates" $ producesValidsOnValids2 mulRate+ describe "divRate" $+ it "produces valid rates" $ producesValidsOnValids2 divRate+ eqSpec @Currency+ genValidSpec @Currency+ jsonSpecOnValid @Currency+ eqSpec @Symbols+ genValidSpec @Symbols+ eqSpec @Rates+ genValidSpec @Rates
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ test/TestImport.hs view
@@ -0,0 +1,19 @@+module TestImport+ ( module X+ ) where++import Debug.Trace as X+import Prelude as X hiding (head, init, last, tail)++import Data.Maybe as X++import Control.Monad as X++import Test.Hspec as X+import Test.QuickCheck as X+import Test.Validity as X+import Test.Validity.Aeson as X++import Data.GenValidity.Containers as X+import Data.GenValidity.Text as X ()+import Data.GenValidity.Time as X ()