packages feed

figment-0.2.0.0: test/Figment/Test/Tutorial.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TypeFamilies #-}

-- | Every code snippet in "Figment.Tutorial", compiled and run. If you
-- change one, change the other.
module Figment.Test.Tutorial (tests) where

import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import Data.Function ((&))
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import GHC.Generics (Generic)
import Test.Tasty
import Test.Tasty.HUnit

import qualified Figment as F
import qualified Figment.Jail as Jail
import qualified Figment.Provider.Env as Env

-- §2 Your first configuration ---------------------------------------------

data Config = Config
  { name :: Text
  , port :: Int
  , debug :: Bool
  }
  deriving stock (Show, Eq, Generic)
  deriving anyclass (F.FromConfig)

firstFigment :: F.Figment
firstFigment = F.from (F.file F.toml "App.toml")

-- §3 Layering sources --------------------------------------------------------

layered :: [Text] -> F.Figment
layered args =
  F.new
    & F.merge (F.defaults (Aeson.object ["port" .= (8080 :: Int), "debug" .= False]))
    & F.merge (F.file F.toml "App.toml")
    & F.merge (F.prefixed "APP_")
    & F.merge (F.cli args)

-- §4 Environment variables ---------------------------------------------------

data Db = Db {host :: Text, port :: Int}
  deriving stock (Show, Eq, Generic)
  deriving anyclass (F.FromConfig)

newtype WithDb = WithDb {db :: Db}
  deriving stock (Show, Eq, Generic)
  deriving anyclass (F.FromConfig)

dbFromEnv :: F.Figment
dbFromEnv = F.from (F.prefixed "APP_" & Env.split "__")

-- §6 Profiles ----------------------------------------------------------------

profiled :: F.Profile -> F.Figment
profiled p =
  F.new
    & F.merge (F.nested (F.file F.toml "App.toml"))
    & F.select p

-- §8 Decoding in depth -------------------------------------------------------

data Level = Debug | Info | Warning
  deriving stock (Show, Eq, Generic)
  deriving anyclass (F.FromConfig)

data Server = Server
  { listenAddress :: Text
  , maxConnections :: Int
  , logLevel :: Level
  }
  deriving stock (Show, Eq, Generic)

instance F.FromConfig Server where
  fromConfig =
    F.genericDecoder
      F.defaultOptions
        { F.fieldLabelModifier = F.snakeCase
        , F.rejectUnknownFields = True
        }

newtype Port = Port Int
  deriving stock (Show, Eq)

instance F.FromConfig Port where
  fromConfig = F.validate check F.fromConfig
    where
      check n
        | n > 0 && n < 65536 = Right (Port n)
        | otherwise = Left "a port must be between 1 and 65535"

data Endpoint = Endpoint {url :: Text, timeout :: Int, retries :: Int}
  deriving stock (Show, Eq)

instance F.FromConfig Endpoint where
  fromConfig =
    Endpoint
      <$> F.field "url"
      <*> F.fieldOr "timeout" 30
      <*> F.fieldOr "retries" 3

-- §9 Provenance --------------------------------------------------------------

data Paths = Paths
  { dataDir :: F.RelativePath
  , token :: F.Tagged Text
  }
  deriving stock (Show, Eq, Generic)

instance F.FromConfig Paths where
  fromConfig = F.genericDecoder F.defaultOptions {F.fieldLabelModifier = F.snakeCase}

-- §10 Partial configurations -------------------------------------------------

data AppF f = App
  { appName :: F.Field f Text
  , workers :: F.Field f Int
  , motd :: F.Field f (Maybe Text)
  }
  deriving stock (Generic)

instance F.Partial AppF

deriving stock instance Show (AppF F.Identity)
deriving stock instance Eq (AppF F.Identity)

type App = AppF F.Identity

appDefaults :: AppF Maybe
appDefaults = (F.emptyPartial @AppF) {workers = Just 4}

-- §11 For library authors -----------------------------------------------------

libraryFigment :: F.Figment
libraryFigment =
  F.new
    & F.merge (F.defaults (Aeson.object ["workers" .= (4 :: Int)]))
    & F.merge (F.prefixed "MYLIB_")

applicationFigment :: F.Figment
applicationFigment = libraryFigment <> F.from (F.file F.toml "MyApp.toml")

-- §12 Writing a provider ------------------------------------------------------

-- | Configuration held in a Haskell map, e.g. fetched from a secrets
-- service at startup.
newtype Secrets = Secrets (Map.Map Text Text)

instance F.Provider Secrets where
  metadata _ = F.named "secrets service"
  provide _limits (Secrets m) =
    pure (Right (Map.singleton F.Default (fmap (F.Value untaggedTag . F.String) m)))

untaggedTag :: F.Tag
untaggedTag = F.Tag Nothing Nothing Nothing Nothing

