EVP (empty) → 0
raw patch · 6 files changed
+355/−0 lines, 6 filesdep +EVPdep +basedep +containers
Dependencies added: EVP, base, containers, data-default-class, text, yaml
Files
- CHANGELOG.md +5/−0
- EVP.cabal +42/−0
- LICENSE +30/−0
- README.md +84/−0
- src/EVP.hs +164/−0
- tests/test.hs +30/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for EVP++## 0 -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ EVP.cabal view
@@ -0,0 +1,42 @@+cabal-version: 3.0+name: EVP+version: 0+synopsis: Environment Variable Parser+description: See README.md+homepage: https://github.com/fumieval/EVP+license: BSD-3-Clause+license-file: LICENSE+author: Fumiaki Kinoshita+maintainer: fumiexcel@gmail.com+copyright: Copyright (c) 2023 Fumiaki Kinoshita+category: System+build-type: Simple+extra-doc-files: CHANGELOG.md+extra-source-files:+ README.md++common warnings+ ghc-options: -Wall++source-repository head+ type: git+ location: https://github.com/fumieval/EVP.git++library+ import: warnings+ exposed-modules: EVP+ build-depends: base >=4.16, containers, data-default-class, text, yaml+ hs-source-dirs: src+ default-language: GHC2021++test-suite test+ type: exitcode-stdio-1.0+ main-is: test.hs+ hs-source-dirs:+ tests+ build-depends:+ base >=4.7 && <5+ , EVP+ , text+ default-language: GHC2021+ ghc-options: -Wall -Wcompat
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2023, Fumiaki Kinoshita++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Fumiaki Kinoshita nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,84 @@+EVP: Environment Variable Parser+====++EVP is a simple environment parser which focues on these three aspects:++* Ease of use: no complicated machinery is needed+* Observability: for each environment variable, EVP logs the parsed value or an error. An error does not interrupt the parsing process and it checks all the variables exhaustively.+* Composability: environment parsers can be composed via the Applicative structure.++Example+----++The following code is a complete example demonstrating how to use EVP:++```haskell+{-# LANGUAGE ApplicativeDo #-}++import EVP qualified++main :: IO ()+main = EVP.scan parser++-- ApplicativeDo is important here because Scan is not a monad.+parser :: EVP.Scan ()+parser = do+ -- @secret@ masks the parsed value+ _token <- EVP.secret $ EVP.string "API_TOKEN"+ -- parse the environment variable as a YAML value+ _port <- EVP.yaml "HTTP_PORT"+ -- obtain the environment variable as is+ _foo <- EVP.string "FOO"+ -- you can also provide a default value+ _debug <- EVP.yamlDefault "DEBUG_MODE" False+ pure ()+```++Running this code produces the following output.++```+[EVP Info] API_TOKEN: <REDACTED>+[EVP Info] HTTP_PORT: 8080+[EVP Info] FOO: foo+[EVP Info] DEBUG_MODE: False (default)+```++Revealing unused variables+----++EVP has a mechanism to detect unused variables.++If your application's environment variables has a common prefix `MYAPP_`, you can set `assumePrefix` as a `unusedLogger`.++```haskell+EVP.scanWith EVP.def+ { EVP.unusedLogger = EVP.assumePrefix "MYAPP_" }+ parser+```++If an environment variable prefixed by `MYAPP_` is set but not referred to, EVP prints the following warning. This is useful for detecting typos too.++```+[EVP Warn] MYAPP_OBSOLETE_FLAG is set but not used+```++Alternatively, you can also name unwanted variables individually:++```haskell+EVP.scanWith EVP.def+ { EVP.unusedLogger = EVP.obsolete ["OBSOLETE_VAR"] }+```++These can be combined using the `Semigroup` instance.++```haskell+EVP.scanWith EVP.def+ { EVP.unusedLogger = EVP.assumePrefix "MYAPP_" <> EVP.obsolete ["OBSOLETE_VAR"] }+ parser+```++Design context+----++* If `Scan` were a monad, the parsing process would be non-deterministic. This might cause a burden when there are two or more problems in the environment variables, because it is not possible to reveal all problems in a non-deterministic context.+* It is recommended to avoid falling back to default values expect for debugging features. If the application configuration has an error such as a typo, it is much safer to exit than launching anyway with a default value.
+ src/EVP.hs view
@@ -0,0 +1,164 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE RecordWildCards #-}+module EVP+ ( Name+ , Error(..)+ -- * Parsers+ , string+ , yaml+ , parse+ , secret+ -- * Providing a default value+ , stringDefault+ , yamlDefault+ , parseDefault+ -- * Runner+ , Settings(..)+ , def+ , scan+ , scanWith+ , enumerate+ -- * Logger+ , assumePrefix+ , obsolete+ -- * Internal+ , Scan(..)+ ) where++import Control.Monad+import Data.Bifunctor+import Data.Default.Class+import Data.List (isPrefixOf)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.String+import Data.Yaml qualified as Yaml+import Data.Text.Encoding+import System.Environment+import System.Exit+import System.IO++type Name = String++data Error = Missing Name+ | ParseError Name String+ deriving Show++-- | Obtain the environment variable.+string :: (IsString a) => Name -> Scan a+string v = Var v (\case+ Nothing -> Left (Missing v)+ Just x -> Right (x, fromString x)) (Pure id)++stringDefault :: (IsString a) => Name -> String -> Scan a+stringDefault v d = Var v (\case+ Nothing -> Right (d <> " (default)", fromString d)+ Just x -> Right (x, fromString x)) (Pure id)++-- | Parse the environment variable as a YAML value.+yaml :: (Show a, Yaml.FromJSON a) => Name -> Scan a+yaml v = parse v decodeYaml++-- | Parse the environment variable as a YAML value.+yamlDefault :: (Show a, Yaml.FromJSON a) => Name -> a -> Scan a+yamlDefault v d = parseDefault v d decodeYaml++decodeYaml :: Yaml.FromJSON a => String -> Either String a+decodeYaml = first show . Yaml.decodeEither' . encodeUtf8 . fromString++parse :: (Show a) => Name -> (String -> Either String a) -> Scan a+parse v f = Var v (\case+ Nothing -> Left (Missing v)+ Just x -> bimap (ParseError v) withShow $ f x) (Pure id)++parseDefault :: (Show a) => Name -> a -> (String -> Either String a) -> Scan a+parseDefault v d f = Var v (\case+ Nothing -> Right (show d <> " (default)", d)+ Just x -> bimap (ParseError v) withShow $ f x) (Pure id)++-- | Disable logging of parsed values.+secret :: Scan a -> Scan a+secret (Pure a) = Pure a+secret (Var v f k) = Var v (fmap (first (const "<REDACTED>")) . f) (secret k)++withShow :: Show a => a -> (String, a)+withShow x = (show x, x)+ +data Scan a where+ Pure :: a -> Scan a+ Var :: Name -> (Maybe String -> Either Error (String, a)) -> Scan (a -> b) -> Scan b++instance Functor Scan where+ fmap f (Pure a) = Pure (f a)+ fmap f (Var k g c) = Var k g (fmap (f.) c)++instance Applicative Scan where+ pure = Pure+ Pure f <*> k = f <$> k+ Var k f c <*> r = Var k f (flip <$> c <*> r)+ +type EnvMap = Map.Map String String++data Settings = Settings+ { parseLogger :: Name -> String -> IO ()+ , errorLogger :: Error -> IO ()+ , unusedLogger :: Name -> Maybe (IO ())+ , pedantic :: Bool -- ^ exit on warning+ }+ +instance Default Settings where+ def = Settings+ { parseLogger = \name value -> putStrLn $ unwords ["[EVP Info]", name <> ":", value]+ , errorLogger = \e -> hPutStrLn stderr $ unwords ["[EVP Error]", show e]+ , unusedLogger = mempty+ , pedantic = False+ }++-- | Custom logging function for 'unusedLogger'.+-- @'assumePrefix' p@ prints a warning for each unused environment variable prefixed by @p@.+assumePrefix :: String -> Name -> Maybe (IO ())+assumePrefix prefix name+ | isPrefixOf prefix name = Just $ hPutStrLn stderr $ unwords ["[EVP Warn]", name, "is set but not used"]+ | otherwise = Nothing++-- | @'obsolete' names@ prints a warning if any of the @names@ is set but not used.+obsolete :: [Name] -> Name -> Maybe (IO ())+obsolete nameSet name+ | elem name nameSet = Just $ hPutStrLn stderr $ unwords ["[EVP Warn]", name, "is obsolete"]+ | otherwise = Nothing++-- | Enumerate the names of the variables it would parse.+enumerate :: Scan a -> [Name]+enumerate m = Set.toList $ go Set.empty m where+ go :: Set.Set Name -> Scan a -> Set.Set Name+ go !s (Pure _) = s+ go !s (Var k _ cont) = go (Set.insert k s) cont++scan :: Scan a -> IO a+scan = scanWith def++scanWith :: Settings -> Scan a -> IO a+scanWith Settings{..} action = do+ envs0 <- Map.fromList <$> getEnvironment+ (remainder, errors, result) <- go envs0 envs0 action+ mapM_ errorLogger errors+ case foldMap unusedLogger $ Map.keys remainder of+ Nothing -> pure ()+ Just m -> do+ m+ when pedantic exitFailure+ case result of+ Nothing -> exitFailure+ Just a -> pure a+ where+ go :: EnvMap -> EnvMap -> Scan a -> IO (EnvMap, [Error], Maybe a)+ go _ envs (Pure a) = pure (envs, [], Just a)+ go allEnvs envs (Var name parser cont) = case parser (Map.lookup name allEnvs) of+ Left e -> do+ (remainder, errors, _) <- go allEnvs (Map.delete name envs) cont+ pure (remainder, e : errors, Nothing)+ Right (display, v) -> do+ parseLogger name display+ (remainder, errors, func) <- go allEnvs (Map.delete name envs) cont+ pure (remainder, errors, ($ v) <$> func)
+ tests/test.hs view
@@ -0,0 +1,30 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE GADTs #-}++import Data.Text (Text)+import EVP qualified++main :: IO ()+main = EVP.scanWith EVP.def+ { EVP.unusedLogger = EVP.assumePrefix "MYAPP_" <> EVP.obsolete ["OBSOLETE_VAR"] }+ parser++-- ApplicativeDo is important here because Scan is not a monad.+parser :: EVP.Scan ()+parser = do+ -- @secret@ disables logging+ _token :: String <- EVP.secret $ EVP.string "API_TOKEN"+ -- parse the environment variable as a YAML value+ _port :: Int <- EVP.yaml "HTTP_PORT"+ -- obtain the environment variable as is+ _ :: Text <- EVP.string "FOO"+ -- you can also provide a default value+ _ :: Bool <- EVP.yamlDefault "DEBUG_MODE" False+ pure ()++{-+[EVP Info] API_TOKEN: <REDACTED>+[EVP Info] HTTP_PORT: 8080+[EVP Info] FOO: foo+[EVP Info] DEBUG_MODE: False (default)+-}