packages feed

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 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)+-}