{-# LANGUAGE DeriveFunctor #-} -- | A module for reading configuration options from environment variables. -- -- Applications have configuration options. [The Twelve-Factor -- App](https://12factor.net/import) recommends applications read these from -- environment variables. This requires us to decode environment variables, -- which are strings, into the different types the app's configuration options -- might have. This module helps with that. -- -- Here's what sets this package apart from other environment parsers: -- -- - Very small API, supporting just one way to do environment parsing. -- - Comes with parsers for common configuration option types, such as URIs. -- Not using type classes for these parsers means we don't have to write a -- bunch of orphan instances. -- - Mandatory documentation of each environment variable we want to decode. -- - The decoders keep track of all the environment variables they depend on. -- That way the decoder for an application can tell us all the environment -- variables an application depends on and what they are used for. module Environment ( -- * Parsers Parser, text, int, float, boolean, uri, filePath, networkURI, secret, custom, -- * Decoders Decoder, consumes, Variable (Variable, name, description, defaultValue), variable, either, decode, decodeDefaults, decodePairs, -- * Decoders for just the variables decodeVariables, decodeVariablePairs, DecodedVariable ( DecodedVariable, decodedVariable, decodedCurrent, decodedErrors ), ) where import qualified Data.Text import qualified Debug import qualified Dict import qualified List import qualified Log import qualified Maybe import qualified Network.URI import NriPrelude import qualified Result import qualified System.Environment import Text.Read (readMaybe) import qualified Text.URI import qualified Tuple import Prelude ( Applicative, Either (Left, Right), FilePath, Functor, IO, Integer, Integral, Semigroup, fail, fromIntegral, mempty, pure, ) -- | -- A function that can read values of a type from text. For example, a -- @Parser Int@ knows how to read a string like "412" and extract from that the -- number @412@. -- -- Parsing functions can fail when they read a text that they do not understand. -- For example, the @Parser Int@ parser will fail if ran against the string -- "Not a number in the slightest!". newtype Parser a = Parser (Text -> Result Text a) deriving (Functor) -- | Parse a text from an environment variable. text :: Parser Text text = Parser Ok -- | Parse an integer from an environment variable. -- Works for any integer type (@Integer@, @Int@, @Int@, ...). int :: (Integral a) => Parser a int = Parser <| \str -> case readMaybe (Data.Text.unpack str) of Nothing -> Err ("Could not parse as integer: " ++ str) Just (n :: Integer) -> Ok (fromIntegral n) -- | Parse a floating point number from an environment variable. float :: Parser Float float = Parser <| \str -> case readMaybe (Data.Text.unpack str) of Nothing -> Err ("Could not parse as float: " ++ str) Just n -> Ok n -- | Parse a boolean from an environment variable. boolean :: Parser Bool boolean = Parser <| \str -> case readMaybe (Data.Text.unpack str) of Nothing -> Err ("Could not parse as boolean: " ++ str) Just x -> Ok x -- | Parse a URI from an environment variable. uri :: Parser Text.URI.URI uri = Parser <| \str -> case Text.URI.mkURI str of Left err -> ["Unexpected exception parsing uri:", str, ". Error reads:", Debug.toString err] |> Data.Text.unwords |> Err Right x -> Ok x -- | Parse a file path from an environment variable. filePath :: Parser FilePath filePath = Parser (Ok << Data.Text.unpack) -- | Parse a secret value from an environment variable. -- -- Check the documentation for the @Log@ module of @nri-prelude@ to learn more -- about secrets. secret :: Parser a -> Parser (Log.Secret a) secret = map Log.mkSecret -- | There's two @URI@ types that are in vogue in the Haskell ecosystem. We would -- like to standardized on the @Text.URI@ package, since it's the more modern -- @Text@ based version (no @Strings@ for us!), but most libraries require the -- other type. This function helps convert. networkURI :: Parser Network.URI.URI networkURI = Parser <| \str -> case Network.URI.parseURI (Data.Text.unpack str) of Nothing -> Err "Oh no! We have a valid Network.URI.URI but can't seem to parse it as a Network.URI.URI." Just uri' -> Ok uri' {Network.URI.uriPath = ""} -- | Create a parser for custom types. Build on the back of one of the primitve -- parsers from this module. -- -- > data Environment = Development | Production -- > -- > environment :: Parser Environment -- > environment = -- > custom text <| \str -> -- > case str of -- > "development" -> Ok Development -- > "production" -> Ok Production -- > _ -> Err ("Unknown environment: " ++ str) custom :: Parser a -> (a -> Result Text b) -> Parser b custom (Parser base) fn = Parser (\val -> base val |> andThen fn) -- | An environment decoder knows how to read an app's configuration from -- environment variables. Check out the @variable@ function to see how you can -- begin building decoders. data Decoder config = Decoder { -- | The list of @Variable@s that this decoder will read when ran. consumes :: [Variable], readFromEnvironment :: Dict.Dict Text Text -> Result [ParseError] config } deriving (Functor) instance Applicative Decoder where pure x = Decoder [] (\_ -> Ok x) (Decoder consumes1 f) <*> (Decoder consumes2 x) = Decoder { consumes = consumes1 ++ consumes2, readFromEnvironment = readFromEnvironment' f x } where readFromEnvironment' :: Semigroup err => (env -> Result err (a -> config)) -> (env -> Result err a) -> env -> Result err config readFromEnvironment' f' x' env = -- This is the same as <*> except that when both sides are errors we -- want to capture both, not just the first. case (f' env, x' env) of (Err fe, Err xe) -> Err (fe ++ xe) (fr, xr) -> fr <*> xr -- | An environment variable with a description of what it is used for. data Variable = Variable { name :: Text, description :: Text, defaultValue :: Text } deriving (Show) data ParseError = ParseError { failingVariable :: Variable, failingReason :: Text } deriving (Show) -- | Describe a decoded variable for informational purposes. data DecodedVariable = DecodedVariable { decodedVariable :: Variable, decodedCurrent :: Maybe Text, -- A single environment variable can be decoded by multiple decoders, -- each with their own constraints. decodedErrors :: List Text } deriving (Show) -- | Produce a configuration from a single environment veriable. Usually you -- will combine these with @mapN@ functions to build larger configurations. -- -- > Data Settings = Settings -- > { amountOfHats :: Int -- > , furLined :: Bool -- > } -- > -- > map2 -- > Settings -- > (variable (Variable "HATS" "Amount of hats" "2") int) -- > (variable (Variable "FUR_LINED" "Do hats have fur lining?" "False") boolean) variable :: Variable -> Parser a -> Decoder a variable var (Parser parse) = Decoder { consumes = [var], readFromEnvironment = \env -> let value = Dict.get (name var) env |> Maybe.withDefault (defaultValue var) in parse value |> Result.mapError (pure << ParseError var) } -- | If the first decoder fails, try the second. either :: Decoder a -> Decoder a -> Decoder a either (Decoder consumes1 fa) (Decoder consumes2 fb) = Decoder { consumes = consumes1 ++ consumes2, readFromEnvironment = \env -> case fa env of Err ea -> case fb env of Err eb -> Err (ea ++ eb) Ok r -> Ok r Ok r -> Ok r } -- | Attempt to decode a configuration by reading environment variables. -- This will fail if one or more environment variables fail to parse. -- -- It will not fail if certain environment variables are absent. Defaults will -- be used for those missing values. decode :: Decoder a -> IO a decode configuration = do env <- getEnv case decodePairs configuration env of Err err -> fail (Data.Text.unpack err) Ok x -> pure x -- | Same as 'decode', but takes the environment to decode as a dictionary. decodePairs :: Decoder a -> Dict.Dict Text Text -> Result Text a decodePairs configuration env = case readFromEnvironment configuration env of Err err -> Err (errorsToText err) Ok x -> Ok x -- | Run a decoder. Instead of returnin the decoded value return metadata about -- each variable that was decoded. -- -- This can be helpful when generating a @--help@ command, for listing all the -- variables that the application supports and what they are currently set to. decodeVariables :: Decoder a -> IO [DecodedVariable] decodeVariables configuration = fmap (decodeVariablePairs configuration) getEnv -- | Same as 'decodeVariables', but takes the environment to decode as a -- dictionary. decodeVariablePairs :: Decoder a -> Dict.Dict Text Text -> [DecodedVariable] decodeVariablePairs configuration env = do var <- consumes configuration pure ( DecodedVariable { decodedVariable = var, decodedCurrent = Dict.get (name var) env, decodedErrors = Dict.get (name var) errors |> Maybe.withDefault [] } ) where errors = map errorPair parseErrors |> List.foldl insert Dict.empty insert (k, v) dict = Dict.update k ( \prev -> case prev of Nothing -> Just v Just v' -> Just (v' ++ v) ) dict errorPair err = ((failingVariable >> name) err, [failingReason err]) parseErrors = case readFromEnvironment configuration env of Err errs -> errs Ok _ -> [] getEnv :: IO (Dict.Dict Text Text) getEnv = do pairs <- System.Environment.getEnvironment pure <| Dict.fromList <| map (Tuple.mapBoth Data.Text.pack Data.Text.pack) pairs -- | Build a configuration using only default values of environment variables. -- Similar to @decode@, except this version doesn't read any environment -- variables. -- -- This is sometimes useful for tests, where you might not care about the exact -- values of settings. decodeDefaults :: Decoder a -> Result Text a decodeDefaults configuration = readFromEnvironment configuration mempty |> Result.mapError errorsToText errorsToText :: [ParseError] -> Text errorsToText errors = map errorToText errors |> Data.Text.intercalate "\n\n" errorToText :: ParseError -> Text errorToText ParseError {failingVariable, failingReason} = Data.Text.unwords ["Parsing", name failingVariable, "failed:", failingReason]