Skip to content

Commit b7b18e1

Browse files
committed
Handle "no env -> Nothing" inside the framework
1 parent d917fe4 commit b7b18e1

File tree

2 files changed

+27
-59
lines changed

2 files changed

+27
-59
lines changed

code/config/lib/Loot/Config/Env.hs

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Imagine you have the following configuration:
1717
type Options =
1818
'[ "appname" ::: Text
1919
, "db" ::<
20-
'[ "username" ::: Text
20+
'[ "username" ::: Text
2121
, "password" ::: Text
2222
]
2323
]
@@ -38,8 +38,6 @@ module Loot.Config.Env
3838
-- * Parsing individual values
3939
, FromEnv (..)
4040
, Parser
41-
, noValue
42-
, withPresent
4341
, parseStringEnvValue
4442
, autoParseEnvValue
4543
, parseBoundedNumEnvValue
@@ -77,7 +75,7 @@ import Loot.Config.Record ((::+), (::-), (:::), (::<), ConfigKind (Partial), Con
7775
-- | A complete description of parsing error.
7876
data EnvParseError = EnvParseError
7977
{ errKey :: Text
80-
, errValue :: Maybe String
78+
, errValue :: String
8179
, errMessage :: Text
8280
} deriving (Show, Eq)
8381

@@ -90,8 +88,7 @@ instance Buildable EnvParseError where
9088
-- My idea is to add "isSecure" flag to 'EnvValue' typelass to resolve
9189
-- this problem.
9290
"Failed to parse an environment variable \
93-
\"+|errKey|+"="+|maybe "-" build errValue|+"\
94-
\: "+|errMessage|+""
91+
\"+|errKey|+"="+|errValue|+": "+|errMessage|+""
9592

9693
-- | Pretty-print a 'EnvParseError'.
9794
parseErrorPretty :: EnvParseError -> String
@@ -102,42 +99,27 @@ instance Exception EnvParseError where
10299

103100
-- | Parser for an environment variable.
104101
--
105-
-- Use 'fail' to report parsing errors, and 'noValue' to indicate
106-
-- that value is left uninitialized.
102+
-- Use 'fail' to report parsing errors.
107103
newtype Parser a = Parser
108-
{ runParser :: MaybeT (Except Text) a
109-
-- ^ Parsing result, Maybe layer designates result presence, while Except
110-
-- layer contains parsing errors.
104+
{ runParser :: Except Text a
105+
-- ^ Parsing result, Except layer contains parsing errors.
111106
} deriving (Functor, Applicative, Monad)
112107

113108
instance MonadFail Parser where
114-
fail = Parser . lift . throwError . fromString
115-
116-
-- | Leave value uninitialized in config.
117-
noValue :: Parser a
118-
noValue = Parser mzero
109+
fail = Parser . throwError . fromString
119110

120111
-- | Describes a way to parse an item appearing in config.
121112
class FromEnv a where
122113
-- | Parse a variable value.
123-
parseEnvValue :: Maybe String -> Parser a
124-
125-
-- | Apply the given parser to an environment value if it present,
126-
-- otherwise leave the configuration option uninitialized.
127-
--
128-
-- This is what most parsers usually do.
129-
withPresent :: (String -> Parser a) -> Maybe String -> Parser a
130-
withPresent parser = \case
131-
Nothing -> noValue
132-
Just val -> parser val
114+
parseEnvValue :: String -> Parser a
133115

134116
-- | Value parser based on 'IsString' instance.
135-
parseStringEnvValue :: IsString a => Maybe String -> Parser a
136-
parseStringEnvValue = withPresent $ pure . fromString
117+
parseStringEnvValue :: IsString a => String -> Parser a
118+
parseStringEnvValue = pure . fromString
137119

138120
-- | Value parser based on 'Read' instance.
139-
autoParseEnvValue :: Read a => Maybe String -> Parser a
140-
autoParseEnvValue = withPresent $ \arg -> case reads arg of
121+
autoParseEnvValue :: Read a => String -> Parser a
122+
autoParseEnvValue arg = case reads arg of
141123
-- This is similar to what e.g. optparse-applicative does,
142124
-- the errors from 'eitherRead' are not too beautiful
143125
[(r, "")] -> return r
@@ -148,17 +130,17 @@ autoParseEnvValue = withPresent $ \arg -> case reads arg of
148130
-- | Value parser for numbers with overflow checks.
149131
parseBoundedNumEnvValue
150132
:: forall a. (Bounded a, Integral a)
151-
=> Maybe String -> Parser a
133+
=> String -> Parser a
152134
parseBoundedNumEnvValue val = do
153135
int <- autoParseEnvValue @Integer val
154136
if | int < fromIntegral (minBound @a) -> fail "Numeric underflow"
155137
| int > fromIntegral (maxBound @a) -> fail "Numeric overflow"
156138
| otherwise -> pure (fromIntegral int)
157139

158140
-- | Value parser based on 'Aeson.FromJSON' instance.
159-
aesonParseEnvValue :: Aeson.FromJSON a => Maybe String -> Parser a
141+
aesonParseEnvValue :: Aeson.FromJSON a => String -> Parser a
160142
aesonParseEnvValue =
161-
withPresent $ either fail pure . Aeson.eitherDecode . encodeUtf8
143+
either fail pure . Aeson.eitherDecode . encodeUtf8
162144

163145
-- | Options which define the expected format of environment variables.
164146
data ParseOptions = ParseOptions
@@ -221,12 +203,12 @@ instance
221203
(:&) <$> fmap ItemOptionP parseOption <*> envParser options env path
222204
where
223205
parseOption :: Either EnvParseError (Maybe v)
224-
parseOption =
206+
parseOption = do
225207
let key = mkEnvKey @l options path
226-
mvalue = Map.lookup key env
227-
in first (EnvParseError key mvalue) $
228-
runExcept . runMaybeT $
229-
runParser (parseEnvValue mvalue)
208+
let mvalue = Map.lookup key env
209+
forM mvalue $ \value ->
210+
first (EnvParseError key value) $
211+
runExcept . runParser $ parseEnvValue value
230212

231213
gatherRequired options path _ =
232214
Endo (mkEnvKey @l options path :) <>
@@ -383,21 +365,12 @@ instance Fixed.HasResolution a => FromEnv (Fixed.Fixed a) where
383365
parseEnvValue = autoParseEnvValue
384366

385367
instance FromEnv Bool where
386-
parseEnvValue = withPresent $ \case
368+
parseEnvValue = \case
387369
"0" -> pure False
388370
"1" -> pure True
389371
(map toLower -> "false") -> pure False
390372
(map toLower -> "true") -> pure True
391373
_ -> fail "Invalid boolean"
392374

393-
-- | Parses to @Nothing@ when value is not provided.
394-
-- Never leaves config value uninitialized.
395-
--
396-
-- Note that if env variable is defined but empty, it will be parsed anyway.
397-
instance FromEnv a => FromEnv (Maybe a) where
398-
parseEnvValue = \case
399-
Nothing -> pure Nothing
400-
Just val -> parseEnvValue (Just val)
401-
402375
instance FromEnv Aeson.Value where
403376
parseEnvValue = aesonParseEnvValue

code/config/test/Test/Loot/Config/Env.hs

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ newtype Option1 = Option1 String
2323
deriving (Eq, Ord, Show, IsString, Generic, FromJSON)
2424

2525
instance FromEnv Option1 where
26-
parseEnvValue = withPresent $ \arg ->
26+
parseEnvValue val =
2727
maybe (fail "Wrong prefix") (pure . Option1) $
28-
L.stripPrefix "Mem " arg
28+
L.stripPrefix "Mem " val
2929

3030
type Fields =
3131
'[ "int" ::: Int
@@ -38,10 +38,6 @@ type SubFields =
3838
, "option1" ::: Option1
3939
]
4040

41-
type OptionalFields =
42-
'[ "option" ::: Maybe Int
43-
]
44-
4541
test_envParsing :: [TestTree]
4642
test_envParsing =
4743
[ testCase "Can parse simple config successfully" $ do
@@ -60,20 +56,19 @@ test_envParsing =
6056
, testCase "Parsing errors works" $
6157
parseEnvPure @SubFields [("OPTION1", "text")]
6258
@?= Left EnvParseError
63-
{ errKey = "OPTION1", errValue = Just "text"
59+
{ errKey = "OPTION1", errValue = "text"
6460
, errMessage = "Wrong prefix" }
6561

6662
, testCase "Number parser does not allow overflow" $
6763
(parseEnvPure @Fields [("INT", replicate 20 '9')]
6864
& first errMessage)
6965
@?= Left "Numeric overflow"
7066

71-
, testCase "Can parse no value to Nothing" $ do
67+
, testCase "Absent value is not present in the config" $ do
7268
let cfg =
73-
either (error . show) id . finalise $
7469
either (error . fmt . build) id $
75-
parseEnvPure @OptionalFields []
76-
cfg ^. option #option @?= Nothing
70+
parseEnvPure @Fields []
71+
cfg ^. option #int @?= Nothing
7772
]
7873

7974
unit_requiredEnvVars :: Assertion

0 commit comments

Comments
 (0)