packages feed

forex2ledger-1.0.0.0: src/OpenExchangeRates.hs

{-# LANGUAGE OverloadedStrings #-}

-- | This module fetches rates from openexchangerates.org
module OpenExchangeRates (
  fetchRates,
  OerRates (..),
) where

import AppId (AppId (AppId))
import Config (Config (Config))
import qualified Data.Aeson as Aeson
import Data.Currency (Alpha)
import Data.Fixed (E6, Fixed)
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Text as T
import Data.Time (Day, UTCTime (utctDay), secondsToNominalDiffTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import qualified Network.HTTP as HTTP
import Network.URL (
  URL (
    URL,
    url_params,
    url_path,
    url_type
  ),
  exportURL,
 )
import qualified Network.URL as URL
import Relude

type CurrencyResolution = E6

data OerRates = OerRates
  { orTimestamp :: !Day
  , orRates :: Map Alpha (Fixed CurrencyResolution)
  }
  deriving stock (Show, Eq)

alphaP :: Text -> Either Text Alpha
alphaP currencyTxt =
  maybeToRight ("Could not parse " `T.append` currencyTxt `T.append` " as currency.\n")
    . readMaybe
    . T.unpack
    $ currencyTxt

currencyInKeysP :: HashMap Text (Fixed CurrencyResolution) -> Either Text (Map Alpha (Fixed CurrencyResolution))
currencyInKeysP =
  convertToMap
    . mapM convertKey
    . HashMap.toList
 where
  convertKey :: (Text, Fixed CurrencyResolution) -> Either Text (Alpha, Fixed CurrencyResolution)
  convertKey = fmap swap . sequence . swap . first alphaP
  convertToMap :: Either Text [(Alpha, Fixed CurrencyResolution)] -> Either Text (Map Alpha (Fixed CurrencyResolution))
  convertToMap = fmap fromList

instance Aeson.FromJSON OerRates where
  parseJSON (Aeson.Object o) =
    OerRates
      <$> timestampP
      <*> ratesP
   where
    timestampP =
      o Aeson..: "timestamp"
        & fmap
          ( utctDay
              . posixSecondsToUTCTime
              . secondsToNominalDiffTime
              . fromInteger
          )
    ratesP = do
      hashMap <- o Aeson..: "rates"
      let eitherRates = currencyInKeysP hashMap
      either (fail . T.unpack) return eitherRates
  parseJSON _ = fail "OpenExchangeRates data should be an object but received something else."

getLatestUrl :: Config -> URL
getLatestUrl (Config (AppId appId) base targets) =
  URL
    { url_type = URL.Absolute (URL.Host (URL.HTTP False) "openexchangerates.org" Nothing)
    , url_path = "/api/latest.json"
    , url_params =
        [ ("app_id", toString appId)
        , ("base", cur2string base)
        , ("symbols", intercalate "," (cur2string <$> targets))
        ]
    }
 where
  cur2string = show

-- | Fetches rates from openexchangerates.org
fetchRates :: Config -> IO (Either Text OerRates)
fetchRates config = do
  response <- HTTP.simpleHTTP req
  responseCode <- HTTP.getResponseCode response
  body <- HTTP.getResponseBody response
  case responseCode of
    (2, 0, 0) -> do
      return . first T.pack $ Aeson.eitherDecode (fromString body)
    _ ->
      return . Left $
        "Received a non-200 response code:"
          `T.append` T.pack (show responseCode)
          `T.append` " with the response: "
          `T.append` T.pack body
 where
  req = HTTP.getRequest . exportURL . getLatestUrl $ config