A type-safe cascading configuration library for Kotlin/Java/Android, supporting most configuration formats.
- Type-safe. Get/set value in config with type-safe APIs.
- Thread-safe. All APIs for config is thread-safe.
- Batteries included. Support sources from JSON, XML, YAML, HOCON, TOML, properties, map, command line and system environment out of box.
- Cascading. Config can fork from another config by adding a new layer on it. Each layer of config can be updated independently. This feature is powerful enough to support complicated situations such as configs with different values share common fallback config, which is automatically updated when configuration file changes.
- Self-documenting. Document config item with type, default value and description when declaring.
- Extensible. Konf makes it easy to customize new sources for config or expose items in config.
- konf
- License
- JDK 17 or higher
- Tested on Android SDK 34 or higher
This library has been published to Maven Central and JitPack.
<dependency>
<groupId>io.github.nhubbard</groupId>
<artifactId>konf</artifactId>
<version>2.1.0</version>
</dependency>
implementation 'io.github.nhubbard:konf:2.1.0'
implementation("io.github.nhubbard:konf:2.1.0")
Add JitPack repository to <repositories>
section:
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
Add dependencies:
<dependency>
<groupId>com.github.nhubbard</groupId>
<artifactId>konf</artifactId>
<version>master-SNAPSHOT</version>
</dependency>
Add the JitPack repository:
repositories {
maven { url 'https://jitpack.io' }
}
Add dependencies:
implementation 'com.github.nhubbard:konf:master-SNAPSHOT'
Add the JitPack repository:
repositories {
maven("https://jitpack.io")
}
Add dependencies:
implementation("com.github.nhubbard:konf:master-SNAPSHOT")
-
Define your options in a
ConfigSpec
:object ServerSpec : ConfigSpec() { val host by optional("0.0.0.0") val tcpPort by required<Int>() }
-
Create an instance of
Config
with yourConfigSpec
and your preferred sources:val config = Config { addSpec(ServerSpec) } .from.yaml.file("server.yml") .from.json.resource("server.json") .from.env() .from.systemProperties()
or:
val config = Config { addSpec(ServerSpec) }.withSource( Source.from.yaml.file("server.yml") + Source.from.json.resource("server.json") + Source.from.env() + Source.from.systemProperties() )
The
config
variable now contains all items defined inServerSpec
, and will load values from the four defined sources.The values in resource file
server.json
will override those inserver.yml
, the system environment variables,server.json
, and system properties.If you want to watch file
server.yml
and reload values when file content is changed, you can usewatchFile
instead offile
:val config = Config { addSpec(ServerSpec) } .from.yaml.watchFile("server.yml") .from.json.resource("server.json") .from.env() .from.systemProperties()
-
Define your configuration values in your source. For example:
- in
server.yml
:server: host: 0.0.0.0 tcp_port: 8080
- in
server.json
:{ "server": { "host": "0.0.0.0", "tcp_port": 8080 } }
- in system environment:
SERVER_HOST=0.0.0.0 SERVER_TCPPORT=8080
- on the command line for system properties:
-Dserver.host=0.0.0.0 -Dserver.tcp_port=8080
- in
-
Now, you can retrieve values from
config
with type-safe APIs:data class Server(val host: String, val tcpPort: Int) { fun start() {} } val server = Server(config[ServerSpec.host], config[ServerSpec.tcpPort]) server.start()
-
You can also retrieve values from multiple sources without using the config spec:
val server = Config() .from.yaml.file("server.yml") .from.json.resource("server.json") .from.env() .from.systemProperties() .at("server") .toValue<Server>() server.start()
Configuration items are declared in the config spec and added to the config by Config#addSpec
.
All items in each ConfigSpec
have the same prefix. For example, to define a config spec with prefix server
:
object ServerSpec : ConfigSpec("server")
If the ConfigSpec
is binding with a single class, you can declare the ConfigSpec
as a companion object of the class:
class Server {
companion object : ConfigSpec("server") {
val host by optional("0.0.0.0")
val tcpPort by required<Int>()
}
}
The ConfigSpec
prefix can also automatically be inferred from the class name. For example:
object ServerSpec : ConfigSpec()
or
class Server {
companion object : ConfigSpec()
}
Here are some examples showing the inference convention:
Uppercase
touppercase
lowercase
tolowercase
SuffixSpec
tosuffix
TCPService
totcpService
The ConfigSpec
can also be nested.
For example, the path Service.Backend.Login.user
in the following example will be inferred as "service.backend.login.user":
object Service : ConfigSpec() {
object Backend : ConfigSpec() {
object Login : ConfigSpec() {
val user by optional("admin")
}
}
}
There are three kinds of Item
:
-
Required items: These don't have default values. If a value isn't provided at runtime, an exception is raised.
// You can provide a description for each configuration entry. val tcpPort by required<Int>(description = "port of server") // You can also omit the description: val name by required<String>()
-
Optional items. These items have default values, and thus can be safely retrieved at any time.
// Similarly to required items, you can omit the description. // However, you have to provide a default value for optional items. val host by optional("0.0.0.0", description = "host IP of server")
-
Lazy items. These also have default values, but the default value is not a constant; instead, it is evaluated from a lambda every time it is retrieved.
val nextPort by lazy { config -> config[tcpPort] + 1 }
You can also define a ConfigSpec
in Java, with a more verbose API (compared to the Kotlin version in "quick start"):
public class ServerSpec {
public static final ConfigSpec spec = new ConfigSpec("server");
public static final OptionalItem<String> host =
new OptionalItem<String>(spec, "host", "0.0.0.0") {};
public static final RequiredItem<Integer> tcpPort = new RequiredItem<Integer>(spec, "tcpPort") {};
}
The {}
after every item declaration is necessary to avoid erasing the type of the item.
To create a new empty config, use the default constructor:
val config = Config()
To create a new config with your ConfigSpec
s, add them using the addSpec
function in a lambda passed to Config
:
val config = Config { addSpec(Server) }
If you need to add more config specs after calling the constructor, you can use the addSpec
function on your instance
of Config
:
config.addSpec(Server)
config.addSpec(Client)
To retrieve the value associated with your config item, you can use the type-safe API:
val host = config[Server.host]
Alternatively, you can use the "unsafe" API with a fully qualified string path to your spec:
val host = config.get<String>("server.host")
You can also omit the .get
:
val host = config<String>("server.host")
The unsafe API is the suggested method to use in Java. It is possible to use the type-safe API from Java, but it is significantly more clumsy than using the unsafe API.
You can cast a config instance to a value given a target type:
val server = config.toValue<Server>()
To check whether an item exists in the config, use the contains
function or the in
overload:
config.contains(Server.host)
// or
Server.host in config
To check whether an item exists in the config by name, you can do the same, but pass the fully qualified value path instead:
config.contains("server.host")
// or
"server.host" in config
To check whether all required configuration items exist in the config, use containsRequired
:
config.containsRequired()
To throw an exception if any required config items don't have values, use validateRequired
:
config.validateRequired()
To associate a new value with an item, you can use the type-safe API:
config[Server.tcpPort] = 80
Alternatively, use the unsafe fully qualified item path API:
config["server.tcpPort"] = 80
To discard the associated value of the item, with the type-safe API, use unset
:
config.unset(Server.tcpPort)
Similarly, to discard the associated value of the item by name, use the unsafe unset
API:
config.unset("server.tcpPort")
To associate an item with a lazy lambda using the type-safe API, use lazySet
:
config.lazySet(Server.tcpPort) { it[basePort] + 1 }
Similarly, to associate an item with a lazy lambda using the unsafe API, use lazySet
:
config.lazySet("server.tcpPort") { it[basePort] + 1 }
If you want your program to react when a configuration item is updated, use onSet
on an item:
val handler = Server.host.onSet { value -> println("the host has changed to $value") }
If you want your program to react before a configuration item is updated, use beforeSet
:
val handler = Server.host.beforeSet { config, value -> println("the host will change to $value") }
You can also use the same API on an instance of Config
to react on all config updates:
val handler = config.beforeSet { item, value -> println("${item.name} will change to $value") }
If you want your program to react after a configuration item is updated, use afterSet
on an item:
val handler = Server.host.afterSet { config, value -> println("the host has changed to $value") }
or on a Config
instance:
val handler = config.afterSet { item, value -> println("${item.name} has changed to $value") }
Finally, to cancel the subscription, use cancel
on the handler returned by beforeSet
, onSet
, or afterSet
:
handler.cancel()
To export a read-write property value from the configuration, use the property
delegate with a var
statement:
var port by config.property(Server.tcpPort)
port = 9090
check(port == 9090)
To export a read-only property value from the configuration, use the property
delegate with a val
statement:
val port by config.property(Server.tcpPort)
check(port == 9090)
It is possible to "fork" a configuration to make a separate instance of a Config
When the parent instance is modified, the changes will propagate to the child instance.
If the child instance is modified, the changes will not propagate to the parent instance.
val config = Config { addSpec(Server) }
config[Server.tcpPort] = 1000
// Fork from the parent config.
val childConfig = config.withLayer("child")
// Create a child config that inherits its values from the parent config.
check(childConfig[Server.tcpPort] == 1000)
// Modifications of the parent config will affect the values of the child config.
config[Server.tcpPort] = 2000
check(config[Server.tcpPort] == 2000)
check(childConfig[Server.tcpPort] == 2000)
// Modifications to the child config will not affect the values of the parent config.
childConfig[Server.tcpPort] = 3000
check(config[Server.tcpPort] == 2000)
check(childConfig[Server.tcpPort] == 3000)
Use the from
receiver to load values from a source that won't affect existing values in the config.
It will return a new child config by loading all values into new layer in child config:
val config = Config { addSpec(Server) }
// The values in the source are loaded into the new layer in the child config
val childConfig = config.from.env()
check(childConfig.parent === config)
The included sources are declared in DefaultLoaders
.
Each source is shown below.
The corresponding config spec for these samples is ConfigForLoad
.
Type | Usage | Provider | Sample |
---|---|---|---|
HOCON | config.from.hocon |
HoconProvider |
source.conf |
JSON | config.from.json |
JsonProvider |
source.json |
properties | config.from.properties |
PropertiesProvider |
source.properties |
TOML | config.from.toml |
TomlProvider |
source.toml |
XML | config.from.xml |
XmlProvider |
source.xml |
YAML | config.from.yaml |
YamlProvider |
source.yaml |
JavaScript | config.from.js |
JsProvider |
source.js |
Hierarchical map | config.from.map.hierarchical |
Built-in | MapSourceLoadSpec |
Map in key-value format | config.from.map.kv |
Built-in | KVSourceSpec |
Map in flat format | config.from.map.flat |
Built-in | FlatSourceLoadSpec |
System environment variables | config.from.env() |
EnvProvider |
- |
System properties | config.from.systemProperties() |
PropertiesProvider |
- |
These sources can also be manually created using their provider, and then loaded into an instance of Config
using
config.withSource(source)
.
All from
APIs have a standalone version that returns sources without loading them into the config, as shown below:
Type | Usage |
---|---|
HOCON | Source.from.hocon |
JSON | Source.from.json |
Properties | Source.from.properties |
TOML | Source.from.toml |
XML | Source.from.xml |
YAML | Source.from.yaml |
JavaScript | Source.from.js |
Hierarchical map | Source.from.map.hierarchical |
Map in key-value format | Source.from.map.kv |
Map in flat format | Source.from.map.flat |
System environment variables | Source.from.env() |
System properties | Source.from.systemProperties() |
The format of the system properties source is the same as the properties source.
The system environment source follows the same mapping convention as the properties file source, but all letters in the
name are in uppercase, and .
in the name is replaced with _
.
For example, an item with the fully qualified path server.port
would be loaded from environment variables as
SERVER_PORT
.
HOCON/JSON/properties/TOML/XML/YAML/JavaScript sources can be loaded from a variety of input formats. Using the properties source as an example:
- From a file:
config.from.properties.file("/path/to/file")
- From a watched file:
config.from.properties.watchFile("/path/to/file", 100, TimeUnit.MILLISECONDS)
- You can re-trigger the setup process every time the updated file is loaded using
watchFile("/path/to/file") { config, source -> setup(config) }
.
- You can re-trigger the setup process every time the updated file is loaded using
- From a string:
config.from.properties.string("server.port = 8080")
- From a URL:
config.from.properties.url("http://localhost:8080/source.properties")
- From a watched URL:
config.from.properties.watchUrl("http://localhost:8080/source.properties", 1, TimeUnit.MINUTES)
- You can re-trigger the setup process every time the URL is loaded using
watchUrl("http://localhost:8080/source.properties") { config, source -> setup(config) }
.
- You can re-trigger the setup process every time the URL is loaded using
- From a Git repository:
config.from.properties.git("https://github.com/nhubbard/konf.git", "/path/to/source.properties", branch = "dev")
- From a watched Git repository:
config.from.properties.watchGit("https://github.com/nhubbard/konf.git", "/path/to/source.properties", period = 1, unit = TimeUnit.MINUTES)
- You can re-trigger the setup process every time the Git file is loaded using
watchGit("https://github.com/nhubbard/konf.git", "/path/to/source.properties") { config, source -> setup(config) }
.
- You can re-trigger the setup process every time the Git file is loaded using
- From a resource:
config.from.properties.resource("source.properties")
- From a
Reader
:config.from.properties.reader(reader)
- From an
InputStream
:config.from.properties.inputStream(inputStream)
- From a
ByteArray
:config.from.properties.bytes(bytes)
- From a portion of a
ByteArray
:config.from.properties.bytes(bytes, 1, 12)
If the source is a file, the file extension can be auto-detected.
You can use config.from.file("/path/to/source.json")
instead of config.from.json.file("/path/to/source.json")
,
or use config.from.watchFile("/path/to/source.json")
instead of config.from.json.watchFile("/path/to/source.json")
.
URLs also support auto-detecting the extension (use config.from.url
or config.from.watchUrl
).
The following file extensions support auto-detection:
Type | Extension(s) |
---|---|
HOCON | conf |
JSON | json |
Properties | properties |
TOML | toml |
XML | xml |
YAML | yml , yaml |
JavaScript | js |
You can also implement your own Source
to customize your new source, which can be loaded into config using config.withSource(source)
.
To subscribe to update events before every load operation, use beforeLoad
:
val handler = config.beforeLoad { source -> println("$source will be loaded") }
You can re-trigger the setup process by subscribing to the update event after every load operation using afterLoad
:
val handler = config.afterLoad { source -> setup(config) }
And to cancel the subscription, use cancel
:
handler.cancel()
By default, Konf extracts the desired paths from sources and ignores other unknown paths in sources.
If you want Konf to throw an exception when unknown paths are found, you can enable the FAIL_ON_UNKNOWN_PATH
feature:
config.enable(Feature.FAIL_ON_UNKNOWN_PATH)
.from.properties.file("server.properties")
.from.json.resource("server.json")
config
will validate paths from both the properties file and the JSON resource.
Furthermore, if you want to validate only one source file, you can use enable
like so:
config.from.enable(Feature.FAIL_ON_UNKNOWN_PATH).properties.file("/path/to/file")
.from.json.resource("server.json")
Path substitution is a feature substitutes path references in a source with their values.
The following rules apply to path substitution:
- Only quoted string value will be substituted. This is to ensure path substitutions made by Konf will not conflict with HOCON substitutions.
- The definition of a path variable uses Kotlin string interpolation syntax; e.g.,
${java.version}
. - The path variable is resolved in the context of the current source.
- If the string value only contains the path variable, it will be replaced by the whole subtree in the path; otherwise, it will be replaced by the string value in the path.
- Use
${path:-default}
to provide a default value when the path is unresolved; e.g.,${java.version:-8}
. - Use
$${path}
to escape the path variable, e.g.,$${java.version}
will be resolved to${java.version}
instead of the value injava.version
. - Path substitution works in a recursive way, so nested path variables like
${jre-${java.specification.version}}
are allowed. - Konf also supports all key prefixes of the Apache Commons Text StringSubstitutor.
Konf will perform path substitution for every source by default, except for the system environment source, upon loading the config.
You can disable this behavior by using config.disable(Feature.SUBSTITUTE_SOURCE_BEFORE_LOADED)
for the config
or source.disabled(Feature.SUBSTITUTE_SOURCE_BEFORE_LOADED)
for a single source.
By default, Konf will throw exception if any path variables are unresolved.
You can use source.substituted(false)
manually to ignore these unresolved variables.
To resolve path variables that refer to other sources, you can merge these sources before loading them into the config.
For example, if we have two sources source1.json
and source2.properties
, where source1.json
is:
{
"base": {
"user": "konf",
"password": "passwd"
}
}
and source2.properties
is:
connection.jdbc=mysql://${base.user}:${base.password}@server:port
then use:
config.withSource(
Source.from.file("source1.json") +
Source.from.file("source2.properties")
)
to merge these sources correctly.
We can then resolve mysql://${base.user}:${base.password}@server:port
as mysql://konf:passwd@server:port
.
All of the Source
/Config
/ConfigSpec
support the add, remove, and merge prefix operations as shown below:
Type | Add Prefix | Remove Prefix | Merge |
---|---|---|---|
Source |
source.withPrefix(prefix) or Prefix(prefix) + source or config.from.prefixed(prefix).file(file) |
source[prefix] or config.from.scoped(prefix).file(file) |
fallback + facade or facade.withFallback(fallback) |
Config |
config.withPrefix(prefix) or Prefix(prefix) + config |
config.at(prefix) |
fallback + facade or facade.withFallback(fallback) |
Spec |
spec.withPrefix(prefix) or Prefix(prefix) + spec |
spec[prefix] |
fallback + facade or facade.withFallback(fallback) |
To export all values in the config as a tree, use config.toTree()
:
val tree = config.toTree()
To export all values in the config to a map in key-value format, use config.toMap()
:
val map = config.toMap()
To export all values in the config to a hierarchical map, use config.toHierarchicalMap()
:
val map = config.toHierarchicalMap()
To export all values in the config to a map in a flat format, use config.toFlatMap()
:
val map = config.toFlatMap()
To export all values in the config to JSON, use config.toJson.toFile(file)
:
val file = createTempFile(suffix = ".json")
config.toJson.toFile(file)
To reload the values from JSON, recreate the config:
val newConfig = Config {
addSpec(Server)
}.from.json.file(file)
check(config == newConfig)
The config can be saved to a variety of output formats. Using JSON as an example:
- Export to file:
config.toJson.toFile("/path/to/file")
- Export to string:
config.toJson.toText()
- Export to
Writer
:config.toJson.toWriter(writer)
- Export to
OutputStream
:config.toJson.toOutputStream(outputStream)
- Export to
ByteArray
:config.toJson.toBytes()
You can also implement the Writer
interface
to customize your new writer
(see
JsonWriter
for how to integrate your writer with config).
Supported item types include:
- All primitive types
- All primitive array types
BigInteger
BigDecimal
String
- Date and Time
java.util.Date
OffsetTime
OffsetDateTime
ZonedDateTime
LocalDate
LocalTime
LocalDateTime
Year
YearMonth
Instant
Duration
SizeInBytes
- Enum
- Array
- Collection
List
Set
SortedSet
Map
SortedMap
- Kotlin Built-in classes
Pair
Triple
IntRange
CharRange
LongRange
- Data classes
- POJOs supported by Jackson core modules
Konf supports the size in bytes format as described in the HOCON specification with the class SizeInBytes
.
Konf supports both the ISO-8601 duration format and HOCON duration format for Duration
.
Konf uses Jackson to support Kotlin built-in classes, data classes, and POJOs.
You can use config.mapper
to access the ObjectMapper
instance used by config,
and configure it to support more types from third-party Jackson modules.
The default modules registered by Konf include:
- Jackson core modules
JavaTimeModule
in jackson-modules-java8- jackson-module-kotlin
There are some optional features that you can enable/disable in the config scope or the source scope using
Config#enable(Feature)
/Config#disable(Feature)
or Source#enabled(Feature)
/Source#disable(Feature)
.
You can use Config#isEnabled()
or Source#isEnabled()
to check whether a feature is enabled.
These features include:
FAIL_ON_UNKNOWN_PATH
: feature that determines what happens when unknown paths appear in the source. If enabled, an exception is thrown when loading from the source to indicate it contains unknown paths. This feature is disabled by default.LOAD_KEYS_CASE_INSENSITIVELY
: feature that determines whether keys are loaded from the sources case-insensitively. This feature is disabled by default except for system environment.LOAD_KEYS_AS_LITTLE_CAMEL_CASE
: feature that determines whether loading keys from sources as little camel case. This feature is enabled by default.OPTIONAL_SOURCE_BY_DEFAULT
: feature that determines whether sources are optional by default. This feature is disabled by default.SUBSTITUTE_SOURCE_BEFORE_LOADED
: feature that determines whether sources should be substituted before loaded into config. This feature is enabled by default.WRITE_DESCRIPTIONS_AS_COMMENTS
: feature that exports item descriptions as comments in supported formats. This feature is disabled by default and currently in development.
To build the library with Gradle, use the following command:
./gradlew clean assemble
To test the library with Gradle, use the following command:
./gradlew clean test
Since Gradle has excellent incremental build support, you can usually omit executing the clean
task.
To install the library in a local Maven repository for consumption in other projects, use the following command:
./gradlew clean install
Copyright © 2017-2024 Uchuhimo and 2024-present Nicholas Hubbard. Licensed under an Apache 2.0 license.