- Kotlin 100%
|
|
||
|---|---|---|
| aspis-bitwarden | ||
| aspis-compiler-plugin | ||
| aspis-gradle-plugin | ||
| aspis-hocon | ||
| aspis-runtime | ||
| aspis-yaml | ||
| gradle | ||
| idea-plugin | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
aspis
aspis loads YAML and HOCON config into regular Kotlin classes. A compiler plugin generates each class's decoder and checks the class while you compile it, and an IntelliJ plugin checks the config files against the same classes while you edit them.
@Config
class DatabaseConfig(
@StartsWith("mongodb://") val uri: String,
@Range(1, 100) val poolSize: Int = 10,
val timeout: Duration = 5.seconds,
)
val config = Aspis.load { file(System.getenv("CONFIG_PATH") ?: "config.conf") }
val database = config.bind<DatabaseConfig>("mongodb")
mongodb {
uri = "mongodb://localhost/app"
pool-size = 20
}
It follows hoplite: the primary constructor is the schema, a default value makes a key optional, and every problem is reported at once. The difference is where the checks run. aspis uses no kotlin-reflect, rejects unsupported types and impossible constraints at compile time, and shows mistakes in config files in the editor, before anything runs.
Contents
- Setup
- Config classes
- Loading and binding
- Hot reload
- Constraints
- Secrets and Bitwarden
- Errors
- IntelliJ plugin
- How it works
- Building
Setup
Versions are <aspis>-<kotlin>: 1.1.0-2.4.10 is aspis 1.1.0 for Kotlin 2.4.10. The compiler plugin must match your Kotlin version exactly.
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
mavenCentral()
}
}
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10"
id("xyz.nillpoe.aspis") version "1.1.0-2.4.10"
}
dependencies {
// The Gradle plugin adds aspis-runtime. Add the formats you read:
implementation("xyz.nillpoe.aspis:aspis-hocon:1.1.0-2.4.10")
implementation("xyz.nillpoe.aspis:aspis-yaml:1.1.0-2.4.10")
// Optional: `${bw:...}` references to a Vaultwarden vault.
implementation("xyz.nillpoe.aspis:aspis-bitwarden:1.1.0-2.4.10")
}
| Module | What it is |
|---|---|
aspis-runtime |
Annotations, Aspis, the decoder. No dependencies besides the Kotlin standard library. |
aspis-hocon |
.conf and .hocon files, read with Lightbend Config. |
aspis-yaml |
.yaml and .yml files (YAML 1.2), read with SnakeYAML Engine. |
aspis-bitwarden |
The bw resolver, for Vaultwarden. |
aspis-compiler-plugin |
FIR checkers and IR code generation. Applied by the Gradle plugin. |
aspis-gradle-plugin |
id("xyz.nillpoe.aspis"). |
idea-plugin |
The IntelliJ plugin, a separate build in this repository. |
Config classes
Mark every class aspis builds with @Config, including nested ones. Plain classes work; they do not need to be data classes.
- Each primary constructor parameter is a key.
- A parameter with a default value is optional.
- A nullable parameter without a default is optional and becomes
null. - Every other parameter is required.
Defaults are evaluated as Kotlin evaluates them, so they can use earlier parameters: val name: String = uri.substringAfterLast('/'). Checks in init { require(...) } blocks run too, and their messages are reported like any other problem.
Keys are matched loosely: poolSize, pool-size, pool_size and POOL_SIZE are the same key. @ConfigName("db", "database") reads a parameter from other keys instead.
Supported types:
| Kind | Types |
|---|---|
| Text | String, Secret, Regex, URI, Path, UUID |
| Numbers | Byte, Short, Int, Long, Float, Double, BigInteger, BigDecimal |
| Other values | Boolean (true/false/yes/no/on/off) |
| Durations | kotlin.time.Duration and java.time.Duration: 30s, 5 minutes, 1h 30m, PT1H, or a plain number of milliseconds |
| Enums | By entry name, matched loosely: production reads PRODUCTION |
| Collections | List, Set and Map<String, V> of any supported type |
| Classes | Other @Config classes |
| State flows | MutableStateFlow<T> and StateFlow<T> of any supported type, for hot reload |
| Anything else | Through a ValueDecoder (see below) |
Sealed classes and interfaces choose a subclass by a discriminator key, type by default:
@Config
@Discriminator("kind")
sealed interface Storage {
@Config class Local(val path: String) : Storage
@Config @ConfigName("s3", "aws") class S3(val bucket: String) : Storage
@Config object Disabled : Storage
}
storage:
kind: s3
bucket: media
For types aspis does not know, register a decoder when loading, or name one on the parameter:
val config = Aspis.load {
file("config.conf")
decoder(Solera::class) { node, context ->
try {
Solera.parse(context.text(node))
} catch (e: SoleraSyntaxException) {
context.fail(e.message.orEmpty())
}
}
}
@Config
class Job(@DecodeWith(CronDecoder::class) val schedule: Cron)
Loading and binding
@ConfigFiles("config.conf", "config-example.conf", schema = "application")
val config = Aspis.load {
resource("defaults.conf") // class path resources
file(System.getenv("CONFIG_PATH") ?: "config.conf")
file("local.yaml", optional = true) // later sources override earlier ones
strict = true // unknown keys are errors (the default)
}
val database = config.bind<DatabaseConfig>("mongodb")
val avatar = config.bind<AccountAvatarConfig>("account.avatar")
val everything = config.bind<AppConfig>() // an empty prefix binds the whole file
bind decodes one section, so each module can read its own part of one file, as hoplite's ConfigBinder does. Objects from several sources are merged key by key; any other value is replaced.
The format is chosen by file extension. HOCON substitutions such as ${?DATABASE_URL} are resolved while parsing, falling back to environment variables. YAML has no substitutions. aspis does not map environment variables to keys by itself.
@ConfigFiles changes nothing at runtime. It tells the IntelliJ plugin which files the config is read from (see IntelliJ plugin).
Hot reload
Declare a parameter as MutableStateFlow<T> or StateFlow<T> to change it while the program runs. Other parameters keep the value they were bound with.
@Config
class RateLimit(
@Range(1, 1000) val perSecond: MutableStateFlow<Int>,
val message: StateFlow<String> = MutableStateFlow("Too many requests"),
val window: Duration = 1.seconds, // read once
)
val config = Aspis.load { file("config.conf") }
val limit = config.bind<RateLimit>("rate-limit")
config.reload() // read the sources again
val watcher = config.watch { e -> log.warn(e.message) } // reload when `file(...)` sources change
watcher.close()
scope.launch { limit.perSecond.collect { limiter.update(it) } }
reload reads every source again, decodes every section bound with flows, and sets each flow's value to the value at the same path. It is all or nothing: if any of those sections has a problem, no flow changes and reload throws ConfigException; watch passes it to its callback instead. A flow whose path is gone, such as an item removed from a list, keeps its value. A key removed from the file sets the flow back to its default.
Constraints check the flow's value. aspis-runtime does not depend on kotlinx-coroutines; config classes with flows need it on the class path, as any code using MutableStateFlow does.
Constraints
Put these on constructor parameters. They are checked when binding and, in config files, by the IntelliJ plugin. The compiler plugin reports a constraint on a type it does not apply to, and one that can never be met, such as @Range(10, 1) or an invalid @Pattern.
| Annotation | Applies to | Checks |
|---|---|---|
@Min(n), @Max(n), @Range(min, max) |
numbers | bounds, both included |
@Size(min, max) |
strings, secrets, collections, maps | length or size |
@NotEmpty |
strings, secrets, collections, maps | not empty |
@NotBlank |
strings, secrets | has a non-space character |
@Pattern(regex) |
strings, secrets | the whole value matches |
@StartsWith(prefix), @EndsWith(suffix) |
strings, secrets | prefix or suffix |
@OneOf("a", "b") |
strings | one of the values |
@MinDuration("1s"), @MaxDuration("1h") |
kotlin.time.Duration, java.time.Duration |
bounds, both included, written as in config files |
Secrets and Bitwarden
Secret holds a value that must not reach logs: toString() prints Secret(****), and reveal() returns the value. @Sensitive does the same for other types' error messages. Values of sensitive keys never appear in error messages, and the IntelliJ plugin warns when a sensitive key has a plain value in a config file.
Config strings can contain references, ${name:argument}, that a resolver fills in when binding. $${ writes a literal ${. In HOCON a reference must be quoted, key = "${bw:item}", because HOCON treats unquoted ${...} as its own substitution. Resolved values are treated as sensitive.
aspis-bitwarden adds the bw resolver for a self-hosted Vaultwarden server. It is found automatically once the module is on the class path.
| Reference | Value |
|---|---|
${bw:item} |
the login password of item |
${bw:item/username} |
also password, notes, totp (the stored secret), uri (the first one) |
${bw:item/field} |
a custom field |
Items are found by name or id. The resolver reads four environment variables, and only when the first ${bw:...} reference is resolved:
| Variable | Value |
|---|---|
ASPIS_BW_URL |
the server, such as https://vault.example.com |
ASPIS_BW_CLIENT_ID |
the account's API key client id, user.… |
ASPIS_BW_CLIENT_SECRET |
the API key client secret |
ASPIS_BW_PASSWORD |
the account's master password |
Vaultwarden has no Bitwarden Secrets Manager or machine accounts. aspis uses the regular Password Manager API instead. The API key only logs in; the vault is end-to-end encrypted, so decrypting it also needs the master password. Give each environment its own Vaultwarden account, in an organization with access to one collection. If those four values leak, only that collection is exposed.
The vault is downloaded once per Aspis.load and decrypted in memory, with Bouncy Castle for the key derivation (PBKDF2, Argon2id, HKDF) and the JDK for AES, HMAC and RSA. Tests run against a fake server only; test against your own Vaultwarden before relying on it.
Errors
Binding reports every problem, not only the first, with the file position, as a ConfigException:
Cannot bind `server` to `Server` (4 problems):
- server.port: must be between 1 and 65535, but was `70000` (server.yaml:3:9)
- server.api-key: must start with `sk-` (server.yaml:6:12)
- server.prot: unknown key `prot` in `Server`. Did you mean `port`? (server.yaml:8:3)
- server.host: missing `host` (String) in `Server` (server.yaml:2:3)
The compiler plugin reports these mistakes at compile time:
| Diagnostic | Problem |
|---|---|
ASPIS_UNSUPPORTED_CLASS |
@Config on an interface, abstract, inner, generic, local or enum class |
ASPIS_NO_PRIMARY_CONSTRUCTOR |
a class without a primary constructor |
ASPIS_UNSUPPORTED_TYPE |
arrays, Map with non-String keys, Any, functions, star projections |
ASPIS_NO_DECODER (warning) |
a class of this module that is neither @Config nor an enum |
ASPIS_DUPLICATE_KEY |
two parameters read from the same key |
ASPIS_CONSTRAINT_NOT_APPLICABLE, ASPIS_INVALID_CONSTRAINT |
see Constraints |
ASPIS_SEALED_SUBCLASS_NOT_CONFIG, ASPIS_DUPLICATE_SUBTYPE_NAME, ASPIS_DISCRIMINATOR_CLASH |
sealed class problems |
ASPIS_BIND_NOT_CONFIG |
bind<T>() with a T that is not @Config |
ASPIS_DYNAMIC_PREFIX (warning) |
a prefix that is not a constant, so the IDE cannot check it |
IntelliJ IDEA in K2 mode loads only bundled compiler plugins, so it does not show these diagnostics; Gradle builds do. To see them in the editor, turn off the registry key kotlin.k2.only.bundled.compiler.plugins.enabled.
IntelliJ plugin
The plugin checks config files against the classes they are bound to, in YAML and HOCON (HOCON needs the HOCON plugin):
- unknown keys with suggestions, missing values and sections, wrong types, enum values, sealed class kinds, and constraints, using the same decoder the runtime uses
- sections bound inside other sections: with
bind<Feed>("feed")andbind<FeedCache>("feed.cache"),feed.cacheis not an unknown key ofFeed - sensitive keys with a plain value instead of a reference
- top-level keys no
bindcall reads - completion of keys, enum values, booleans and sealed class kinds
- Go to Declaration from a key to its constructor parameter, and from a section to its
bindcall
A config file is found through @ConfigFiles. Its patterns are file names, with * for any characters. They are matched against every file in the IntelliJ project, including folders of other repositories you attach to it. The file is checked against the bind calls of the module with the annotation and of the modules that module depends on. bind prefixes must be constants for this.
When several @ConfigFiles match a file, or a file's name matches none, name the schema in the file's first lines:
# @aspis application
# @aspis application partial marks a file that only overrides parts of another. Missing values are not reported in it.
Build the plugin with ./gradlew buildPlugin in idea-plugin, and install idea-plugin/build/distributions/*.zip with Install Plugin from Disk.
How it works
For each @Config class, the FIR part of the compiler plugin declares a nested object:
object `$Aspis` : AspisFactory<DatabaseConfig> {
override fun schema(): String // the class's schema, as JSON
override fun create(values: Array<Any?>): DatabaseConfig
}
The IR part fills in the bodies. schema() returns a constant with the class's keys, types and constraints. create calls the primary constructor, and evaluates a parameter's default value where the config has none:
override fun create(values: Array<Any?>): DatabaseConfig {
val uri = values[0] as String
val poolSize = if (values[1] === Absent) 10 else values[1] as Int
return DatabaseConfig(uri, poolSize)
}
At runtime, bind finds $Aspis by class name with plain Java reflection, decodes the section against the schema, and calls create. The IntelliJ plugin builds the same schemas from source code with the Kotlin Analysis API, and runs the runtime's decoder over the file without creating anything.
Building
./gradlew build
Compiler plugin tests live in aspis-compiler-plugin/testData. box files are compiled and run, and diagnostic files check reported diagnostics. ./gradlew :aspis-compiler-plugin:test -Poverwrite rewrites the expected output. The IntelliJ plugin has its own build in idea-plugin/, which uses aspis-runtime from this repository. Set ideaPath in idea-plugin/local.properties to build against an installed IDE.