envy
Let's face it, dealing with environment variables in Haskell isn't that satisfying.
import System.Environment
import Data.Text (pack)
import Text.Read (readMaybe)
data ConnectInfo = ConnectInfo {
pgPort :: Int
pgURL :: Text
} deriving (Show, Eq)
getPGPort :: IO ConnectInfo
getPGPort = do
portResult <- lookupEnv "PG_PORT"
urlResult <- lookupEnv "PG_URL"
case (portResult, urlResult) of
(Just port, Just url) ->
case readMaybe port :: Maybe Int of
Nothing -> error "PG_PORT isn't a number"
Just portNum -> return $ ConnectInfo portNum (pack url)
(Nothing, _) -> error "Couldn't find PG_PORT"
(_, Nothing) -> error "Couldn't find PG_URL"
-- Pretty gross right...
Another attempt to remedy the lookup madness is with a MaybeT IO a
. See below.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Applicative
import Control.Monad.Trans.Maybe
import Control.Monad.IO.Class
import System.Environment
newtype Env a = Env { unEnv :: MaybeT IO a }
deriving (Functor, Applicative, Monad, MonadIO, Alternative, MonadPlus)
getEnv :: Env a -> IO (Maybe a)
getEnv env = runMaybeT (unEnv env)
env :: String -> Env a
env key = Env (MaybeT (lookupEnv key))
connectInfo :: Env ConnectInfo
connectInfo = ConnectInfo
<$> env "PG_HOST"
<*> env "PG_PORT"
<*> env "PG_USER"
<*> env "PG_PASS"
<*> env "PG_DB"
This abstraction falls short in two areas:
- Lookups don't return any information when a variable doesn't exist (just a
Nothing
)
- Lookups don't attempt to parse the returned type into something meaningful (everything is returned as a
String
because lookupEnv :: String -> IO (Maybe String)
)
What if we could apply aeson's FromJSON
/ ToJSON
pattern to give us variable lookups that provide both key-lookup and parse failure information?
Armed with the GeneralizedNewTypeDeriving
extension we can derive instances of Var
that will parse to and from an environment variable. The Var
typeclass is simply:
class Var a where
toVar :: a -> String
fromVar :: String -> Maybe a
With instances for most concrete and primitive types supported (Word8
- Word64
, Int
, Integer
, String
, Text
, etc.) the Var
class is easily deriveable. The FromEnv
typeclass provides a parser type that is an instance of MonadError String
and MonadIO
. This allows for connection pool initialization inside of our environment parser and custom error handling. The ToEnv
class allows us to create an environment configuration given any a
. See below for an example.
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveDataTypeable #-}
------------------------------------------------------------------------------
module Main ( main ) where
------------------------------------------------------------------------------
import Control.Applicative
import Control.Exception
import Control.Monad
import Data.Either
import Data.Word
import System.Environment
import System.Envy
------------------------------------------------------------------------------
data ConnectInfo = ConnectInfo {
pgHost :: String
, pgPort :: Word16
, pgUser :: String
, pgPass :: String
, pgDB :: String
} deriving (Show)
------------------------------------------------------------------------------
-- | FromEnv instances support popular aeson combinators *and* IO
-- for dealing with connection pool initialization. `env` is equivalent to (.:) in `aeson`
-- and `envMaybe` is equivalent to (.:?), except here the lookups are impure.
instance FromEnv ConnectInfo where
fromEnv _ =
ConnectInfo <$> envMaybe "PG_HOST" .!= "localhost"
<*> env "PG_PORT"
<*> env "PG_USER"
<*> env "PG_PASS"
<*> env "PG_DB"
------------------------------------------------------------------------------
-- | To Environment Instances
-- (.=) is a smart constructor for producing types of `EnvVar` (which ensures
-- that Strings are set properly in an environment so they can be parsed properly
instance ToEnv ConnectInfo where
toEnv ConnectInfo {..} = makeEnv
[ "PG_HOST" .= pgHost
, "PG_PORT" .= pgPort
, "PG_USER" .= pgUser
, "PG_PASS" .= pgPass
, "PG_DB" .= pgDB
]
------------------------------------------------------------------------------
-- | Example
main :: IO ()
main = do
setEnvironment (toEnv :: EnvList ConnectInfo)
print =<< do decodeEnv :: IO (Either String ConnectInfo)
-- unsetEnvironment (toEnv :: EnvList ConnectInfo) -- remove when done
Our parser might also make use a set of an optional default values provided by the user,
for dealing with errors when reading from the environment
instance FromEnv ConnectInfo where
fromEnv Nothing =
ConnectInfo <$> envMaybe "PG_HOST" .!= "localhost"
<*> env "PG_PORT"
<*> env "PG_USER"
<*> env "PG_PASS"
<*> env "PG_DB"
fromEnv (Just def) =
ConnectInfo <$> envMaybe "PG_HOST" .!= (pgHost def)
<*> envMaybe "PG_PORT" .!= (pgPort def)
<*> env "PG_USER" .!= (pgUser def)
<*> env "PG_PASS" .!= (pgPass def)
<*> env "PG_DB" .!= (pgDB def)
Note: As of base 4.7 setEnv
and getEnv
throw an IOException
if a =
is present in an environment. envy
catches these synchronous exceptions and delivers them
purely to the end user.
Generics
As of version 1.0
, all FromEnv
instance boilerplate can be completely removed thanks to GHC.Generics
! Below is an example.
{-# LANGUAGE DeriveGeneric #-}
module Main where
import System.Envy
import GHC.Generics
import System.Environment.Blank
-- This record corresponds to our environment, where the field names become the variable names, and the values the environment variable value
data PGConfig = PGConfig {
pgHost :: String -- "PG_HOST"
, pgPort :: Int -- "PG_PORT"
} deriving (Generic, Show)
instance FromEnv PGConfig
-- Generically creates instance for retrieving environment variables (PG_HOST, PG_PORT)
main :: IO ()
main = do
_ <- setEnv "PG_HOST" "valueFromEnv" True
_ <- setEnv "PG_PORT" "66354651" True
print =<< do decodeEnv :: IO (Either String PGConfig)
-- > PGConfig { pgHost = "valueFromEnv", pgPort = 66354651 }
If the variables are not found in the environment, the parser will currently fail with an error about the first missing field.
The user can decide to provide a default value, whose fields will be used by the generic instance, if retrieving them from the environment fails.
defConfig :: PGConfig
defConfig = PGConfig "localhost" 5432
main :: IO ()
main = do
_ <- setEnv "PG_HOST" "customURL" True
print =<< decodeWithDefaults defConfig
-- > PGConfig { pgHost = "customURL", pgPort = 5432 }
Suppose you'd like to customize the field name (i.e. add your own prefix, or drop the existing record prefix). This too is possible. See below.
{-# LANGUAGE DeriveGeneric #-}
module Main where
import System.Envy
import GHC.Generics
data PGConfig = PGConfig {
connectHost :: String -- "PG_HOST"
, connectPort :: Int -- "PG_PORT"
} deriving (Generic, Show)
instance DefConfig PGConfig where
defConfig = PGConfig "localhost" 5432
-- All fields will be converted to uppercase
instance FromEnv PGConfig where
fromEnv = gFromEnvCustom Option {
dropPrefixCount = 7
, customPrefix = "CUSTOM"
}
main :: IO ()
main =
_ <- setEnv "CUSTOM_HOST" "customUrl" True
print =<< do decodeEnv :: IO (Either String PGConfig)
-- PGConfig { pgHost = "customUrl", pgPort = 5432 }
It's also possible to avoid typeclasses altogether using runEnv
with gFromEnvCustom
.
{-# LANGUAGE DeriveGeneric #-}
module Main where
import System.Envy
import GHC.Generics
data PGConfig = PGConfig {
pgHost :: String -- "PG_HOST"
, pgPort :: Int -- "PG_PORT"
} deriving (Generic, Show)
-- All fields will be converted to uppercase
getPGEnv :: IO (Either String PGConfig)
getPGEnv = runEnv $ gFromEnvCustom defOption
(Just (PGConfig "localhost" 5432))
main :: IO ()
main = print =<< getPGEnv
-- PGConfig { pgHost = "localhost", pgPort = 5432 }