References
Three kinds of reference bring values in from elsewhere — variables, the environment and other keys — and they share one syntax for defaults, guards, indexes and filters. Tags and resolvers convert and fetch.
Three kinds
| Form | Reads | Resolved |
|---|---|---|
@{name} | a variable declared with @name = … | against all variables of the load |
${NAME} | an environment variable | while loading |
%{a.b.c} | another key of the configuration | against the final, merged tree |
A reference that is the whole value keeps the type of what it reads — %{server.port} is an integer. Inside a double-quoted string it is interpolated as text.
Values in a string
A value interpolated into a string reads as CASC writes it, in every implementation: nil, true, inf; an atom as its name; a duration in nanoseconds and a byte size in bytes; an address in canonical form; a float as the shortest digits that read back as the same float, always with a fraction. A list, a tuple or a map has no string form, and interpolating one is a load error.
@d = 500ms
@b = 2KiB
@f = 1000.0
@i = inf
@a = info
@ip = 10.0.0.0/8
s = "@{d} @{b} @{f} @{i} @{a} @{ip}"%{"s" => "500000000ns 2048B 1.0e3 inf info 10.0.0.0/8"}Defaults, guards and indexes
All three kinds take the same suffixes. Without one, a reference to something undefined is a load error.
| Suffix | Meaning |
|---|---|
@{x:default} | use default if undefined |
@{x:+alt} | alt if defined, otherwise "" |
@{x:?"message"} | fail the load with this message if undefined |
@{x[1]} | index into a list (from 0), combinable with a default |
@{x | trim} | apply filters, left to right, after the suffix |
There is deliberately no shell-style :-default. A default is parsed as a CASC value, so ${PORT:8080} falls back to the integer 8080. :+ always introduces a substitute: ${PORT:+1} substitutes 1, and a default of minus one is ${PORT:-1}. The message after :? is an ordinary double-quoted string and may interpolate.
Variables
@name = value declares a public variable, visible to every file of the load — the files that import this one and the files it imports. @*name declares a private one, visible only in its own file; it shadows a public variable of the same name there.
@hosts = ["x", "y"]
@*suffix = ".internal"
primary = "@{hosts[0]}@{suffix}"
second = @{hosts[1]}
all = @{hosts}
mode = @{mode:"standalone"}
note = @{suffix:+"private suffix set"}%{"primary" => "x.internal", "second" => "y", "all" => ["x", "y"],
"mode" => "standalone", "note" => "private suffix set"}Variables are not in the result. They resolve against the whole load, not in reading order: a variable may be used above its declaration, and if it is declared twice the last declaration wins.
Environment
An unset or empty environment variable counts as undefined. What the environment supplies is always a string; a tag converts it. A reference ending in [] splits the value on , and ; into a list.
region = ${REGION}
retries = !int(${MAX_RETRIES:3})
hosts = ${HOSTS[]}
second = ${HOSTS[1]}
allowed = ${ALLOWED_HOSTS[]:["localhost"]}%{"region" => "eu-west", "retries" => 3, "hosts" => ["a", "b", "c"],
"second" => "b", "allowed" => ["localhost"]}Conditional statements
${?NAME} on a line of its own keeps the next statement only if NAME is set and not empty — exactly one statement, which may be a whole block.
${?ENABLE_METRICS}
metrics {
port = 9568
path = "/metrics"
}The current environment
${COOPER_ENV} is always set, in every implementation, so one document selects per-environment files the same way everywhere: import "env/${COOPER_ENV}.casc". The names are dev, staging, test and prod. A real COOPER_ENV wins and is used exactly as written. Unset or empty, each implementation falls back to its host’s own variable — MIX_ENV, PRX_ENV, APP_ENV, NODE_ENV — or else dev, and maps that value onto the shared names:
| Host value | COOPER_ENV |
|---|---|
development, local | dev |
testing | test |
production | prod |
anything else (dev, staging, test, prod, …) | unchanged |
Config references
%{path} reads another key. It resolves last, against the finished tree — after every import, override and sigil — so it sees the value the key finally has, wherever in the files that was set. A cycle of references is a load error.
health.url = "http://%{server.host}:%{server.port}/health"
server.host = "api.internal"
server.port = 8080
admin = %{contact.admin:"ops@example.com"}%{"health" => %{"url" => "http://api.internal:8080/health"},
"server" => %{"host" => "api.internal", "port" => 8080},
"admin" => "ops@example.com"}A path is followed through a reference: a key that holds a reference holds what it resolves to, so a path may run on into it. A path through its own key (a = %{a.b}) is a cycle.
origins.eu { code = "eu", host = "eu.example.com" }
origin = %{origins.eu}
code = %{origin.code}%{"origins" => %{"eu" => %{"code" => "eu", "host" => "eu.example.com"}},
"origin" => %{"code" => "eu", "host" => "eu.example.com"},
"code" => "eu"}Filters
Filters clean up a string on its way in. The set is closed: trim, downcase, upcase, trim_prefix: "…" and trim_suffix: "…". Anything else is a load error, as is a filter applied to a non-string. A secret can be filtered and stays secret. Single-quoted arguments make filters usable inside a double-quoted string.
scheme = ${SCHEME | trim_suffix: "://" | downcase}
region = ${REGION:" us-east-1 " | trim}
endpoint = "${SCHEME | trim_suffix: '://' | downcase}://api.internal"%{"scheme" => "https", "region" => "us-east-1",
"endpoint" => "https://api.internal"}Built names
The name inside a reference may itself be a double-quoted string with interpolation. That is how a loop reads one environment variable per element. There is deliberately no concatenation operator besides this.
@which = "HOST"
host = ${"APP_@{which}"} # reads APP_HOST
@ids = ["1", "2"]
for @id in @{ids} as tokens {
"@{id}" = ${"TOKEN_@{id}"} # reads TOKEN_1, TOKEN_2
}Tags and resolvers
A tag !name(value) converts its one argument; tags nest. Built in are !int, !float, !bool, !duration, !bytes, !trim, !downcase, !upcase and !module. A failed conversion is a load error.
port = !int(${PORT:"8080"})
debug = !bool(${DEBUG:"false"})
ttl = !duration("5m")
cache = !bytes("512MiB")%{"port" => 8080, "debug" => false,
"ttl" => %Cooper.Duration{nanoseconds: 300000000000},
"cache" => %Cooper.ByteSize{bytes: 536870912}}!bool reads true, 1, yes and on as true and false, 0, no and off as false — lower case only — and refuses anything else, so DEBUG=1 works as it does in most .env files.
A resolver !{name:payload} fetches a value from somewhere else — a secret store, a parameter service. CASC ships none: the application registers each one, and so decides exactly what a configuration file is allowed to reach. The same holds for further tags and for import schemes.
*password = !{vault:secret/db/password}Modules
!module("Name") names a module of the host language, often one chosen per deployment through ${…}. The name is written the same way for every implementation: dot-separated segments, each in PascalCase. Anything else — crypto, Foo.bar, stripe_client — is a load error. Each implementation first looks the name up in a modules mapping the application passes, then applies its own convention. The module is named, never loaded or checked.
client = !module("Acme.Payments")
label = "x-!module(\"Foo.Bar\")"%{"client" => Acme.Payments, "label" => "x-Foo.Bar"}| Written | Elixir | Praxis | PHP | Node.js |
|---|---|---|---|---|
Acme.Payments.StripeClient | Acme.Payments.StripeClient | acme.payments.stripe-client | Acme\Payments\StripeClient | mapping required |
ASCO.HTTPClient | ASCO.HTTPClient | asco.http-client | ASCO\HTTPClient | mapping required |
Node.js has no convention to translate into — a Node module is a location, not a name — so there every module needs an entry in the mapping. It is also how Elixir reaches an Erlang module: %{"Crypto" => :crypto}. A mapped module interpolated into a string reads as what it maps to — crypto here, not Crypto — because the mapping is the application’s own.