{-# LANGUAGE RankNTypes #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE UnicodeSyntax #-} {-# OPTIONS_HADDOCK show-extensions #-} -- | -- Module: Configuration.Utils -- Description: Utilities for Configuring Programs -- Copyright: Copyright © 2014-2015 PivotCloud, Inc. -- License: MIT -- Maintainer: Lars Kuhtz <lkuhtz@pivotmail.com> -- Stability: experimental -- -- This module provides a collection of utilities on top of the packages -- optparse-applicative, aeson, and yaml, for configuring libraries and -- applications in a composable way. -- -- The main feature is the integration of command line option parsing and -- configuration files. -- -- The purpose is to make management of configurations easy by providing an -- idiomatic style of defining and deploying configurations in a modular -- and composable way. -- -- = Usage -- -- The module provides operators and functions that make the implementation of -- these entities easy for the common case that the configurations are encoded -- mainly as nested records. -- -- For each data type that is used as as component in a configuration type -- the following must be provided: -- -- 1. a /default value/, -- -- 2. a /'FromJSON' instance/ that yields a function that takes a value and -- updates that value with the parsed values, -- -- 3. a /'ToJSON' instance/, and -- -- 4. a /command line options parser/ that yields a function that takes a value -- and updates that value with the values provided as command line options. -- -- In addition to the above optionally a /validation function/ may be provided -- that (recursively) validates a configuration value and returns either -- an error or a (possibly empty) list-like structure of warnings. -- -- The modules -- -- * "Configuration.Utils.CommandLine", -- * "Configuration.Utils.ConfigFile", and -- * "Configuration.Utils.Operators" -- -- contain tools and examples for defining above prerequisites for using a -- type in a configuration type. -- -- The provided functions and operators assume that lenses for the -- configuration record types are provided. -- -- 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. -- -- = Usage Example -- -- Beside the examples that are provided in the haddock documentation there is -- a complete usage example in the file -- <https://github.com/alephcloud/hs-configuration-tools/blob/master/examples/Example.hs example/Example.hs> -- of the cabal package. -- module Configuration.Utils ( -- * Program Configuration ProgramInfo , programInfo , piDescription , piHelpHeader , piHelpFooter , piOptionParser , piDefaultConfiguration , piConfigurationFiles -- * Program Configuration with Validation of Configuration Values , ConfigValidation , programInfoValidate -- * Running a Configured Application , runWithConfiguration , PkgInfo , runWithPkgInfoConfiguration , parseConfiguration -- * Command Line Option Parsing with Default Values , module Configuration.Utils.CommandLine -- * Parsing of Configuration Files with Default Values , module Configuration.Utils.ConfigFile -- * Miscellaneous Utilities , module Configuration.Utils.Operators , Lens' , Lens -- * Configuration of Optional Values , module Configuration.Utils.Maybe -- * Configuration of Monoids , module Configuration.Utils.Monoid -- * Low-level Configuration Validation , ProgramInfoValidate , piValidateConfiguration , ConfigValidationFunction(..) , piOptionParserAndDefaultConfiguration ) where import Configuration.Utils.CommandLine import Configuration.Utils.ConfigFile hiding (parseConfigFiles) import qualified Configuration.Utils.ConfigFile as CF (parseConfigFiles) import Configuration.Utils.Internal import Configuration.Utils.Maybe import Configuration.Utils.Monoid import Configuration.Utils.Operators import Configuration.Utils.Validation import Control.Monad.Except hiding (mapM_) import Control.Monad.Writer hiding (mapM_) import qualified Data.ByteString.Char8 as B8 import Data.Foldable import Data.Maybe import Data.Monoid.Unicode import Data.String import qualified Data.Text as T import qualified Data.Text.IO as T import qualified Data.Yaml as Yaml import qualified Options.Applicative.Types as O import qualified Options.Applicative as O import Prelude hiding (concatMap, mapM_, any) import Prelude.Unicode import System.IO import qualified Text.PrettyPrint.ANSI.Leijen as P #ifdef REMOTE_CONFIGS import Control.Monad.Trans.Control #endif -- -------------------------------------------------------------------------- -- -- Main Configuration -- | A newtype wrapper around a validation function. The only purpose of -- this type is to avoid @ImpredicativeTypes@ when storing the function -- in the 'ProgramInfoValidate' record. -- newtype ConfigValidationFunction α λ = ConfigValidationFunction { runConfigValidation ∷ ConfigValidation α λ } type ProgramInfo α = ProgramInfoValidate α [] data ProgramInfoValidate α λ = ProgramInfo { _piDescription ∷ !String -- ^ Program Description , _piHelpHeader ∷ !(Maybe String) -- ^ Help header , _piHelpFooter ∷ !(Maybe String) -- ^ Help footer , _piOptionParser ∷ !(MParser α) -- ^ options parser for configuration , _piDefaultConfiguration ∷ !α -- ^ default configuration , _piValidateConfiguration ∷ !(ConfigValidationFunction α λ) -- ^ a validation function. The 'Right' result is interpreted as a 'Foldable' -- structure of warnings. , _piConfigurationFiles ∷ ![ConfigFile] -- ^ a list of configuration files that are loaded in order -- before any command line argument is evaluated. } -- | Program Description -- piDescription ∷ Lens' (ProgramInfoValidate α λ) String piDescription = lens _piDescription $ \s a → s { _piDescription = a } {-# INLINE piDescription #-} -- | Help header -- piHelpHeader ∷ Lens' (ProgramInfoValidate α λ) (Maybe String) piHelpHeader = lens _piHelpHeader $ \s a → s { _piHelpHeader = a } {-# INLINE piHelpHeader #-} -- | Help footer -- piHelpFooter ∷ Lens' (ProgramInfoValidate α λ) (Maybe String) piHelpFooter = lens _piHelpFooter $ \s a → s { _piHelpFooter = a } {-# INLINE piHelpFooter #-} -- | Options parser for configuration -- piOptionParser ∷ Lens' (ProgramInfoValidate α λ) (MParser α) piOptionParser = lens _piOptionParser $ \s a → s { _piOptionParser = a } {-# INLINE piOptionParser #-} -- | Default configuration -- piDefaultConfiguration ∷ Lens' (ProgramInfoValidate α λ) α piDefaultConfiguration = lens _piDefaultConfiguration $ \s a → s { _piDefaultConfiguration = a } {-# INLINE piDefaultConfiguration #-} -- | Validation Function -- -- The 'Right' result is interpreted as a 'Foldable' structure of warnings. -- piValidateConfiguration ∷ Lens' (ProgramInfoValidate α λ) (ConfigValidationFunction α λ) piValidateConfiguration = lens _piValidateConfiguration $ \s a → s { _piValidateConfiguration = a } {-# INLINE piValidateConfiguration #-} -- | Configuration files that are loaded in order before any command line -- argument is evaluated. -- piConfigurationFiles ∷ Lens' (ProgramInfoValidate α λ) [ConfigFile] piConfigurationFiles = lens _piConfigurationFiles $ \s a → s { _piConfigurationFiles = a } {-# INLINE piConfigurationFiles #-} -- | 'Lens' for simultaneous query and update of 'piOptionParser' and -- 'piDefaultConfiguration'. This supports to change the type of 'ProgramInfo' -- with 'over' and 'set'. -- piOptionParserAndDefaultConfiguration ∷ Lens (ProgramInfoValidate α λ) (ProgramInfoValidate β γ) (MParser α, α, ConfigValidationFunction α λ) (MParser β, β, ConfigValidationFunction β γ) piOptionParserAndDefaultConfiguration = lens g $ \s (a,b,c) → ProgramInfo { _piDescription = _piDescription s , _piHelpHeader = _piHelpHeader s , _piHelpFooter = _piHelpFooter s , _piOptionParser = a , _piDefaultConfiguration = b , _piValidateConfiguration = c , _piConfigurationFiles = _piConfigurationFiles s } where g s = (_piOptionParser s, _piDefaultConfiguration s, _piValidateConfiguration s) {-# INLINE piOptionParserAndDefaultConfiguration #-} -- | Smart constructor for 'ProgramInfo'. -- -- 'piHelpHeader' and 'piHelpFooter' are set to 'Nothing'. -- The function 'piValidateConfiguration' is set to @const (return [])@ -- programInfo ∷ String -- ^ program description → MParser α -- ^ parser for updating the default configuration → α -- ^ default configuration → ProgramInfo α programInfo desc parser defaultConfig = programInfoValidate desc parser defaultConfig $ const (return ()) -- | Smart constructor for 'ProgramInfo'. -- -- 'piHelpHeader' and 'piHelpFooter' are set to 'Nothing'. -- programInfoValidate ∷ String → MParser α → α → ConfigValidation α λ → ProgramInfoValidate α λ programInfoValidate desc parser defaultConfig valFunc = ProgramInfo { _piDescription = desc , _piHelpHeader = Nothing , _piHelpFooter = Nothing , _piOptionParser = parser , _piDefaultConfiguration = defaultConfig , _piValidateConfiguration = ConfigValidationFunction valFunc , _piConfigurationFiles = [] } -- -------------------------------------------------------------------------- -- -- AppConfiguration -- | An /internal/ data type that is used during configuration parsing to -- represent the overall application configuration which includes -- -- 1. the /user/ configuration, which is actual configuration value that -- is given to the application and -- -- 2. the /meta/ configuration, which are all settings that determine how the -- actual /user/ configuration is loaded and parsed. -- -- NOTE that /meta/ configuration settings can only be provided via command -- line options but not through configuration files. -- data AppConfiguration α = AppConfiguration { _printConfig ∷ !Bool , _configFilesConfig ∷ !ConfigFilesConfig , _configFiles ∷ ![ConfigFile] , _mainConfig ∷ !α } -- | A flag that indicates that the application should output the effective -- configuration and exit. -- printConfig ∷ Lens' (AppConfiguration α) Bool printConfig = lens _printConfig $ \s a → s { _printConfig = a } -- | The 'ConfigFilesConfig' collects all parameters that determine how -- configuration files are loaded and parsed. -- configFilesConfig ∷ Lens' (AppConfiguration α) ConfigFilesConfig configFilesConfig = lens _configFilesConfig $ \s a → s { _configFilesConfig = a } -- | A list of configuration file locations. Configuration file locations are -- set either statically in the code or are provided dynamically on the command -- line via @--config-file@ options. -- configFiles ∷ Lens' (AppConfiguration α) [ConfigFile] configFiles = lens _configFiles $ \s a → s { _configFiles = a } -- | The /user/ configuration. During parsing this is represented as an update -- function that yields a configuration value when applied to a default -- value. -- mainConfig ∷ Lens (AppConfiguration α) (AppConfiguration β) α β mainConfig = lens _mainConfig $ \s a → s { _mainConfig = a } -- | This function parsers /all/ command line options: -- -- 1. 'ConfigFilesConfig' options that determine how configuration -- files are loaded. -- -- 2. 'ConfigFiles' options are all @--config-file@ options. -- -- 3. Other /meta/ options, such as @--print-config@. -- -- 4. Options for the actual user /configuration/. The user configuration -- is represented as an update function that yields a configuration -- value when applied to an default value. -- pAppConfiguration ∷ O.Parser (α → α) → O.Parser (AppConfiguration (α → α)) pAppConfiguration mainParser = AppConfiguration <$> pPrintConfig <*> (pConfigFilesConfig <*> pure defaultConfigFilesConfig) <*> many pConfigFile <*> mainParser where pPrintConfig = O.switch × O.long "print-config" ⊕ O.short 'p' ⊕ O.help "Print the parsed configuration to standard out and exit" ⊕ O.showDefault pConfigFile = ConfigFileRequired ∘ T.pack <$> O.strOption × O.long "config-file" ⊕ O.short 'c' ⊕ O.metavar "FILE" ⊕ O.help "Configuration file in YAML format. If more than a single config file option is present files are loaded in the order in which they appear on the command line." -- -------------------------------------------------------------------------- -- -- Main Configuration without Package Info -- | Run an IO action with a configuration that is obtained by updating the -- given default configuration the values defined via command line arguments. -- -- In addition to the options defined by the given options parser the following -- options are recognized: -- -- [@--config-file, -c@] -- Parse the given file path as a (partial) configuration in YAML -- format. -- -- [@--print-config, -p@] -- Print the final parsed configuration to standard out and exit. -- -- [@--help, -h@] -- Print a help message and exit. -- -- As long as the package wasn't build with @-f-remote-configs@ the following -- two options are available. They affect how configuration files -- are loaded from remote URLs. -- -- [@--config-https-insecure=true|false@] -- Bypass certificate validation for all HTTPS -- connections to all services. -- -- [@--config-https-allow-cert=HOSTNAME:PORT:FINGERPRINT@] -- Unconditionally trust the certificate for connecting -- to the service. -- runWithConfiguration ∷ (FromJSON (α → α), ToJSON α, Foldable λ, Monoid (λ T.Text)) ⇒ ProgramInfoValidate α λ -- ^ program info value; use 'programInfo' to construct a value of this -- type → (α → IO ()) -- ^ computation that is given the configuration that is parsed from -- the command line. → IO () runWithConfiguration appInfo = runInternal appInfo Nothing -- -------------------------------------------------------------------------- -- -- Main Configuration with Package Info pPkgInfo ∷ PkgInfo → MParser α pPkgInfo (sinfo, detailedInfo, version, license) = infoO <*> detailedInfoO <*> versionO <*> licenseO where infoO = infoOption sinfo $ O.long "info" ⊕ O.short 'i' ⊕ O.help "Print program info message and exit" ⊕ O.value id detailedInfoO = infoOption detailedInfo $ O.long "long-info" ⊕ O.help "Print detailed program info message and exit" ⊕ O.value id versionO = infoOption version $ O.long "version" ⊕ O.short 'v' ⊕ O.help "Print version string and exit" ⊕ O.value id licenseO = infoOption license $ O.long "license" ⊕ O.help "Print license of the program and exit" ⊕ O.value id -- | Information about the cabal package. The format is: -- -- @(info message, detailed info message, version string, license text)@ -- -- See the documentation of "Configuration.Utils.Setup" for a way -- how to generate this information automatically from the package -- description during the build process. -- type PkgInfo = ( String -- info message , String -- detailed info message , String -- version string , String -- license text ) -- | Run an IO action with a configuration that is obtained by updating the -- given default configuration the values defined via command line arguments. -- -- In addition to the options defined by the given options parser the following -- options are recognized: -- -- [@--config-file, -c@] -- Parse the given file path as a (partial) configuration in YAML -- format. -- -- [@--print-config, -p@] -- Print the final parsed configuration to standard out and exit. -- -- [@--help, -h@] -- Print a help message and exit. -- -- [@--version, -v@] -- Print the version of the application and exit. -- -- [@--info, -i@] -- Print a short info message for the application and exit. -- -- [@--long-info@] -- Print a detailed info message for the application and exit. -- -- [@--license@] -- Print the text of the license of the application and exit. -- -- As long as the package wasn't build with @-f-remote-configs@ the following -- two options are available. They affect how configuration files -- are loaded from remote URLs. -- -- [@--config-https-insecure=true|false@] -- Bypass certificate validation for all HTTPS -- connections to all services. -- -- [@--config-https-allow-cert=HOSTNAME:PORT:FINGERPRINT@] -- Unconditionally trust the certificate for connecting -- to the service. -- runWithPkgInfoConfiguration ∷ (FromJSON (α → α), ToJSON α, Foldable λ, Monoid (λ T.Text)) ⇒ ProgramInfoValidate α λ -- ^ program info value; use 'programInfo' to construct a value of this -- type → PkgInfo -- 'PkgInfo' value that contains information about the package. -- -- See the documentation of "Configuration.Utils.Setup" for a way -- how to generate this information automatically from the package -- description during the build process. → (α → IO ()) -- ^ computation that is given the configuration that is parsed from -- the command line. → IO () runWithPkgInfoConfiguration appInfo pkgInfo = runInternal appInfo (Just $ pPkgInfo pkgInfo) -- -------------------------------------------------------------------------- -- -- Internal main function mainOptions ∷ ∀ α λ . FromJSON (α → α) ⇒ ProgramInfoValidate α λ -- ^ Program Info value which may include a validation function → (∀ β . Maybe (MParser β)) -- ^ Maybe a package info parser. This parser is run only for its -- side effects. It is supposed to /intercept/ the parsing process -- and execute any implied action (showing help messages). → O.ParserInfo (AppConfiguration (α → α)) mainOptions ProgramInfo{..} pkgInfoParser = O.info optionParser $ O.progDesc _piDescription ⊕ O.fullDesc ⊕ maybe mempty O.header _piHelpHeader ⊕ O.footerDoc (Just $ defaultFooter ⊕ maybe mempty P.text _piHelpFooter) where optionParser = -- these are identity parsers that are only applied for their side effects fromMaybe (pure id) pkgInfoParser <*> nonHiddenHelper -- this parser produces the results <*> pAppConfiguration _piOptionParser -- the 'O.helper' option from optparse-applicative is hidden be default -- which seems a bit weired. This option doesn't hide the access to help. nonHiddenHelper = abortOption ShowHelpText × long "help" ⊕ short 'h' ⊕ short '?' ⊕ help "Show this help message" defaultFooter = P.vsep [ par "Configurations are loaded in order from the following sources:" , P.indent 2 ∘ P.vsep $ zipWith ($) (catMaybes [staticFiles, cmdFiles, cmdOptions]) [1..] , "" , P.fillSep [ par "Configuration file locations can be either local file system paths" , par "or remote HTTP or HTTPS URLs. Remote URLs must start with" , par "either \"http://\" or \"https://\"." ] , "" , P.fillSep [ par "Configuration settings that are loaded later overwrite settings" , par "that were loaded before." ] , "" ] staticFiles | null _piConfigurationFiles = Nothing | otherwise = Just $ \n → P.hang 3 $ P.vsep [ P.int n ⊕ "." P.</> par "Configuration files at the following locations:" , P.vsep $ map (\f → "* " ⊕ printConfigFile f) _piConfigurationFiles ] cmdFiles = Just $ \n → P.hang 3 $ P.fillSep [ P.int n ⊕ "." P.</> par "Configuration files from locations provided through" , par "--config-file options in the order as they appear." ] cmdOptions = Just $ \n → P.hang 3 $ P.int n ⊕ "." P.</> par "Command line options." printConfigFile f = P.text (T.unpack $ getConfigFile f) P.<+> case f of ConfigFileRequired _ → P.text "(required)" ConfigFileOptional _ → P.text "(optional)" par ∷ String → P.Doc par = P.fillSep ∘ map P.string ∘ words -- | Internal main function -- runInternal ∷ (FromJSON (α → α), ToJSON α, Foldable λ, Monoid (λ T.Text)) ⇒ ProgramInfoValidate α λ -- ^ program info value; use 'programInfo' to construct a value of this -- type → (∀ β . Maybe (MParser β)) -- 'PkgInfo' value that contains information about the package. -- -- See the documentation of "Configuration.Utils.Setup" for a way -- how to generate this information automatically from the package -- description during the build process. → (α → IO ()) -- ^ computation that is given the configuration that is parsed from -- the command line. → IO () runInternal appInfo maybePkgInfo mainFunction = do -- Parse command line arguments and add static config files to resulting app config cliAppConf ← configFiles `over` (⊕) (_piConfigurationFiles appInfo) <$> O.customExecParser parserPrefs (mainOptions appInfo maybePkgInfo) -- Load and parse all configuration files appConf ← cliAppConf & mainConfig `id` \a → a <$> errorT × CF.parseConfigFiles (_configFilesConfig cliAppConf) (_piDefaultConfiguration appInfo) (_configFiles cliAppConf) -- Validate final configuration validateConfig appInfo $ _mainConfig appConf if _printConfig appConf then B8.putStrLn ∘ Yaml.encode ∘ _mainConfig $ appConf else mainFunction ∘ _mainConfig $ appConf where parserPrefs = O.prefs O.disambiguate -- | Parse the command line arguments. -- -- Any warnings from the configuration function are discarded. -- The options @--print-config@ and @--help@ are just ignored. -- parseConfiguration ∷ ( Applicative m , MonadIO m #ifdef REMOTE_CONFIGS , MonadBaseControl IO m #endif , MonadError T.Text m , FromJSON (α → α) , ToJSON α , Foldable λ , Monoid (λ T.Text) ) ⇒ T.Text -- ^ program name (used in error messages) → ProgramInfoValidate α λ -- ^ program info value; use 'programInfo' to construct a value of this -- type → [String] -- ^ command line arguments → m α parseConfiguration appName appInfo args = do -- Parse command line arguments (add static config files to resulting app config) cliAppConf ← case O.execParserPure parserPrefs (mainOptions appInfo Nothing) args of O.Success a → return $ a & configFiles `over` (⊕) (_piConfigurationFiles appInfo) O.Failure e → throwError ∘ T.pack ∘ fst $ renderFailure e (T.unpack appName) O.CompletionInvoked _ → throwError "command line parser returned completion result" -- Load and parse all configuration files appConf ← cliAppConf & mainConfig `id` \a → a <$> CF.parseConfigFiles (_configFilesConfig cliAppConf) (_piDefaultConfiguration appInfo) (_configFiles cliAppConf) -- Validate final configuration void ∘ validate appInfo $ _mainConfig appConf return $ _mainConfig appConf where parserPrefs = O.prefs O.disambiguate validate i conf = runWriterT $ runConfigValidation (view piValidateConfiguration i) conf -- -------------------------------------------------------------------------- -- -- Validation -- | Validates a configuration value. Throws an user error -- if there is an error. If there are warnings they are -- printed to 'stderr'. -- validateConfig ∷ (Foldable λ, Monoid (λ T.Text)) ⇒ ProgramInfoValidate α λ → α → IO () validateConfig appInfo conf = do warnings ← execWriterT ∘ exceptT (error ∘ T.unpack) return $ runConfigValidation (view piValidateConfiguration appInfo) conf when (any (const True) warnings) $ do T.hPutStrLn stderr "WARNINGS:" mapM_ (\w → T.hPutStrLn stderr $ "warning: " ⊕ w) warnings