Bulletproof App Settings with Malli & Aero
Aero is a great library for pulling external application settings into your application, but even if you don’t go overboard with what you put in your config files, it has weaknesses. But pairing it together with Malli we have a recipe for bulletproof application settings; let’s take a look at how they can work together…
Let’s see some code
First let’s see how we can read app settings using the two libraries, then we’ll dig into the detail of what the benefits are.
Given an Aero config.edn
file that looks like
{:http-port
#env PORT
:db-url
#profile
{:default [#env DATABASE_URL]
:dev "some-db-url"}
:db-password
#profile
{:default [#env DATABASE_URL]
:dev "some-db-url"}
:auth-client
#profile
{:default #or [#env AUTH_CLIENT :okta]
:dev #or [#env AUTH_CLIENT :dummy]}
:okta-client-id
#profile
{:default [#env OKTA_CLIENT_ID]
:dev "dev-client-id"}
:metrics-enabled
#profile
{:default [#env METRICS_ENABLED]
:dev #or [#env METRICS_ENABLED false]}}
{:http-port #env PORT
:db-url #profile {:default #env DATABASE_URL
:dev #or [#env DATABASE_URL
"some-db-url"]}
:db-password #profile {:default [#env DATABASE_URL]
:dev "some-db-url"}
:auth-client #profile {:default #or [#env AUTH_CLIENT :okta]
:dev #or [#env AUTH_CLIENT :dummy]}
:okta-client-id #profile {:default [#env OKTA_CLIENT_ID]
:dev "dev-client-id"}
:metrics-enabled #profile {:default [#env METRICS_ENABLED]
:dev #or [#env METRICS_ENABLED false]}}
We can combine it with the following Clojure code - making use of Malli - like so:
(require '[aero.core :as aero]
'[clojure.string :as str]
'[clojure.java.io :as io]
'[malli.core :as m]
'[malli.error :as me]
'[malli.transform :as mt]
'[malli.util :as mu])
(def non-blank-string
[:and
[:string {:min 1}]
[:fn {:error/fn
(constantly "must not be blank")}
(fn not-blank? [s]
(not (str/blank? s)))]])
(def Settings
"A Malli schema representing all
the application settings;
i.e. values that can be set externally
at runtime via environment variables."
(let [schema-common
[:map
[:http-port
{:default 8080}
:int]
[:db-url non-blank-string]
[:db-password non-blank-string]
[:auth-client
{:default :okta}
[:enum :okta :dummy]]
[:metrics-enabled
{:default false}
[:boolean]]]]
;; Multi-schema; add in different
;; extra fields depending on
;; auth client type
[:multi
{:dispatch :auth-client
:decode/string
(fn [string-settings]
(update
string-settings
:auth-client
#(or (some-> % name
str/lower-case
keyword)
:okta)))}
[:okta
(mu/merge
schema-common
[:map
[:okta-client-id non-blank-string]])]
[:dummy schema-common]]))
(defn decode-string-settings
[string-settings]
(m/decode Settings
string-settings
(mt/transformer
mt/string-transformer
(mt/default-value-transformer))))
(defn coerce-and-validate-string-settings
"Parses a raw map of string-based
settings, coercing the values into
their respective types and throwing
an exception on any validation errors."
[string-settings]
(let [settings
(decode-string-settings
string-settings)
settings-errors
(me/humanize (m/explain Settings
settings))]
(if settings-errors
(throw
(Exception.
(format "Invalid settings: %s"
settings-errors)))
settings)))
(defn read-config
([]
(read-config :default))
([profile]
(let [string-settings
(-> (io/resource "config.edn")
(aero/read-config
{:profile profile}))]
(coerce-and-validate-string-settings
string-settings))))
(require '[aero.core :as aero]
'[clojure.string :as str]
'[clojure.java.io :as io]
'[malli.core :as m]
'[malli.error :as me]
'[malli.transform :as mt]
'[malli.util :as mu])
(def non-blank-string
[:and
[:string {:min 1}]
[:fn {:error/fn (constantly "must not be blank")}
(fn not-blank? [s]
(not (str/blank? s)))]])
(def Settings
"A Malli schema representing all the application settings; i.e. values that can
be set externally at runtime via environment variables."
(let [schema-common
[:map
[:http-port :int]
[:db-url non-blank-string]
[:db-password non-blank-string]
[:auth-client {:default :okta} [:enum :okta :dummy]]
[:metrics-enabled {:default false} [:boolean]]]]
;; Multi-schema; add in different extra fields depending on auth client type
[:multi {:dispatch :auth-client
:decode/string
(fn [string-settings]
(update string-settings
:auth-client
#(or (some-> % name str/lower-case keyword)
:okta)))}
[:okta
(mu/merge schema-common
[:map
[:okta-client-id non-blank-string]])]
[:dummy schema-common]]))
(defn decode-string-settings [string-settings]
(m/decode Settings
string-settings
(mt/transformer mt/string-transformer
(mt/default-value-transformer))))
(defn coerce-and-validate-string-settings
"Parses a raw map of string-based settings, coercing the values into their
respective types and throwing an exception on any validation errors."
[string-settings]
(let [settings (decode-string-settings string-settings)
settings-errors (me/humanize (m/explain Settings settings))]
(if settings-errors
(throw (Exception. (format "Invalid settings: %s" settings-errors)))
settings)))
(defn read-config
([]
(read-config :default))
([profile]
(let [string-settings (-> (io/resource "config.edn")
(aero/read-config {:profile profile}))]
(coerce-and-validate-string-settings string-settings))))
With this in place, we can easily ready our config and get the coerced values we want:
(read-config :dev)
;; =>
{:http-port 8080,
:db-url "some-db-url",
:db-password "password123"
:auth-client :dummy,
:okta-client-id "dev-client-id",
:metrics-enabled false}
While automatically validating incorrect inputs:
(coerce-and-validate-string-settings
{:http-port "foo"
:db-url "some-db-url"
:db-password "password123"
:okta-client-id "okta-client-123"
:metrics-enabled "bar"})
;; =>
;; Invalid settings: {:http-port ["should be an integer"],
;; :metrics-enabled ["should be a boolean"]}
Why bother?
Aero already offers to do some coercion for us, so what do we gain here over using just using Aero, like so?
{:http-port
#long
#profile
{:default [#env PORT]
:dev #or [#env PORT 8080]}
:db-url
#profile
{:default #env DATABASE_URL
:dev #or [#env DATABASE_URL
"some-db-url"]}
:auth-client
#profile
{:default #or [#env AUTH_CLIENT :okta]
:dev #or [#env AUTH_CLIENT :dummy]}
:okta-client-id
#profile
{:default [#env OKTA_CLIENT_ID]
:dev "dev-client-id"}
:metrics-enabled
#boolean
#profile
{:default [#env METRICS_ENABLED]
:dev #or [#env METRICS_ENABLED true]}}
{:http-port #long #profile {:default [#env PORT]
:dev #or [#env PORT 8080]}
:db-url #profile {:default [#env DATABASE_URL]
:dev "some-db-url"}
:db-password #profile {:default [#env DATABASE_PASSWORD]
:dev "postgres"}
:auth-client #keyword #profile {:default #or [#env AUTH_CLIENT :okta]
:dev #or [#env AUTH_CLIENT :dummy]}
:okta-client-id #profile {:default [#env OKTA_CLIENT_ID]
:dev "dev-client-id"}
:metrics-enabled #boolean #profile {:default [#env METRICS_ENABLED]
:dev #or [#env METRICS_ENABLED true]}}
This is probably fine to get started - after all, when creating a new project you probably want to focus on more interesting things than configuration.
But in a day and age where most of us are working on large fleets of microservices, with configuration passed down through multiple layers of abstraction, it can be worth taking some time making your application settings bulletproof.
Let’s take a look at what we can gain here.
Fail-fast on simple mistakes
These days it’s not so likely that we’ll be deploying jars straight onto servers; more likely, we have to contend with various layers such as Docker, Kubernetes and AWS Secrets Manager. This means that just one typo somewhere can potentially leave you with an application with a blank connection string, flailing away as it attempts to connect to a database or message broker. And if your logging and/or app healthchecks aren’t quite up to scratch, that broken service may not be so easy to find.
Using plain Aero, we don’t get any such validation for free. We could write such code ourselves, but why do that when Malli lets us do that far more succinctly?
For example, in our example code above we make sure that if the db-url
and/or
okta-client-id
are missing, we’ll know about it straight away as an exception
on app startup:
(coerce-and-validate-string-settings
{:http-port "8080"
:db-url ""
:db-password "password123"
:okta-client-id "okta-client-123"
:metrics-enabled "false"})
;; =>
;; Invalid settings:
;; {:db-url ["should be at least 1 character"
;; "must not be blank"]}
(coerce-and-validate-string-settings
{:http-port "8080"
:db-url ""
:db-password "password123"
:okta-client-id "okta-client-123"
:metrics-enabled "false"})
;; =>
;; Invalid settings: {:db-url ["should be at least 1 character"
;; "must not be blank"]}
We could guard against nil
or empty strings just by declaring db-url
in our
Malli schema as
[:db-url [:string {:min 1}]]
But by creating our own non-blank-string
schema via
(def non-blank-string
[:and
[:string {:min 1}]
[:fn {:error/fn
(constantly "must not be blank")}
(fn not-blank? [s]
(not (str/blank? s)))]])
(def non-blank-string
[:and
[:string {:min 1}]
[:fn {:error/fn (constantly "must not be blank")}
(fn not-blank? [s]
(not (str/blank? s)))]])
we guard against strings containing only whitespace, too.
Friendly error messages
Using Aero on its own would still catch certain errors such as type coercion issues - but the resulting error messages wouldn’t be nearly as helpful.
For example, compare a bad port number in plain Aero…
;; config.edn
{;; Not a valid long!
:http-port #long "foo"
:db-url "some-db-url"
:db-password "password123"
:auth-client #keyword "okta"
:okta-client-id "dev-client-id"
:metrics-enabled #boolean "false"}
(aero/read-config (io/resource "config.edn") {:profile :default})
;; =>
;; Unhandled java.lang.NumberFormatException
;; For input string: "foo"
;; config.edn
{:http-port #long "foo" ;; Not a valid long!
:db-url "some-db-url"
:db-password "password123"
:auth-client #keyword "okta"
:okta-client-id "dev-client-id"
:metrics-enabled #boolean "false"}
(aero/read-config (io/resource "config.edn") {:profile :default})
;; =>
;; Unhandled java.lang.NumberFormatException
;; For input string: "foo"
…with a bad port number when using Malli…
(coerce-and-validate-string-settings
{:http-port "foo"
:db-url "some-db-url"
:db-password "password123"
:okta-client-id "dev-client-id"
:metrics-enabled "false"})
;; =>
;; Unhandled java.lang.Exception
;; Invalid settings:
;; {:http-port ["should be an integer"]}
(coerce-and-validate-string-settings
{:http-port "foo"
:db-url "some-db-url"
:db-password "password123"
:okta-client-id "dev-client-id"
:metrics-enabled "false"})
;; =>
;; Unhandled java.lang.Exception
;; Invalid settings: {:http-port ["should be an integer"]}
We can now see which config value has the problem - much better!
Secure error messages
Another thing to note with Malli’s human-friendly error messages is that by default they don’t report the values in question. While you lose some convenience with this, they key thing is that it makes it safe to use across your entire config, which almost certainly will contain sensitive values such as passwords.
If you were to use Spec for example, most of it’s informative validation output also includes the complete value received. This is desirable in a development context, but not so much when your data potentially contains passwords. With Malli’s approach you can apply schemas to all your application settings without fear of leaking sensitive data.
Don’t fall at the first hurdle
Another issue with depending too much on Aero for picking up config issues is that it will fall over at the first issue it comes across:
;; config.edn
{;; Not a valid long!
:http-port #long "foo"
:db-url "some-db-url"
:db-password "password123"
:auth-client #keyword "okta"
:okta-client-id "dev-client-id"
;; Not a valid boolean!
:metrics-enabled #boolean "bar"}
(aero/read-config
(io/resource "config.edn")
{:profile :default})
;; =>
;; Unhandled java.lang.NumberFormatException
;; For input string: "foo"
;; config.edn
{:http-port #long "foo" ;; Not a valid long!
:db-url "some-db-url"
:db-password "password123"
:auth-client #keyword "okta"
:okta-client-id "dev-client-id"
:metrics-enabled #boolean "bar"} ;; Not a valid boolean!
(aero/read-config (io/resource "config.edn") {:profile :default})
;; =>
;; Unhandled java.lang.NumberFormatException
;; For input string: "foo"
Aero identified the bad HTTP port, but is still none the wiser about the bad boolean.
Making use of our Malli schema, however, tells us everything that’s gone wrong in one go:
(coerce-and-validate-string-settings
{:http-port "foo"
:db-url "some-db-url"
:db-password "password123"
:okta-client-id "dev-client-id"
:metrics-enabled "bar"})
;; =>
;; Invalid settings:
;; {:http-port ["should be an integer"],
;; :metrics-enabled ["should be a boolean"]}
(coerce-and-validate-string-settings
{:http-port "foo"
:db-url "some-db-url"
:db-password "password123"
:okta-client-id "dev-client-id"
:metrics-enabled "bar"})
;; =>
;; Invalid settings: {:http-port ["should be an integer"],
;; :metrics-enabled ["should be a boolean"]}
This helps avoid having to go through multiple fix-build-deploy-test cycles to sort out teething issues with config.
Easier to test
Because Aero introduces its own reader tags it understandably makes you isolate your Aero config within separate EDN files. But this is awkward to test because you can’t as easily create test cases inline within a test file.
Worse, if you bundle your system component map into your
config.edn, you effectively have logic which you
can’t test without either making duplicate copies of your config.edn
data, or
else setting environment variables on the fly.
However if you lean on Aero only for obtaining your settings as strings, then that leaves the parts left to Malli as being much easier to test:
(deftest db-url-cannot-be-blank
(is (thrown-with-msg?
Exception
#".*must not be blank.*"
(sut/coerce-and-validate-string-settings
{:http-port "8080"
:db-url " "
:db-password "password123"
:auth-client "okta"
:okta-client-id "okta-client-123"
:metrics-enabled "true"}))))
Conditional validation
By making use of Malli’s multi-schemas functionality, we can add polymorphic validation in a similar way to Clojure’s multimethods. This makes it easy to adapt the validation for multiple modes or subcomponent types.
We saw this in action in the below part of the example code:
;; Multi-schema; add in different
;; extra fields depending on
;; auth client type
[:multi
{:dispatch :auth-client
:decode/string
(fn [string-settings]
(update
string-settings
:auth-client
#(or (some-> % name
str/lower-case
keyword)
:okta)))}
[:okta
(mu/merge
schema-common
[:map
[:okta-client-id non-blank-string]])]
[:dummy schema-common]]
;; Multi-schema; add in different extra fields depending on auth client type
[:multi {:dispatch :auth-client
:decode/string
(fn [string-settings]
(update string-settings
:auth-client
#(or (some-> % name str/lower-case keyword)
:okta)))}
[:okta
(mu/merge schema-common
[:map
[:okta-client-id non-blank-string]])]
[:dummy schema-common]]
Which in our example, means that an okta-client-id
is required when the auth
client is set to okta
…
(coerce-and-validate-string-settings
{:http-port "8080"
:db-url "some-db-url"
:db-password "password123"
:auth-client "okta"
:metrics-enabled "false"})
;; =>
;; Unhandled java.lang.Exception
;; Invalid settings:
;; {:okta-client-id ["missing required key"]}
(coerce-and-validate-string-settings
{:http-port "8080"
:db-url "some-db-url"
:db-password "password123"
:auth-client "okta"
:metrics-enabled "false"})
;; =>
;; Unhandled java.lang.Exception
;; Invalid settings: {:okta-client-id ["missing required key"]}
…but isn’t required when the auth client is set to dummy
:
(coerce-and-validate-string-settings
{:http-port "8080"
:db-url "some-db-url"
:db-password "password123"
:auth-client "dummy"
:metrics-enabled "false"})
;; =>
;; All OK :)
{:http-port 8080,
:db-url "some-db-url",
:db-password "password123",
:auth-client :dummy,
:metrics-enabled false}
Simple global defaults
Malli lets us simply and more clearly state our intended defaults. In our
example our Malli schema contains [:http-port {:default 8080} :int]
, meaning
that the HTTP port always has a default of 8080.
Aero’s #or
functionality does much the same, though it does have an odd quirk; for some reason Aero converts false to nil when #or is used.
For example, an Aero config.edn
file of
{:some-boolean #or [#env SOME_FLAG false]}
would result in
{:some-boolean nil}
This is OK most of the time, so long as the receiving code in question nil-puns
it. However, especially when it comes to application configuration, we’re often
passing these values down to pure-Java libraries which fall over when passed a
nil
rather than false
.
Why use Aero?
At this point you may be wondering why we’re still using Aero if all we’re basically using it for is grabbing values? The key reason for me is that Aero’s profile functionality lets you have your cake and eat it when it comes to prod VS development defaults.
There’s often a tension between making an application convenient to start up on
a dev machine VS making it reliable in production. A common approach is to set
global defaults; e.g. the database is assumed to be on localhost
if no
configuration is specified.
Where the ‘global default’ approach falls down though is in reliability, if for example a typo is accidentally put into a production env variable name. The application falls back to the local default, and so if its container healthchecks are absent or broken it will just uselessly sit there until someone notices the problem in some other way. (Sadly, I’ve seen this happen!)
However, with Aero profiles you get given a simple approach to having it both ways; you can set up convenient defaults that only apply to dev and/or test, whereas any properties that don’t have a sensible default in a production context will be caught at app start-up if they are misconfigured.
Lastly, Aero provides the benefit of a commonly-used convention for getting external values into your application. Especially when pared down to the limited subset of its functionality we’re using here, the things Aero does wouldn’t be very hard to write yourself. But since Aero is so ubiquitous, it provides a lot of value simply by the virtue of putting all the external inputs into a single, commonly-used location.
Summary
Validation of application configuration is a sadly neglected area of development. This is a shame when it’s such a low-cost way of preventing faulty application deployments, as well as making it easier for a dev new to a project to get an app running locally on their machine for the first time.
Thankfully, the combination of Malli & Aero gives you a simple & easy way of making your application config rock-solid.