{-# LANGUAGE CPP #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE UnicodeSyntax #-} -- | -- Module: Configuration.Utils.ConfigFile -- Description: Parsing of Configuration Files with Default Values -- Copyright: Copyright © 2015 PivotCloud, Inc. -- License: MIT -- Maintainer: Lars Kuhtz <lkuhtz@pivotmail.com> -- Stability: experimental -- -- This module provides tools for defining configuration file -- parsers via instances of 'FromJSON'. -- -- Unlike /normal/ 'FromJSON' instances the parsers for configuration -- files are expected to yield an update function that takes -- a value and updates the value with the settings from the configuration -- file. -- -- Assuming that -- -- * all configuration types are nested Haskell records or -- simple types and -- -- * that there are lenses for all record fields -- -- usually the operators '..:' and '%.:' are all that is needed from this module. -- -- The module "Configuration.Utils.Monoid" provides tools for the case that -- a /simple type/ is a container with a monoid instance, such as @List@ or -- @HashMap@. -- -- The module "Configuration.Utils.Maybe" explains the usage of optional -- 'Maybe' values in configuration types. -- module Configuration.Utils.ConfigFile ( -- * Parsing of Configuration Files with Default Values setProperty , (..:) , (!..:) , updateProperty , (%.:) -- * Configuration File Parsing Policy , ConfigFile(..) , ConfigFilesConfig(..) #if REMOTE_CONFIGS , cfcHttpsPolicy #endif , defaultConfigFilesConfig , pConfigFilesConfig -- * Miscellaneous Utilities , dropAndUncaml , module Data.Aeson -- * Internal Tools for Parsing Configuration Files , parseConfigFiles ) where import Configuration.Utils.CommandLine import Configuration.Utils.Internal import Configuration.Utils.Validation import Control.Monad.Except hiding (mapM_) import Data.Aeson import Data.Aeson.Types (Parser) import Data.Char import Data.Foldable import qualified Data.HashMap.Strict as H import Data.Maybe import Data.Monoid.Unicode import Data.String import qualified Data.Text as T import Data.Typeable import qualified Data.Yaml as Yaml import Prelude hiding (concatMap, mapM_, any) #ifdef REMOTE_CONFIGS import Configuration.Utils.HttpsCertPolicy import Configuration.Utils.Operators import Control.Exception.Enclosed import Control.Monad.Trans.Control import qualified Data.ByteString.Lazy as LB import qualified Data.List as L import qualified Data.Text.IO as T import qualified Network.HTTP.Client as HTTP import Prelude.Unicode import System.IO #endif -- | A JSON 'Value' parser for a property of a given -- 'Object' that updates a setter with the parsed value. -- -- > data Auth = Auth -- > { _userId ∷ !Int -- > , _pwd ∷ !String -- > } -- > -- > userId ∷ Functor φ ⇒ (Int → φ Int) → Auth → φ Auth -- > userId f s = (\u → s { _userId = u }) <$> f (_userId s) -- > -- > pwd ∷ Functor φ ⇒ (String → φ String) → Auth → φ Auth -- > pwd f s = (\p → s { _pwd = p }) <$> f (_pwd s) -- > -- > -- or with lenses and TemplateHaskell just: -- > -- $(makeLenses ''Auth) -- > -- > instance FromJSON (Auth → Auth) where -- > parseJSON = withObject "Auth" $ \o → id -- > <$< setProperty user "user" p o -- > <*< setProperty pwd "pwd" parseJSON o -- > where -- > p = withText "user" $ \case -- > "alice" → pure (0 ∷ Int) -- > "bob" → pure 1 -- > e → fail $ "unrecognized user " ⊕ e -- setProperty ∷ Lens' α β -- ^ a lens into the target that is updated by the parser → T.Text -- ^ the JSON property name → (Value → Parser β) -- ^ the JSON 'Value' parser that is used to parse the value of the property → Object -- ^ the parsed JSON 'Value' 'Object' → Parser (α → α) setProperty s k p o = case H.lookup k o of Nothing → pure id Just v → set s <$> p v -- | A variant of the 'setProperty' that uses the default 'parseJSON' method from the -- 'FromJSON' instance to parse the value of the property. Its usage pattern mimics the -- usage pattern of the '.:' operator from the aeson library. -- -- > data Auth = Auth -- > { _user ∷ !String -- > , _pwd ∷ !String -- > } -- > -- > user ∷ Functor φ ⇒ (String → φ String) → Auth → φ Auth -- > user f s = (\u → s { _user = u }) <$> f (_user s) -- > -- > pwd ∷ Functor φ ⇒ (String → φ String) → Auth → φ Auth -- > pwd f s = (\p → s { _pwd = p }) <$> f (_pwd s) -- > -- > -- or with lenses and TemplateHaskell just: -- > -- $(makeLenses ''Auth) -- > -- > instance FromJSON (Auth → Auth) where -- > parseJSON = withObject "Auth" $ \o → id -- > <$< user ..: "user" × o -- > <*< pwd ..: "pwd" × o -- (..:) ∷ FromJSON β ⇒ Lens' α β → T.Text → Object → Parser (α → α) (..:) s k = setProperty s k parseJSON infix 6 ..: {-# INLINE (..:) #-} -- | A JSON parser for a function that modifies a property -- of a given 'Object' and updates a setter with the parsed -- function. -- -- > data HttpURL = HttpURL -- > { _auth ∷ !Auth -- > , _domain ∷ !String -- > } -- > -- > auth ∷ Functor φ ⇒ (Auth → φ Auth) → HttpURL → φ HttpURL -- > auth f s = (\u → s { _auth = u }) <$> f (_auth s) -- > -- > domain ∷ Functor φ ⇒ (String → φ String) → HttpURL → φ HttpURL -- > domain f s = (\u → s { _domain = u }) <$> f (_domain s) -- > -- > path ∷ Functor φ ⇒ (String → φ String) → HttpURL → φ HttpURL -- > path f s = (\u → s { _path = u }) <$> f (_path s) -- > -- > -- or with lenses and TemplateHaskell just: -- > -- $(makeLenses ''HttpURL) -- > -- > instance FromJSON (HttpURL → HttpURL) where -- > parseJSON = withObject "HttpURL" $ \o → id -- > <$< auth %.: "auth" × o -- > <*< domain ..: "domain" × o -- updateProperty ∷ Lens' α β → T.Text → (Value → Parser (β → β)) → Object → Parser (α → α) updateProperty s k p o = case H.lookup k o of Nothing → pure id Just v → over s <$> p v {-# INLINE updateProperty #-} -- | A variant of 'updateProperty' that used the 'FromJSON' instance -- for the update function. It mimics the aeson operator '.:'. -- It creates a parser that modifies a setter with a parsed function. -- -- > data HttpURL = HttpURL -- > { _auth ∷ !Auth -- > , _domain ∷ !String -- > } -- > -- > auth ∷ Functor φ ⇒ (Auth → φ Auth) → HttpURL → φ HttpURL -- > auth f s = (\u → s { _auth = u }) <$> f (_auth s) -- > -- > domain ∷ Functor φ ⇒ (String → φ String) → HttpURL → φ HttpURL -- > domain f s = (\u → s { _domain = u }) <$> f (_domain s) -- > -- > path ∷ Functor φ ⇒ (String → φ String) → HttpURL → φ HttpURL -- > path f s = (\u → s { _path = u }) <$> f (_path s) -- > -- > -- or with lenses and TemplateHaskell just: -- > -- $(makeLenses ''HttpURL) -- > -- > instance FromJSON (HttpURL → HttpURL) where -- > parseJSON = withObject "HttpURL" $ \o → id -- > <$< auth %.: "auth" × o -- > <*< domain ..: "domain" × o -- (%.:) ∷ FromJSON (β → β) ⇒ Lens' α β → T.Text → Object → Parser (α → α) (%.:) s k = updateProperty s k parseJSON infix 6 %.: {-# INLINE (%.:) #-} -- | This operator requires that a value is explicitly provided in a -- configuration file, thus preventing the default value from being used. -- Otherwise this operator does the same as '(..:)'. -- (!..:) ∷ FromJSON β ⇒ Lens' α β → T.Text → Object → Parser (α → α) (!..:) l property o = set l <$> (o .: property) {-# INLINE (!..:) #-} -- -------------------------------------------------------------------------- -- -- Config File Parsing Policy data ConfigFile = ConfigFileRequired { getConfigFile ∷ !T.Text } | ConfigFileOptional { getConfigFile ∷ !T.Text } deriving (Show, Read, Eq, Ord, Typeable) -- | An /internal/ type for the meta configuration that specifies how the -- configuration files are loaded and parsed. -- #if REMOTE_CONFIGS data ConfigFilesConfig = ConfigFilesConfig { _cfcHttpsPolicy ∷ !HttpsCertPolicy } deriving (Show, Eq, Typeable) cfcHttpsPolicy ∷ Lens' ConfigFilesConfig HttpsCertPolicy cfcHttpsPolicy = lens _cfcHttpsPolicy $ \a b → a { _cfcHttpsPolicy = b } defaultConfigFilesConfig ∷ ConfigFilesConfig defaultConfigFilesConfig = ConfigFilesConfig { _cfcHttpsPolicy = defaultHttpsCertPolicy } pConfigFilesConfig ∷ MParser ConfigFilesConfig pConfigFilesConfig = id <$< cfcHttpsPolicy %:: pHttpsCertPolicy "config-" #else data ConfigFilesConfig = ConfigFilesConfig {} defaultConfigFilesConfig ∷ ConfigFilesConfig defaultConfigFilesConfig = ConfigFilesConfig {} pConfigFilesConfig ∷ MParser ConfigFilesConfig pConfigFilesConfig = pure id #endif -- -------------------------------------------------------------------------- -- -- Tools for parsing configuration files #ifdef REMOTE_CONFIGS type ConfigFileParser μ = ( Functor μ , Applicative μ , MonadIO μ , MonadBaseControl IO μ , MonadError T.Text μ ) #else type ConfigFileParser μ = ( Functor μ , Applicative μ , MonadIO μ , MonadError T.Text μ ) #endif parseConfigFiles ∷ (ConfigFileParser μ, FromJSON (α → α)) ⇒ ConfigFilesConfig → α -- ^ default configuration value → [ConfigFile] -- ^ list of configuration file paths → μ α parseConfigFiles conf = foldM $ \val file → readConfigFile conf file <*> pure val readConfigFile ∷ (ConfigFileParser μ, FromJSON (α → α)) ⇒ ConfigFilesConfig → ConfigFile -- ^ file path → μ (α → α) readConfigFile _conf file = #ifdef REMOTE_CONFIGS if isRemote file then loadRemote _conf file else loadLocal file #else loadLocal file #endif loadLocal ∷ (Functor μ, MonadIO μ, MonadError T.Text μ, FromJSON (α → α)) ⇒ ConfigFile -- ^ file path → μ (α → α) loadLocal path = do validateFilePath "config-file" (T.unpack file) exists ← (True <$ validateFile "config-file" (T.unpack file)) `catchError` \e → case path of ConfigFileOptional _ → return False ConfigFileRequired _ → throwError $ "failed to read config file: " ⊕ e if exists then liftIO (Yaml.decodeFileEither (T.unpack file)) >>= \case Left e → throwError $ "failed to parse configuration file " ⊕ file ⊕ ": " ⊕ sshow e Right r → return r else return id where file = getConfigFile path #ifdef REMOTE_CONFIGS isRemote ∷ ConfigFile → Bool isRemote path = L.any (`T.isPrefixOf` getConfigFile path) ["http://", "https://"] loadRemote ∷ (ConfigFileParser μ, FromJSON (α → α)) ⇒ ConfigFilesConfig → ConfigFile -- ^ URL → μ (α → α) loadRemote conf path = do validateHttpOrHttpsUrl "config-file" (T.unpack url) dat ← (Just <$> doHttp) `catchAnyDeep` \e → case path of ConfigFileOptional _ → do liftIO ∘ T.hPutStrLn stderr $ "WARNING: failed to download remote configuration file " ⊕ url ⊕ ": " ⊕ sshow e return Nothing ConfigFileRequired _ → throwError $ "failed to download remote configuration file " ⊕ url ⊕ ": " ⊕ sshow e case dat of Nothing → return id Just d → case Yaml.decodeEither' d of Left e → throwError $ "failed to parse remote configuration " ⊕ url ⊕ ": " ⊕ sshow e Right r → return r where url = getConfigFile path policy = _cfcHttpsPolicy conf doHttp = LB.toStrict ∘ HTTP.responseBody <$> liftIO × httpWithValidationPolicy url policy #endif -- -------------------------------------------------------------------------- -- -- Miscellaneous Utilities dropAndUncaml ∷ Int → String → String dropAndUncaml i l | length l < i + 1 = l | otherwise = let (h:t) = drop i l in toLower h : concatMap (\x → if isUpper x then "-" ⊕ [toLower x] else [x]) t