Cooper

Warning

Under heavy development: not for production use before 1.0.0. Things can and will break, and the specification can and will change, between any two releases until 1.0.0. Do not use this library in a production system yet.

Cooper loads CASC config files — a hierarchical, extensible config language with imports, variables, string interpolation, environment/config references, loops, and consumer-registered resolvers and tags — into native Elixir terms.

# config.casc
#@version = 1.0

@region = "eu-west"

server {
  host = "0.0.0.0"
  port = 8080
}

database {
  *password = ${DB_PASSWORD}
  host = "db.@{region}.internal"
  pool_size = 10
  timeout = 500ms
}
iex> Cooper.load_file("config.casc", env: %{"DB_PASSWORD" => "hunter2"})
{:ok,
 %{
   "server" => %{"host" => "0.0.0.0", "port" => 8080},
   "database" => %{
     "password" => [~~REDACTED~~],
     "host" => "db.eu-west.internal",
     "pool_size" => 10,
     "timeout" => %Cooper.Duration{nanoseconds: 500_000_000}
   }
 }}

Maps come back with string keys, durations/byte sizes as %Cooper.Duration{nanoseconds: n}/%Cooper.ByteSize{bytes: n} structs, infinity as %Cooper.Infinity{}, tuples as real Elixir tuples (never coerced to lists, CASC.md §6.11), and any *key-prefixed value wrapped in Cooper.Secret — redacted by inspect/1 and to_string/1 by default; call Cooper.Secret.reveal/1 to get the real value back.

Why use Cooper?

Configuration starts as a file of values and grows: a host written in three places, a port that differs per environment, a password that must never reach a log, a second file for what two services share. CASC is a configuration language for that point. It stays data, not a program, and adds the few things configuration keeps needing:

Compared with other configuration languages

JSON YAML TOML INI / .env CASC
Comments no yes yes yes yes
References to other keys no anchors, within one document no some .env loaders expand ${VAR} %{path}, against the merged result
Environment variables no left to the loader no it is the environment ${NAME}, with defaults, checks and filters
Imports no left to the loader no no files, globs, custom schemes
Values beyond strings and numbers no dates, and implicit typing (no can become false) dates and times no durations, sizes, addresses, dates, tuples
Secrets no no no no marked with *, redacted

HCL comes closest in expressiveness, with expressions and interpolation, but much of what makes it useful -- variables, modules, sensitive -- belongs to the tool that embeds it, Terraform above all. Jsonnet, Dhall, CUE and Pkl go further than CASC: they are programmable, with functions and, in CUE, Dhall and Pkl, types and schemas. CASC deliberately stops short of that. It has loops, but no functions and no schemas, so a document stays something a person reads top to bottom.

Compared with each language's usual configuration

Language Usually With Cooper
Elixir config/*.exs: Elixir code, split between compile time and runtime.exs, reading the environment with System.get_env/1 cooper reads CASC; cooper_config replaces Mix's config sources -- config/runtime.exs through Config.Provider in a release
Node JSON or YAML files and dotenv, often under a library such as config or convict @joetjen/cooper reads CASC; @joetjen/cooper-config loads it once at startup; @joetjen/cooper-fastify and @joetjen/cooper-nestjs hand it to Fastify and to @nestjs/config
PHP PHP arrays and YAML: Laravel's config/*.php calling env(), Symfony's config/packages/*.yaml with %env()% joetjen/cooper reads CASC; joetjen/cooper-config loads it once at startup; joetjen/cooper-laravel replaces config/*.php, joetjen/cooper-symfony replaces bundle configuration and container parameters
Praxis CASC is its configuration language: config/config.casc cooper reads it; cooper_prx loads it before the program starts and hands it to praxis.app

Every one of them also reads .env files, the same way. So a service in PHP and a worker in Node can share one configuration document and read it identically.

When something else fits better

If a handful of values is all there is, .env alone is enough. If configuration has to be computed -- functions, generated structures -- or checked against a schema before it is used, Jsonnet, CUE, Dhall or Pkl do what CASC does not try to.

How it fits together

Loading a file runs one pipeline, each stage handing off to the next:

  1. Lex + parse — Cooper.Grammar and Cooper.Actions turn CASC source into a flat list of assignments and variable declarations, with every literal value (numbers, durations, dates, strings, ...) already converted to its native Elixir form.
  2. Loop expansion — Cooper.Loop expands each for statement (CASC.md §5.5) into the ops it generates.
  3. Import resolution — Cooper.Loader recursively loads each import statement (CASC.md §5.1), splicing the imported file's own ops into the importer's list and propagating its public variables.
  4. Merge and secret-wrapping — Cooper.Merge folds the flattened ops into a tree, honoring the merge-control sigils (~/+/-, CASC.md §5.7/§8), and wraps every *key-prefixed value in Cooper.Secret so it can't leak through an accidental inspect/log call.
  5. Reference resolution — Cooper.Resolver resolves everything left unresolved: @{} variables, ${} environment reads, %{} config references, !{resolver:...} dispatch, and !Name(...) tagged values (CASC.md §7) — including re-wrapping a secret that gets copied elsewhere by one of those references, so the redaction travels with the value, not just its original declared path.

See the tutorial for a full walkthrough of both the library and the CASC syntax it parses, or the cheatsheet for a terse API reference.

.env files

${...} reads pick up .env/.env.<env>/.env.local from the project root automatically — on by default, no option needed — through dotenvy, which Cooper depends on.

The real OS environment outranks those files, and an explicit :env option outranks everything (but only for the names it defines — anything else still falls through). A deployment sets variables in the environment it controls, and a file sitting in the working directory must not silently beat them; this is also what Ruby's and Node's dotenv do by default. Pass dotenv_override: true if you want the files on top instead, which is occasionally handy locally to shadow something exported in your shell.

See the tutorial's §11 for the full layering rules.

Caching

Cooper.load_file/2 caches everything up to the final ${...}-resolution step by default, keyed by file mtime (imports included) — a repeat call for an unchanged file skips re-parsing entirely, while ${...} values themselves are always re-resolved fresh, hit or miss. Pass cache: false to opt out for one call. It emits :telemetry events too — [:cooper, :cache, :file_changed] whenever a cached file's fingerprint changes, and [:cooper, :cache, :env_changed] (on by default for any file that references ${...} at all, via watch_env) when an actually-referenced ${NAME} changes, a real System.put_env/2 or a .env file edit either one. See the tutorial's §12 for the invalidation rules and Cooper.Cache for the module backing it.

Installation

Add cooper to your list of dependencies in mix.exs. ichor comes along as its own dependency automatically — no separate line needed:

def deps do
  [
    {:cooper, "~> 0.7.0"}
  ]
end

Where to go next

Development

mix deps.get
mix precommit

mix precommit runs everything a change needs to pass before review — formatting, --warnings-as-errors compilation, Credo, Sobelow, the test suite, and Dialyzer, in that order. See CONTRIBUTING.md for how to propose changes, and CHANGELOG.md for release history.

License

Apache-2.0 — see LICENSE. Releases before 0.5.0 were MIT.