Add typesafe 12-factor configuration to http4k apps with Environments
October 2021 / the http4k team
Intro
This post covers the various concerns around configuring HTTP apps, and introduces the http4k approach for addressing these when deploying applications into cloud-native environments, which leverages the Kotlin type system for maximum safely and code reuse.
Concerns when configuring applications
One of the tenets of operating applications according to the principles of 12factor, and especially in containerised cloud-native apps, is to inject all app configuration through the use of environmental variables. Additionally, when using more restrictive settings (such as utilising JVM security manager policies or through the use of container images which don’t provide an OS baseline) it may not be possible to read files (such as YAML, JSON etc) from disk, which reinforces this approach.
There are several particular relevant concerns that we need to address, but overall the effect that we are looking for is that any misconfiguration of the application will result in it failing to startup. For this reason we want to reify all values to check them as soon as possible in the application bootstrap phase.
1. Optionality
Kotlin’s type system guards us against missing values being injected - for instance the following code will throw a
IllegalStateException
due to a typo in the parameter name:
package content.news.typesafe_configuration.pre
// export ANTIDISESTABLISHMENTARIANISM=opposition to the disestablishment of the Church of England
val definition: String = System.getenv("ANTIDISESTABLISHMENTARIAMISM")
However not all configuration values will be required. We can define that there are 3 distinct modes of optionality available for each parameter:
- Required: These values must be injected for each environment, with no default value defined. Most configurations such as hostnames should always use this form to maximise operational safety.
- Optional: These values can be supplied, but there is no default value. This category fits well with dynamic properties which could be data-driven (ie. not known at compile-time).
- Defaulted: These values can be supplied, but a fallback value (or chain of other config values) will be used if they are not.
Missing values should produce a reasonable error and stop the app from starting.
2. Type coercion
Most applications will require a variety of configuration primitive types, which may or may not map to the Java/Kotlin standard types, including:
- strings such as service URLs, log levels, or AWS role names
- numeric values such as ports or retry counts
- booleans such as debug switch or feature flags
- duration values for timeouts, backoff times
But handling these raw types alone is not enough to guarantee safety - it is best to marshall the values into a
suitable operational/domain type that can validate the input and avoid confusion. Kotlin gives us a simple way to do this
using require
as a guard:
package content.news.typesafe_configuration.pre
class Port(val value: Int) {
init {
require((1..65535).contains(value)) { "Out of range Port: '$value'" }
}
}
// export PORT=8000
val port = Port(System.getenv("PORT").toInt())
Additionally to the above, it is important to represent those values in a form that cannot be misinterpreted. A good
example of this is the passing of temporal values as integers - timeouts defined this way could be easily be
parsed into the wrong time unit (seconds instead of milliseconds). Using a higher level primitive such as Duration
will help us here:
package content.news.typesafe_configuration.pre
import java.time.Duration
data class Timeout(val value: Duration) {
init {
require(!value.isNegative) { "Cannot have negative timeout" }
}
}
// export TIMEOUT=PT30S
val timeout = Timeout(Duration.parse(System.getenv("TIMEOUT")))
Obviously, the above is still not very safe - and what’s more, a coercion could now fail with one of 3 different
exceptions depending on if the value was missing (IllegalStateException
), unparsable (DateTimeParseException
) or
invalid (IllegalArgumentException
). The conversion code from String -> Duration
must also be repeated (or extracted)
for each value that we wish to parse.
3. Multiplicity
Configuration parameters may have one or many values and need to be converted safely from the injected string representation (usually comma-separated) and into their internally represented types at application startup:
package content.news.typesafe_configuration.pre
class Host(val value: String)
// export HOSTNAMES=eu-west1.aws.com,eu-west2.aws.com,eu-west3.aws.com
val hosts = System.getenv("HOSTNAMES").split(",").map { Host(it.trim()) }
Once again, the splitting code will need to be repeated for each config value, or extracted to a library function.
4. Security
The configuration of a standard app will generally contain both sensitive and non-sensitive values. Sensitive such as application secrets, DB passwords or API keys should be handled in a way that avoid storing directly in memory in a readable format or long lived fashion, where they may be inadvertently inspected or outputted into a log file.
Dangling code situations such as in the code below are common, and are asking for trouble…
package content.news.typesafe_configuration.pre
import org.http4k.client.OkHttp
import org.http4k.core.Method.GET
import org.http4k.core.Request
import java.nio.ByteBuffer
val s3 = OkHttp()
fun readFile(secretKey: String, bucketKey: String): ByteBuffer {
return s3(Request(GET, "https://mybucket.s3.amazonaws.com/$bucketKey")).body.payload
}
// export AWS_SECRET_KEY=someSuperSecretValueThatOpsReallyDoNotWantYouToKnow
val file = readFile(System.getenv("AWS_SECRET_KEY"), "myfile.txt")
5. Configuration Context & Overriding
We also want to avoid defining all values for all possible scenarios - for example in test cases, so the ability to overlay configuration sets on top of each other is useful. Although it is against the rules of 12-factor, it is sometimes convenient to source parameter values from a variety of contexts when running applications in non-cloud environments:
- System Environment variables
- Properties files
- JAR resources
- Local files
- Source code defined environmental configuration
Implementing this kind of fallback logic manually, you’d end up with code like the below:
package content.news.typesafe_configuration.pre
val name = System.getProperty("USERNAME") ?: System.getenv("USERNAME") ?: "DEFAULT_USER"
The http4k approach…
There are already many options for configurational
libraries written in Kotlin, but http4k also provides an option in the http4k-config
add-on module
which leverages the power of the Lens system already built into the core library to provide a consistent experience to
API users. In case you’re new to Lenses, here’s a recap…
Lenses - a recap
In http4k, Lenses are typically used to provide typesafe conversion of typed values into and out of HTTP messages, although this concept has been extended within the http4k ecosystem to support that of a form handling and request contexts.
A Lens is an stateless object responsible for either the one-way (or Bidirectional) transformation of
It defines type parameters representing input IN
and output OUT
types and implements
one (for a Lens
) or both (for a BiDiLens
) of the following interfaces:
- LensExtractor - takes a value of type
IN
and extracts a value of typeOUT
- LensInjector - takes a value of type
IN
and a value of typeOUT
and returns a modified value of typeIN
with the value injected into it.
package content.news.typesafe_configuration.post
interface LensExtractor<in IN, out OUT> : (IN) -> OUT {
override operator fun invoke(target: IN): OUT
}
interface LensInjector<in IN, in OUT> {
operator fun <R : OUT> invoke(value: IN, target: R): R
}
interface Lens<IN, OUT> : LensExtractor<IN, OUT>
interface BiDiLens<IN, OUT> : Lens<IN, OUT>, LensInjector<IN, OUT>
The creation of a Lens consists of 4 main concerns:
- targeting determines where the Lens expects to extract and inject the values from/to, which can consist of both an overall target and a name within that target.
- multiplicity handling which defines how many of a particular value we are attempting to handle.
- the transformation chain of function composition which forms a specification for converting one type to another.
This is done in code using the
map()
method defined on the Lens. - the optionality of a Lens denotes the behaviour if/when a value cannot be found in the target.
To define a Lens instance through the http4k Lens API, we take an initial target specification, decide it’s
multiplicity, provide any transformations with map()
, and finally reify the specification into a Lens instance
by deciding it’s optionality.
It sounds involved, but it is consistent and the fluent API has been designed to make it simpler. By way of an example,
here we define a bi-directional Lens for custom type Page
, extracted from a querystring value and defaulting to Page 1.
package content.news.typesafe_configuration.post
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.lens.BiDiLens
import org.http4k.lens.Query
import org.http4k.lens.int
data class Page(val value: Int) {
init {
require(value > 1) { "Page number must be positive" }
}
}
val lens: BiDiLens<Request, Page> =
Query.int().map(::Page, Page::value).defaulted("pageNumber", Page(1))
val pageNumber: Page = lens(Request(GET, "http://abc/search?pageNumber=55"))
val updatedRequest: Request = lens(pageNumber, Request(GET, "http://abc/search"))
In http4k, Lenses are typically used to provide typesafe conversion of typed values into and out of HTTP messages, although this concept has been extended within the http4k ecosystem to support that of a form handling and request contexts.
http4k Environments
in http4k, an Environment
object is a context which holds configuration values. It effectively behaves like a
Map
, in that it can be composed with other Environment
objects to provide a consolidated view of all of it’s
component values.
package content.news.typesafe_configuration.post
import org.http4k.config.Environment
import java.io.File
val systemEnv: Environment = Environment.ENV
val jvmFlags: Environment = Environment.JVM_PROPERTIES
val jar: Environment = Environment.fromResource("jar.properties")
val filesystem: Environment = Environment.from(File("fs.properties"))
val codeBased: Environment = Environment.from("key1" to "value1", "key2" to "value2")
val consolidated: Environment = jvmFlags overrides
systemEnv overrides
codeBased overrides
filesystem overrides
jar
If you’re using any of the other Kotlin-based configuration libraries, the above should look pretty familiar. The
difference starts to become apparent when attempting to retrieve values from the Environment
instance. This is done
using EnviromentKey
Lenses, which are an extension of the http4k Lens system that specifically targets Environment
objects.
package content.news.typesafe_configuration.post
import org.http4k.config.Environment
import org.http4k.config.EnvironmentKey
import org.http4k.lens.Lens
import org.http4k.lens.duration
import org.http4k.lens.int
import java.time.Duration
data class Timeout(val value: Duration)
data class Port(val value: Int)
data class Host(val value: String)
// export TIMEOUT=PT30S
// export PORT=8080
// export HOSTNAMES=eu-west1.aws.com,eu-west2.aws.com,eu-west3.aws.com
val env = Environment.ENV
val portLens: Lens<Environment, Port> =
EnvironmentKey.int().map(::Port).defaulted("PORT", Port(9000))
val timeoutLens: Lens<Environment, Timeout?> =
EnvironmentKey.duration().map(::Timeout).optional("TIMEOUT")
val hostsLens: Lens<Environment, List<Host>> =
EnvironmentKey.map(::Host).multi.required("HOSTNAMES")
val timeout: Timeout? = timeoutLens(env)
val port: Port = portLens(env)
val hosts: List<Host> = hostsLens(env)
Handling failure
When using the http4k Environment to define config, missing or values which cannot be deserialised all now cause
a LensFailure
to be thrown with a descriptive error message. As before, this results in the application failing to
start, but as the exception if both consistent and explicit, diagnosing the problem becomes much simpler.
Single-shot Secrets
In order to avoid the accidental exposure of sensitive information such as passwords into the application runtime, a new
type Secret
has been introduced, which tries as much as possible to avoid exposing it’s internal value as a readable
String
. The Secret
class is designed to only have the string version of it’s value read once, and only within a
specific use()
block, after which the underlying value is internally overwritten and further attempts to read it throw
an IllegalStateException
.
The typical use-case for this block is to set-up a SQL Datasource
or to create a Filter
which adds authentication to
all outbound requests, as in the example below:
package content.news.typesafe_configuration.post
import org.http4k.client.OkHttp
import org.http4k.config.Environment
import org.http4k.config.EnvironmentKey
import org.http4k.config.Secret
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.then
import org.http4k.filter.ServerFilters
import org.http4k.lens.Lens
import org.http4k.lens.secret
// export USER_PASSWORD=12345
val accessToken: Lens<Environment, Secret> = EnvironmentKey.secret().required("USER_PASSWORD")
val secret: Secret = accessToken(Environment.ENV)
val authFilter: Filter = secret.use { value: String -> ServerFilters.BearerAuth(value) }
val authedHttp: HttpHandler = authFilter.then(OkHttp())
As with other supported primitives, Secret
is available by default in all supported Lens Locations.