figment-0.2.0.0: README.md
# figment
Layered configuration for Haskell, after Rust's
[`figment`](https://docs.rs/figment): combine files (JSON, TOML, YAML),
environment variables, command-line arguments and Haskell values; select
profiles; decode into your own types. Every value remembers where it came
from, so when something is wrong you get **every** problem at once, each
pointing at the key, the name you would actually type (`APP_PORT`,
`--port`), the file, and the line and column:
```text
4 configuration errors:
error: missing field `name`
--> key `name`
= hint: no source sets it; it can be set as `name` in TOML source string, or `APP_NAME` in environment variables (prefix APP_)
error: unknown field `hots`
--> key `db.hots` in TOML source string at line 4, column 8
= hint: did you mean `host`?
error: invalid value: found integer 70000, expected an integer in the range 0..65535
--> key `db.port` in TOML source string at line 3, column 8
error: unknown variant `verbose`, expected one of `Debug`, `Info`
--> key `level` (`APP_LEVEL`) in environment variables (prefix APP_)
```
(That exact output is pinned by a golden test.)
## Quick start
```haskell
{-# LANGUAGE DeriveAnyClass, DeriveGeneric, DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings, TypeApplications #-}
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import Data.Function ((&))
import Data.Text (Text)
import GHC.Generics (Generic)
import qualified Figment as F
data Config = Config
{ name :: Text
, port :: Int
, debug :: Bool
}
deriving stock (Show, Generic)
deriving anyclass (F.FromConfig)
figment :: F.Figment
figment =
F.new
& F.merge (F.defaults (Aeson.object ["port" .= (8080 :: Int), "debug" .= False]))
& F.merge (F.file F.toml "App.toml") -- optional; searched for upwards
& F.merge (F.prefixed "APP_") -- APP_PORT=9000, APP_DEBUG=true
main :: IO ()
main = F.loadExtractOrThrow @Config figment >>= print
```
Import `Figment` qualified: it exports `merge`, `join` and `from`. The
README examples are compiled and run by the test suite.
**New to figment?** Start with the tutorial,
[`Figment.Tutorial`](https://hackage.haskell.org/package/figment/docs/Figment-Tutorial.html):
sixteen short sections covering layering, environment variables, the
command line, profiles, errors, decoding, provenance, partial
configurations, library patterns, custom providers and testing. It also
has a cheat sheet for Rust figment users and a guide for upgrading from
0.1.
## Concepts
**Providers** produce configuration, one dictionary per **profile**. A
**`Figment`** is a pure description: an ordered list of providers, each
combined with one of four strategies (the same as Rust figment):
| strategy | on a conflicting key | arrays |
|-----------|----------------------|-------------|
| `merge` | incoming value wins | replaced |
| `join` | existing value wins | kept |
| `admerge` | incoming value wins | concatenated|
| `adjoin` | existing value wins | concatenated|
Dictionaries always merge key by key. Building a figment does no IO.
`load` runs every provider, collects **every** failure (an unreadable
file and a syntax error elsewhere are reported together), and combines
the results. `Figment` is a lawful `Monoid`, so a library can publish its
defaults as a `Figment` and an application can append overrides with
`<>`. (The value-level merge is *not* associative, so values have no
`Semigroup`; the laws are property-tested.)
**Profiles.** On extraction, the `Default` profile is merged with the
selected profile, and the `Global` profile is merged over both. Select
with `F.select "prod"`, or read the choice from the environment with
`F.profileFromEnv "APP_PROFILE" F.Default`. With `F.nested`, a file's
top-level keys are profiles:
```toml
[default]
workers = 4
[prod]
workers = 32
```
## Providers
| provider | notes |
|---|---|
| `F.file fmt path` | optional; a relative path is searched for in the working directory and its parents. `F.fileExact` does not search; `F.required` makes absence an error; `F.nested` maps top-level keys to profiles |
| `F.fromText fmt text` | configuration held in memory |
| formats `F.json`, `F.toml`, `F.yaml` | each parser enforces depth and size limits *while parsing*, rejects duplicate keys, and (TOML, YAML) records line and column for every value |
| `F.prefixed "APP_"` | environment variables, Rust semantics: no splitting unless `Env.split "__"`; values are typed (`8080`, `true`, `[1, 2]`, `{a = 1}`) while keeping the original text (so a `Text` field gets `007`, not `7`); conflicts are errors; see `Figment.Provider.Env` for `only`, `ignore`, `mapKeys`, `global`, ... |
| `F.cli args` | `--key=value`, `--flag`, `--no-flag`, `--db.port=1`, `--` ends options; see `Figment.Provider.Cli` |
| `F.defaults x`, `F.globals x`, `F.keyed "db.port" x` | any `ToJSON` value; errors point at the Haskell source line |
| a `Figment` | figments nest, keeping the provenance of every inner value |
| your own | implement the three-method `Provider` class |
Format details: TOML dates and times arrive as ISO 8601 strings and
decode straight into `Day`, `LocalTime`, `ZonedTime`, `UTCTime`; `inf`
and `nan` are kept as non-finite numbers. YAML uses the 1.2 core schema
(`no` and `on` stay strings, so there is no "Norway problem"), supports
anchors, aliases and merge keys (`<<`), and charges alias expansion to
the node limit, which defeats "billion laughs". Invalid UTF-8 is an
error, never silently replaced.
## Decoding
`FromConfig` is figment's own decoding class. It reads the value tree
directly, which is how errors know their source and position. Derive it
with `Generic`:
* records are dictionaries; `Maybe` fields are optional;
* enums are strings, matched case-insensitively; unknown variants list
the valid ones;
* other sum types are externally tagged (`{ Circle = { radius = 1 } }`);
* newtypes unwrap and positional constructors are arrays.
`genericDecoder defaultOptions { fieldLabelModifier = snakeCase,
rejectUnknownFields = True }` changes the key names and reports unknown
keys with suggestions. Hand-written decoders use an **accumulating**
applicative (`field`, `optionalField`, `fieldOr`, `validate`, ...). It
deliberately has no `Monad` instance, so independent errors are never
hidden; `andThen` gives you sequencing when you need it.
Strict by default: a string is not a number. `F.extractLossy` accepts
`"8080"` for numbers and `"yes"` for booleans (Rust's `extract_lossy`).
Magic types: `Tagged a` carries the value's provider, profile, position
and path. `RelativePath` resolves a path written in a file against
*that file's* directory. `ViaAeson a` reuses an existing `FromJSON`
instance, and aeson's error path is translated so it still points at
the right key and line.
Queries on a loaded configuration: `findValue`, `contains`,
`findMetadata`, `extractAt` (Rust: `extract_inner`), `focus`,
`reselect`.
## Partial configurations: no missing field, by construction
Write the record once, parameterised by a functor:
```haskell
data ServerF f = Server
{ host :: F.Field f Text
, listen :: F.Field f Int
, workers :: F.Field f Int
}
deriving stock (Generic)
instance F.Partial ServerF
type Server = ServerF F.Identity -- ordinary fields: host :: Text
serverDefaults :: ServerF Maybe
serverDefaults = (F.emptyPartial @ServerF) {listen = Just 8080, workers = Just 4}
-- given a loaded configuration `loaded :: F.Loaded`, every missing field
-- is reported at once, each with the env var, CLI flag, or file key
-- that would have set it:
--
-- F.extractPartial serverDefaults loaded :: Either F.ConfigErrors Server
```
`F.extractTotal` takes *total* defaults (`ServerF Identity`). Its type
guarantees that a missing-field error cannot happen; only type errors in
the values that are present remain. Nested partial records are
supported, and `overlay`, `complete` and `total` are available for your
own layering.
## Safety
* **Limits** (`F.defaultLimits`: depth 64, one million values; override
with `F.withLimits`) are enforced inside every parser, so a hostile
`[[[[...` megabyte is rejected at depth 65 without being built.
* No exception escapes `load`: I/O and parse failures are errors, and a
synchronous exception thrown by a third-party provider becomes an
`IoError` (asynchronous exceptions are re-thrown, as they must be).
* `ConfigErrors` is an `Exception` whose `displayException` is the
rendered report.
## Testing your configuration
`Figment.Jail.withJail` runs a test in a fresh temporary working
directory with a snapshot of the environment, restores both afterwards,
and serialises jails process-wide, so jailed tests are safe under a
parallel runner. `Env.fromList` gives an environment provider fixed
variables, with no global state at all.
## Compared with Rust figment
Feature parity: providers, the four strategies, profiles (default,
global, nested, selection), metadata with interpolated key names and the
code location where each provider was added, `Tagged`, `RelativePath`,
lossy extraction, `extract_inner`/`focus`/`find_value`/`contains`, a
test jail, and figments as providers.
Beyond it:
* all errors are reported at once, not one per run;
* TOML and YAML errors and values carry line and column;
* missing-field hints list the exact env var, flag and key to set;
* unknown fields come with "did you mean" suggestions;
* `extractTotal` guarantees, through its type, that no field is missing;
* resource limits are enforced inside the parsers, YAML alias bombs are
defeated, and the YAML 1.2 core schema avoids the Norway problem;
* conflicting env vars or flags are errors, never silently resolved;
* the builder is a lawful monoid with property-tested laws.
## Building
```bash
cabal build all
cabal test
bash scripts/check-compile-fail.sh # the compile-time guarantees
cabal bench
```
Supported compilers: GHC 9.10 and 9.12.
Version 0.2.0.0 is a breaking redesign of 0.1; see the changelog. The
source distribution also contains the design record: `ADRs/` (decisions
016 onwards apply to 0.2) and `docs/audit-remediation.md` (each problem
found in 0.1, how it was fixed, and the test that pins it).
License: BSD-3-Clause.