Add support for customizing spring boot ConfigurationProperties (#1897)

This adds an option which allows users to control exactly how
`@ConfigurationProperties` annotations are attached to classes.

This helps when:

* Spring Boot users don't have a single root class, but multiple
  classes that are injected in disparate places
* Pkl's module structure doesn't match the resulting `.properties`
  layout
This commit is contained in:
Daniel Chao authored and GitHub committed 2026-10-08 20:42:59 +00:00
1 parent f973fd6717
commit 88a50d167e
15 files changed
+294 -10

No files matched your search

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -77,7 +77,17 @@ data class CliJavaCodeGeneratorOptions(
* Pkl module name, and the value is the desired replacement.
*/
val renames: Map<String, String> = emptyMap(),
/**
* The mapping of Pkl class names to the spring boot `@ConfigurationProperties` prefix name, when
* [generateSpringBootConfig] is enabled.
*
* Setting this field is optional, and by default is inferred through the schema of the input
* modules.
*/
val springBootConfigurationProperties: Map<String, String> = emptyMap(),
) {
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("deprecated without replacement")
fun toJavaCodegenOptions() = toJavaCodeGeneratorOptions()
@@ -93,5 +103,6 @@ data class CliJavaCodeGeneratorOptions(
nonNullAnnotation,
implementSerializable,
renames,
springBootConfigurationProperties,
)
}
@@ -97,7 +97,24 @@ data class JavaCodeGeneratorOptions(
* from the corresponding name derived from the Pkl module declaration .
*/
val renames: Map<String, String> = emptyMap(),
)
/**
* The mapping of Pkl class names to the spring boot `@ConfigurationProperties` prefix name, when
* [generateSpringBootConfig] is enabled.
*
* Setting this field is optional, and by default is inferred through the schema of the input
* modules.
*/
val springBootConfigurationProperties: Map<String, String> = emptyMap(),
) {
init {
for (value in springBootConfigurationProperties.values) {
require(value.isEmpty() || value.isValidConfigurationPropertiesPrefix) {
"Invalid value in `springBootConfigurationProperties`: expected a valid ConfigurationProperties prefix, but got '$value'."
}
}
}
}
/** Entrypoint for the Java code generator API. */
class JavaCodeGenerator(
@@ -523,7 +540,25 @@ class JavaCodeGenerator(
}
fun generateSpringBootAnnotations(builder: TypeSpec.Builder) {
if (isModuleClass) {
if (codegenOptions.springBootConfigurationProperties.isNotEmpty()) {
val configurationPropertyPrefix =
codegenOptions.springBootConfigurationProperties[pClass.displayName] ?: return
val annotation =
with(
AnnotationSpec.builder(
ClassName.get(
"org.springframework.boot.context.properties",
"ConfigurationProperties",
)
)
) {
if (!configurationPropertyPrefix.isEmpty()) {
addMember("value", "\$S", configurationPropertyPrefix)
}
build()
}
builder.addAnnotation(annotation)
} else if (isModuleClass) {
builder.addAnnotation(
ClassName.get("org.springframework.boot.context.properties", "ConfigurationProperties")
)
@@ -25,6 +25,7 @@ import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.cliMain
import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.commons.isValidConfigurationPropertiesPrefix
import org.pkl.commons.toPath
import org.pkl.core.Release
@@ -131,6 +132,29 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink
)
.associate()
private val springBootConfigurationProperties: Map<String, String> by
option(
names = arrayOf("--spring-boot-configuration-properties"),
metavar = "pkl_class_name=prefix_value",
help =
"""
Turn the specified Pkl classes into Java classes with the `@ConfigurationProperties`
annotation.
If this setting is omitted, the `@ConfigurationProperties` annotation is inferred
according to the structure of the input modules.
"""
.trimIndent(),
)
.associate()
.validate { map ->
for (prefix in map.values) {
require(prefix.isEmpty() || prefix.isValidConfigurationPropertiesPrefix) {
"value must be a valid spring boot config prefix, but got '$prefix'"
}
}
}
override val helpString: String = "Generate Java classes and interfaces from Pkl module(s)"
override fun run() {
@@ -147,6 +171,7 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink
nonNullAnnotation = nonNullAnnotation,
implementSerializable = implementSerializable,
renames = renames,
springBootConfigurationProperties = springBootConfigurationProperties,
)
CliJavaCodeGenerator(options).run()
}
@@ -1651,6 +1651,65 @@ class JavaCodeGeneratorTest {
)
}
@Test
fun `spring boot -- explicitly configured ConfigurationProperties`() {
val javaCode =
generateJavaCode(
"""
module my.mod
class Server
"""
.trimIndent(),
JavaCodeGeneratorOptions(
generateSpringBootConfig = true,
springBootConfigurationProperties =
mapOf("my.mod" to "", "my.mod#Server" to "my-server-prefix"),
),
)
assertThat(javaCode)
.contains(
"""
|@ConfigurationProperties
|public final class Mod {
| private Mod() {
| }
"""
.trimMargin()
)
assertThat(javaCode)
.contains(
"""
| @ConfigurationProperties("my-server-prefix")
| public static final class Server {
"""
.trimMargin()
)
}
@Test
fun `spring boot -- explicity configured properties means no interface`() {
val javaCode =
generateJavaCode(
"""
module my.mod
server: Server
class Server
"""
.trimIndent(),
JavaCodeGeneratorOptions(
generateSpringBootConfig = true,
springBootConfigurationProperties = mapOf("foo" to "foo"),
),
)
// normally this would generate `@ConfigurationProperties` annotations through inference.
// but setting this option means that the user is in total control of how these annotations
// get produced.
assertThat(javaCode).doesNotContain("@ConfigurationProperties")
}
@Test
fun `import module`() {
val library =