Type-checked YAML and HOCON config for Kotlin classes, checked by a compiler plugin and an IntelliJ plugin.
Find a file
NillPoe b568c9d2f8
All checks were successful
Publish Package (aspis) TeamCity build finished
chore: bump version to 1.2.0-2.4.10
2026-10-06 07:18:35 +09:00
aspis-bitwarden Initial aspis implementation 2026-09-30 08:35:26 +09:00
aspis-compiler-plugin feat: hot reload through MutableStateFlow parameters 2026-10-05 22:10:32 +00:00
aspis-gradle-plugin Initial aspis implementation 2026-09-30 08:35:26 +09:00
aspis-hocon Initial aspis implementation 2026-09-30 08:35:26 +09:00
aspis-runtime feat: hot reload through MutableStateFlow parameters 2026-10-05 22:10:32 +00:00
aspis-yaml Initial aspis implementation 2026-09-30 08:35:26 +09:00
gradle feat: hot reload through MutableStateFlow parameters 2026-10-05 22:10:32 +00:00
idea-plugin feat: hot reload through MutableStateFlow parameters 2026-10-05 22:10:32 +00:00
.gitattributes Initial aspis implementation 2026-09-30 08:35:26 +09:00
.gitignore Initial aspis implementation 2026-09-30 08:35:26 +09:00
build.gradle.kts Initial aspis implementation 2026-09-30 08:35:26 +09:00
gradle.properties chore: bump version to 1.2.0-2.4.10 2026-10-06 07:18:35 +09:00
gradlew Initial aspis implementation 2026-09-30 08:35:26 +09:00
gradlew.bat Initial aspis implementation 2026-09-30 08:35:26 +09:00
LICENSE Initial aspis implementation 2026-09-30 08:35:26 +09:00
README.md feat: hot reload through MutableStateFlow parameters 2026-10-05 22:10:32 +00:00
settings.gradle.kts Initial aspis implementation 2026-09-30 08:35:26 +09:00

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

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") and bind<FeedCache>("feed.cache"), feed.cache is not an unknown key of Feed
  • sensitive keys with a plain value instead of a reference
  • top-level keys no bind call 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 bind call

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.