-- tests --------------------------------------------------------------------

ok :: F.Figment -> IO F.Loaded
ok f = F.load f >>= either (assertFailure . Text.unpack . F.renderErrors) pure

tests :: TestTree
tests =
  testGroup
    "tutorial"
    [ testCase "§2 first configuration" $ Jail.withJail $ \j -> do
        _ <- Jail.createFile j "App.toml" "name = \"demo\"\nport = 8080\ndebug = false\n"
        cfg <- F.loadExtractOrThrow firstFigment
        cfg @?= Config "demo" 8080 False
    , testCase "§3 layering" $ Jail.withJail $ \j -> do
        _ <- Jail.createFile j "App.toml" "name = \"demo\"\nport = 9000\n"
        Jail.setEnv j "APP_DEBUG" "true"
        cfg <- F.loadExtractOrThrow (layered ["--port=9100"])
        cfg @?= Config "demo" 9100 True
    , testCase "§4 env" $ Jail.withJail $ \j -> do
        Jail.setEnv j "APP_DB__HOST" "db.internal"
        Jail.setEnv j "APP_DB__PORT" "5432"
        cfg <- F.loadExtractOrThrow dbFromEnv
        cfg @?= WithDb (Db "db.internal" 5432)
    , testCase "§6 profiles" $ Jail.withJail $ \j -> do
        _ <- Jail.createFile j "App.toml" $ Text.unlines
          [ "[default]", "name = \"app\"", "port = 8080", "debug = true"
          , "[production]", "port = 80", "debug = false" ]
        dev <- F.loadExtractOrThrow (profiled F.Default)
        prod <- F.loadExtractOrThrow (profiled "production")
        dev @?= Config "app" 8080 True
        prod @?= Config "app" 80 False
    , testCase "§7 errors as values" $ do
        l <- ok (F.from (F.fromText F.toml "name = 1\n"))
        case F.extract l :: Either F.ConfigErrors Config of
          Left errs -> map F.errorKind (F.errorList errs)
            @?= [ F.InvalidType "integer 1" "a string", F.MissingField "port", F.MissingField "debug" ]
          Right v -> assertFailure (show v)
    , testCase "§8 decoding" $ do
        l <- ok (F.from (F.fromText F.yaml "listen_address: 0.0.0.0\nmax_connections: 100\nlog_level: info\n"))
        F.extract l @?= Right (Server "0.0.0.0" 100 Info)
        l2 <- ok (F.from (F.fromText F.json "{\"port\": 0, \"api\": {\"url\": \"https://x\"}}"))
        case F.extractAt (F.keyPath "port") l2 :: Either F.ConfigErrors Port of
          Left errs -> map F.errorKind (F.errorList errs) @?= [F.Message "a port must be between 1 and 65535"]
          Right v -> assertFailure (show v)
        F.extractAt (F.keyPath "api") l2 @?= Right (Endpoint "https://x" 30 3)
    , testCase "§9 provenance" $ Jail.withJail $ \j -> do
        path <- Jail.createFile j "conf/app.toml" "data_dir = \"data\"\ntoken = \"t0k3n\"\n"
        l <- ok (F.from (F.fileExact F.toml path))
        case F.extract l of
          Right (Paths d t) -> do
            F.relative d @?= Jail.jailDirectory j <> "/conf/data"
            fmap F.metaName (F.taggedMetadata t) @?= Just "TOML file"
          Left e -> assertFailure (Text.unpack (F.renderErrors e))
    , testCase "§10 partial" $ do
        l <- ok (F.from (F.fromText F.toml "appName = \"x\"\n"))
        F.extractPartial appDefaults l @?= (Right (App "x" 4 Nothing) :: Either F.ConfigErrors App)
        l2 <- ok (F.from (F.fromText F.toml ""))
        case F.extractPartial appDefaults l2 of
          Left errs -> map F.errorKind (F.errorList errs) @?= [F.MissingField "appName"]
          Right v -> assertFailure (show v)
    , testCase "§11 library figments compose" $ Jail.withJail $ \j -> do
        _ <- Jail.createFile j "MyApp.toml" "workers = 16\n"
        l <- ok applicationFigment
        F.extractAt (F.keyPath "workers") l @?= Right (16 :: Int)
    , testCase "§12 custom provider" $ do
        l <- ok (F.from (Secrets (Map.fromList [("token", "s3cret")])))
        F.extractAt (F.keyPath "token") l @?= Right ("s3cret" :: Text)
        fmap F.metaName (F.findMetadata (F.keyPath "token") l) @?= Just "secrets service"
    , testCase "§13 testing without global state" $ do
        l <- ok (F.from (Env.fromList [("APP_PORT", "1")] (F.prefixed "APP_")))
        F.extractAt (F.keyPath "port") l @?= Right (1 :: Int)
    ]