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

@@ -22,6 +22,24 @@ Default: (not set) +
Flag that indicates to generate config classes for use with Spring Boot. Flag that indicates to generate config classes for use with Spring Boot.
==== ====
.--spring-boot-configuration-properties
[%collapsible]
====
Default: (none) +
Example: `my.mod#Server=server` +
Explicitly configures which generated classes receive the Spring Boot `@ConfigurationProperties` annotation, and with what prefix.
Only applies if `--generate-spring-boot` is set.
The key is the Pkl class name (for example, `my.mod` for a module class, or `my.mod#Server` for a class within a module), and the value is the annotation's prefix.
An empty value generates a bare `@ConfigurationProperties` annotation.
If this option is omitted, the annotations are inferred from the structure of the input modules.
If this option is set, no annotations are inferred, and only the specified classes are annotated.
Repeat this option to define multiple mappings.
Values must be valid Spring Boot configuration properties prefixes.
====
.--implement-serializable .--implement-serializable
[%collapsible] [%collapsible]
==== ====
@@ -36,6 +36,21 @@ Example: `generateSpringBootConfig = true` +
Whether to generate config classes for use with Spring Boot. Whether to generate config classes for use with Spring Boot.
==== ====
.springBootConfigurationProperties: MapProperty<String, String>
[%collapsible]
====
Default: `[:]` +
Example: `springBootConfigurationProperties = ["my.mod": "", "my.mod#Server": "server"]` +
Explicitly configures which generated classes receive the Spring Boot `@ConfigurationProperties` annotation, and with what prefix.
Only applies if `generateSpringBootConfig` is `true`.
The key is the Pkl class name (for example, `my.mod` for a module class, or `my.mod#Server` for a class within a module), and the value is the annotation's prefix.
An empty value generates a bare `@ConfigurationProperties` annotation.
If this property is empty, the annotations are inferred from the structure of the input modules.
If this property is set, no annotations are inferred, and only the specified classes are annotated.
====
.implementSerializable: Property<Boolean> .implementSerializable: Property<Boolean>
[%collapsible] [%collapsible]
==== ====
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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. * Pkl module name, and the value is the desired replacement.
*/ */
val renames: Map<String, String> = emptyMap(), 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") @Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("deprecated without replacement") @Deprecated("deprecated without replacement")
fun toJavaCodegenOptions() = toJavaCodeGeneratorOptions() fun toJavaCodegenOptions() = toJavaCodeGeneratorOptions()
@@ -93,5 +103,6 @@ data class CliJavaCodeGeneratorOptions(
nonNullAnnotation, nonNullAnnotation,
implementSerializable, implementSerializable,
renames, renames,
springBootConfigurationProperties,
) )
} }
@@ -97,7 +97,24 @@ data class JavaCodeGeneratorOptions(
* from the corresponding name derived from the Pkl module declaration . * from the corresponding name derived from the Pkl module declaration .
*/ */
val renames: Map<String, String> = emptyMap(), 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. */ /** Entrypoint for the Java code generator API. */
class JavaCodeGenerator( class JavaCodeGenerator(
@@ -523,7 +540,25 @@ class JavaCodeGenerator(
} }
fun generateSpringBootAnnotations(builder: TypeSpec.Builder) { 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( builder.addAnnotation(
ClassName.get("org.springframework.boot.context.properties", "ConfigurationProperties") 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.cliMain
import org.pkl.commons.cli.commands.ModulesCommand import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.installCommonOptions import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.commons.isValidConfigurationPropertiesPrefix
import org.pkl.commons.toPath import org.pkl.commons.toPath
import org.pkl.core.Release import org.pkl.core.Release
@@ -131,6 +132,29 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink
) )
.associate() .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 val helpString: String = "Generate Java classes and interfaces from Pkl module(s)"
override fun run() { override fun run() {
@@ -147,6 +171,7 @@ class PklJavaCodegenCommand : ModulesCommand(name = "pkl-codegen-java", helpLink
nonNullAnnotation = nonNullAnnotation, nonNullAnnotation = nonNullAnnotation,
implementSerializable = implementSerializable, implementSerializable = implementSerializable,
renames = renames, renames = renames,
springBootConfigurationProperties = springBootConfigurationProperties,
) )
CliJavaCodeGenerator(options).run() 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 @Test
fun `import module`() { fun `import module`() {
val library = val library =
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -49,6 +49,15 @@ data class CliKotlinCodeGeneratorOptions(
* original Pkl module name, and the value is the desired replacement. * original Pkl module name, and the value is the desired replacement.
*/ */
val renames: Map<String, String> = emptyMap(), 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") @Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("deprecated without replacement") @Deprecated("deprecated without replacement")
@@ -62,5 +71,6 @@ data class CliKotlinCodeGeneratorOptions(
implementSerializable, implementSerializable,
addGeneratedAnnotation, addGeneratedAnnotation,
renames, renames,
springBootConfigurationProperties,
) )
} }
@@ -53,7 +53,25 @@ data class KotlinCodeGeneratorOptions(
* from the corresponding name derived from the Pkl module declaration . * from the corresponding name derived from the Pkl module declaration .
*/ */
val renames: Map<String, String> = emptyMap(), val renames: Map<String, String> = emptyMap(),
)
/**
* Explicitly configures `@ConfigurationProperties` on generated classes.
*
* The key is the Pkl class name (e.g. `my.mod` for a module class, `my.mod#Server` for a nested
* class), and the value is the prefix. An empty prefix generates a bare annotation.
*
* If non-empty, no `@ConfigurationProperties` annotations are inferred.
*/
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'."
}
}
}
}
class KotlinCodeGeneratorException(message: String) : RuntimeException(message) class KotlinCodeGeneratorException(message: String) : RuntimeException(message)
@@ -136,7 +154,11 @@ class KotlinCodeGenerator(
val hasModuleProperties = pModuleClass.properties.any { !it.value.isHidden } val hasModuleProperties = pModuleClass.properties.any { !it.value.isHidden }
val isGenerateModuleClass = val isGenerateModuleClass =
hasModuleProperties || pModuleClass.isOpen || pModuleClass.isAbstract hasModuleProperties ||
pModuleClass.isOpen ||
pModuleClass.isAbstract ||
options.generateSpringBootConfig &&
options.springBootConfigurationProperties.containsKey(pModuleClass.displayName)
fun generateCompanionRelatedCode( fun generateCompanionRelatedCode(
builder: TypeSpec.Builder, builder: TypeSpec.Builder,
@@ -420,7 +442,21 @@ class KotlinCodeGenerator(
} }
fun generateSpringBootAnnotations(builder: TypeSpec.Builder) { fun generateSpringBootAnnotations(builder: TypeSpec.Builder) {
if (isModuleClass) { if (options.springBootConfigurationProperties.isNotEmpty()) {
// explicit configuration: the user is in total control, so don't infer anything
val prefix = options.springBootConfigurationProperties[pClass.displayName] ?: return
val annotation =
AnnotationSpec.builder(
ClassName("org.springframework.boot.context.properties", "ConfigurationProperties")
)
.apply {
if (prefix.isNotEmpty()) {
addMember("%S", prefix)
}
}
.build()
builder.addAnnotation(annotation)
} else if (isModuleClass) {
builder.addAnnotation( builder.addAnnotation(
ClassName("org.springframework.boot.context.properties", "ConfigurationProperties") ClassName("org.springframework.boot.context.properties", "ConfigurationProperties")
) )
@@ -22,12 +22,14 @@ import com.github.ajalt.clikt.parameters.options.associate
import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.validate
import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.parameters.types.path
import java.nio.file.Path import java.nio.file.Path
import org.pkl.commons.cli.CliBaseOptions import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.cliMain import org.pkl.commons.cli.cliMain
import org.pkl.commons.cli.commands.ModulesCommand import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.installCommonOptions import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.commons.isValidConfigurationPropertiesPrefix
import org.pkl.commons.toPath import org.pkl.commons.toPath
import org.pkl.core.Release import org.pkl.core.Release
@@ -72,6 +74,29 @@ class PklKotlinCodegenCommand : ModulesCommand(name = "pkl-codegen-kotlin", help
) )
.flag() .flag()
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 Kotlin 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'"
}
}
}
private val implementSerializable: Boolean by private val implementSerializable: Boolean by
option( option(
names = arrayOf("--implement-serializable"), names = arrayOf("--implement-serializable"),
@@ -113,6 +138,7 @@ class PklKotlinCodegenCommand : ModulesCommand(name = "pkl-codegen-kotlin", help
implementSerializable = implementSerializable, implementSerializable = implementSerializable,
addGeneratedAnnotation = addGeneratedAnnotation, addGeneratedAnnotation = addGeneratedAnnotation,
renames = renames, renames = renames,
springBootConfigurationProperties = springBootConfigurationProperties,
) )
CliKotlinCodeGenerator(options).run() CliKotlinCodeGenerator(options).run()
} }
@@ -142,6 +142,7 @@ class KotlinCodeGeneratorTest {
generateKdoc: Boolean = false, generateKdoc: Boolean = false,
generateSpringBootConfig: Boolean = false, generateSpringBootConfig: Boolean = false,
implementSerializable: Boolean = false, implementSerializable: Boolean = false,
springBootConfigurationProperties: Map<String, String> = emptyMap(),
): KotlinSourceCode { ): KotlinSourceCode {
val module = Evaluator.preconfigured().evaluateSchema(ModuleSource.text(pklCode)) val module = Evaluator.preconfigured().evaluateSchema(ModuleSource.text(pklCode))
@@ -153,6 +154,7 @@ class KotlinCodeGeneratorTest {
generateKdoc = generateKdoc, generateKdoc = generateKdoc,
generateSpringBootConfig = generateSpringBootConfig, generateSpringBootConfig = generateSpringBootConfig,
implementSerializable = implementSerializable, implementSerializable = implementSerializable,
springBootConfigurationProperties = springBootConfigurationProperties,
), ),
) )
return KotlinSourceCode(generator.kotlinFile) return KotlinSourceCode(generator.kotlinFile)
@@ -1566,6 +1568,42 @@ class KotlinCodeGeneratorTest {
) )
} }
@Test
fun `spring boot -- explicitly configured ConfigurationProperties`() {
val kotlinCode =
generateKotlinCode(
"""
module my.mod
class Server
"""
.trimIndent(),
generateSpringBootConfig = true,
springBootConfigurationProperties =
mapOf("my.mod" to "", "my.mod#Server" to "my-server-prefix"),
)
assertThat(kotlinCode).contains("@ConfigurationProperties\ndata class Mod")
assertThat(kotlinCode).contains("@ConfigurationProperties(\"my-server-prefix\")")
}
@Test
fun `spring boot -- explicitly configured properties disables inference`() {
val kotlinCode =
generateKotlinCode(
"""
module my.mod
server: Server
class Server
"""
.trimIndent(),
generateSpringBootConfig = true,
springBootConfigurationProperties = mapOf("foo" to "foo"),
)
assertThat(kotlinCode).doesNotContain("@ConfigurationProperties")
}
@Test @Test
fun `import module`() { fun `import module`() {
val library = val library =
@@ -16,6 +16,7 @@
package org.pkl.gradle; package org.pkl.gradle;
import java.io.File; import java.io.File;
import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -367,6 +368,8 @@ public class PklPlugin implements Plugin<Project> {
spec.getGenerateSpringBootConfig().convention(false); spec.getGenerateSpringBootConfig().convention(false);
spec.getSpringBootConfigurationProperties().convention(Collections.emptyMap());
spec.getImplementSerializable().convention(false); spec.getImplementSerializable().convention(false);
spec.getAddGeneratedAnnotation().convention(false); spec.getAddGeneratedAnnotation().convention(false);
@@ -480,6 +483,7 @@ public class PklPlugin implements Plugin<Project> {
task.getIndent().set(spec.getIndent()); task.getIndent().set(spec.getIndent());
task.getOutputDir().set(spec.getOutputDir()); task.getOutputDir().set(spec.getOutputDir());
task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig()); task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig());
task.getSpringBootConfigurationProperties().set(spec.getSpringBootConfigurationProperties());
task.getImplementSerializable().set(spec.getImplementSerializable()); task.getImplementSerializable().set(spec.getImplementSerializable());
task.getAddGeneratedAnnotation().set(spec.getAddGeneratedAnnotation()); task.getAddGeneratedAnnotation().set(spec.getAddGeneratedAnnotation());
task.getRenames().set(spec.getRenames()); task.getRenames().set(spec.getRenames());
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -32,6 +32,8 @@ public interface CodeGenSpec extends ModulesSpec {
Property<Boolean> getGenerateSpringBootConfig(); Property<Boolean> getGenerateSpringBootConfig();
MapProperty<String, String> getSpringBootConfigurationProperties();
Property<Boolean> getImplementSerializable(); Property<Boolean> getImplementSerializable();
MapProperty<String, String> getRenames(); MapProperty<String, String> getRenames();
@@ -38,6 +38,9 @@ public abstract class CodeGenTask extends ModulesTask {
@Input @Input
public abstract Property<Boolean> getGenerateSpringBootConfig(); public abstract Property<Boolean> getGenerateSpringBootConfig();
@Input
public abstract MapProperty<String, String> getSpringBootConfigurationProperties();
@Input @Input
public abstract Property<Boolean> getImplementSerializable(); public abstract Property<Boolean> getImplementSerializable();
@@ -56,7 +56,8 @@ public abstract class JavaCodeGenTask extends CodeGenTask {
getParamsAnnotation().getOrNull(), getParamsAnnotation().getOrNull(),
getNonNullAnnotation().getOrNull(), getNonNullAnnotation().getOrNull(),
getImplementSerializable().get(), getImplementSerializable().get(),
getRenames().get())) getRenames().get(),
getSpringBootConfigurationProperties().get()))
.run(); .run();
} }
} }
@@ -41,7 +41,8 @@ public abstract class KotlinCodeGenTask extends CodeGenTask {
getGenerateSpringBootConfig().get(), getGenerateSpringBootConfig().get(),
getImplementSerializable().get(), getImplementSerializable().get(),
getAddGeneratedAnnotation().get(), getAddGeneratedAnnotation().get(),
getRenames().get())) getRenames().get(),
getSpringBootConfigurationProperties().get()))
.run(); .run();
} }
} }