/TypedConfig

Translate your configuration toml into Kotlin classes

Primary LanguageKotlinApache License 2.0Apache-2.0

TypedConfig

Still in Early Development

This tool is still very early in development. Breaking changes are made all the time, documentation is sparse, insufficient code coverage, hard to use, etc. Feel free to use it in your own projects, but keep all that in mind. I’d love your feedback, too.

CircleCI Codecov Sonar Quality Gate wakatime

Maven Central Apache 2.0

Development Phase: Early Alpha

TypedConfig is a JVM tool and library written in Kotlin to get you actual typesafety when adding configuration to your application or library.

What this means is that, for example, when reading the port number for your web application to listen on, you don’t need to parse it from a string, or call some kind of getInt("port")-type method (which does all kinds of parsing and casts for you), but rather you can just call MyConfig.port. This still does some parsing for you — it has to — but as a consumer of that configuration you get things like autocomplete and config documentation (if provided) for free.

You accomplish this by writing out a spec in a config.tc.toml file which might look something like this:

[port] # (1)
type = "int" # (2)
default = 8080 # (3)
checks = ["validport"] # (4)
ℹ️

If the TOML syntax is unfamiliar to you, you can learn more about it at toml.io.

This fragment does four things:

  1. Defines a new key called port, which corresponds to a new property in the generated class. Keys are expected in lower camelcase, e.g. applicationPort.

  2. It’s of type int (integer). Required field.

  3. It has a default value that will be used if a value isn’t found in any configuration sources. Optional field; if unset, configuration load will fail if it can’t be resolved.

  4. It has one check — it must be a valid port. Optional; if unset, no checks are performed (other than typechecks).

This causes the following source code to be generated (edited for clarity):

class GeneratedConfig(source: Source) {
    val port: Int by IntKey("port", source, 8080, listOf(ValidPortCheck))
    companion object Factory {
        fun default() = GeneratedConfig(TypedConfig.defaultSource)
    }
}

Which you can then access in your own code:

val config = GeneratedConfig.default() // Initialize using default sources, i.e. EnvSource
val port: Int = config.port // ultimately reads System.getenv("PORT") in this case

Goals

Provide typesafe access to configuration

When accessing configuration values, you know definitively what type it is, what the default value is, whether it’s required, and what other related configuration is available. Also, hand-written documentation may also be available (if provided by the author).

Provide configuration from multiple sources

Look up your configuration from environment variables, static files, network services, or any combination thereof. Specify your configuration in a file but override it using an environment variable. Or specify your configuration in multiple files (say, one for each deployment stage and one for defaults), and override your defaults using deployment stage-specific config.

Fail fast as early as possible

If configuration is missing or incorrect, how quickly can we detect it and fail? TypedConfig provides checks and validation for this.

Good for both applications and libraries

Making TypedConfig accessible to applications is easy: they control the config spec, config values, and runtime. Things are a little trickier for libraries because while they have a runtime component, they don’t want to telegraph the internals of their configuration library to their consumers.

Usage

Firstly, to set your expectations correctly: what this tool does a little tricky, and some of that trickiness leaks into the usage. It takes your configuration, turns it into source code, and then your code can compile against it. That is, your code won’t be able to rely on the generated source code until after it’s generated! The Gradle plugin guarantees the config classes will be generated before they’re used, but if you’re using an IDE, you may need to perform a Gradle build first.

Using the Gradle Plugin

Gradle Plugin Portal

To apply the plugin itself, follow the directions on the Gradle Plugin page.

Next, create the following file in your project root (next to your settings.gradle file):

config.tc.toml
class = "com.example.GeneratedConfig"

[greeting]
type = "str"
description = "A friendly greeting."
default = "Hello!"

Now when you run ./gradlew generateTypedConfigs (or any compile-related task), a GeneratedConfig file will be generated for you in build/generated-config, which is automatically added to your main Gradle source sets. Now you can adapt it to your requirements.

See here for a sample.

To use the SNAPSHOT version of TypedConfig instead of the officially released one, see SNAPSHOT Usage.

In your code

Once the configuration class has been generated, you just need to construct the generated class and query its properties like any other class.

If your generated config is called GeneratedConfig, this looks like this:

val config = GeneratedConfig.default()
val port = config.port

Or if you want to specify a custom source for your configuration, like this:

val config = GeneratedConfig.default(EnvSource())
val port = config.port

If you’re using libraries that are using TypedConfig, and you want to change their configuration sources, you can write this:

TypedConfig.defaultSource = EnvSource()

This works if 1. you call it before the upstream library has constructed its configuration, and 2. that library is using the default() factory method for its own configuration (or is directly referring to TypedConfig.defaultSource).

Configuration Sources

Configuration sources provide the actual values at runtime. For example, one of the sources is EnvSource, which looks up configuration in environment variables. This may require translating the key — if you query EnvSource using the key port, it’ll check the PORT environment variable, for instance.

You can choose to provide these sources either to each config object as you construct them or globally, as a default (on [TypedConfig]).

Built-in Sources

There are a number of built-in sources that you can use to provide configuration.

EnvSource reads environment variables to populate configuration.

Keys are translated from lower camel case to screaming snake case when checking in the environment.

Config Key Environment Variable

port

PORT

applicationPort

APPLICATION_PORT

MapSource simply takes a Map<String, Any> as a constructor argument that you provide when constructing the source. The map can be hardcoded or built any way you like.

By default, keys are passed through as is — the key applicationPort is queried directly against the map as applicationPort.

MultiSource is a higher-order source that takes a list of other sources as an input. When querying the MultiSource, it simply queries each source provided until one provides a non-null value.

If one constructs a MultiSource like this:

val source = MultiSource(source1, source2)
val config = GeneratedConfig(source)
val port = config.port

Then MultiSource will query source1 for the configuration, and if none is found, query source2, and so on.

If this behavior isn’t to your needs, you can also implement your own Source.

CachedSource is another higher-order source that wraps another, presumably slow, source, by calling through to the delegated source and saving its results internally.

It also defines a .cached() extension method on Sources for convenience.

Usage is like this:

val source: Source = MySlowSource().cached()

However, none of the built-in sources are slow enough to benefit from caching, so this is provided mainly for user-provided sources that perhaps pull configuration from the network.