diff --git a/docs/modules/java-binding/partials/cli-codegen-options.adoc b/docs/modules/java-binding/partials/cli-codegen-options.adoc index 4c9a98e14..f583c1fdc 100644 --- a/docs/modules/java-binding/partials/cli-codegen-options.adoc +++ b/docs/modules/java-binding/partials/cli-codegen-options.adoc @@ -22,6 +22,24 @@ Default: (not set) + 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 [%collapsible] ==== diff --git a/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc index ec3152321..f309e3851 100644 --- a/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-codegen-properties.adoc @@ -36,6 +36,21 @@ Example: `generateSpringBootConfig = true` + Whether to generate config classes for use with Spring Boot. ==== +.springBootConfigurationProperties: MapProperty +[%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 [%collapsible] ==== diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt index 3e35f674e..c2bf16848 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/CliJavaCodeGeneratorOptions.kt @@ -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 = 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 = emptyMap(), ) { + @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("deprecated without replacement") fun toJavaCodegenOptions() = toJavaCodeGeneratorOptions() @@ -93,5 +103,6 @@ data class CliJavaCodeGeneratorOptions( nonNullAnnotation, implementSerializable, renames, + springBootConfigurationProperties, ) } diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt index c4f0adcfd..e7eaea10d 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/JavaCodeGenerator.kt @@ -97,7 +97,24 @@ data class JavaCodeGeneratorOptions( * from the corresponding name derived from the Pkl module declaration . */ val renames: Map = 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 = 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") ) diff --git a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt index 748a55ecb..3f9297612 100644 --- a/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt +++ b/pkl-codegen-java/src/main/kotlin/org/pkl/codegen/java/Main.kt @@ -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 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() } diff --git a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt index 2b335a52b..5c11f7cce 100644 --- a/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt +++ b/pkl-codegen-java/src/test/kotlin/org/pkl/codegen/java/JavaCodeGeneratorTest.kt @@ -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 = diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt index b930c601a..569e90fce 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/CliKotlinCodeGeneratorOptions.kt @@ -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. @@ -49,6 +49,15 @@ data class CliKotlinCodeGeneratorOptions( * original Pkl module name, and the value is the desired replacement. */ val renames: Map = 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 = emptyMap(), ) { @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("deprecated without replacement") @@ -62,5 +71,6 @@ data class CliKotlinCodeGeneratorOptions( implementSerializable, addGeneratedAnnotation, renames, + springBootConfigurationProperties, ) } diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt index 508f59933..10f84c0e9 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/KotlinCodeGenerator.kt @@ -53,7 +53,25 @@ data class KotlinCodeGeneratorOptions( * from the corresponding name derived from the Pkl module declaration . */ val renames: Map = 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 = 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) @@ -136,7 +154,11 @@ class KotlinCodeGenerator( val hasModuleProperties = pModuleClass.properties.any { !it.value.isHidden } val isGenerateModuleClass = - hasModuleProperties || pModuleClass.isOpen || pModuleClass.isAbstract + hasModuleProperties || + pModuleClass.isOpen || + pModuleClass.isAbstract || + options.generateSpringBootConfig && + options.springBootConfigurationProperties.containsKey(pModuleClass.displayName) fun generateCompanionRelatedCode( builder: TypeSpec.Builder, @@ -420,7 +442,21 @@ class KotlinCodeGenerator( } 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( ClassName("org.springframework.boot.context.properties", "ConfigurationProperties") ) diff --git a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt index 04df39757..cf05052ff 100644 --- a/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt +++ b/pkl-codegen-kotlin/src/main/kotlin/org/pkl/codegen/kotlin/Main.kt @@ -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.flag 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 java.nio.file.Path 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 @@ -72,6 +74,29 @@ class PklKotlinCodegenCommand : ModulesCommand(name = "pkl-codegen-kotlin", help ) .flag() + private val springBootConfigurationProperties: Map 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 option( names = arrayOf("--implement-serializable"), @@ -113,6 +138,7 @@ class PklKotlinCodegenCommand : ModulesCommand(name = "pkl-codegen-kotlin", help implementSerializable = implementSerializable, addGeneratedAnnotation = addGeneratedAnnotation, renames = renames, + springBootConfigurationProperties = springBootConfigurationProperties, ) CliKotlinCodeGenerator(options).run() } diff --git a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt index c48321843..755cc317e 100644 --- a/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt +++ b/pkl-codegen-kotlin/src/test/kotlin/org/pkl/codegen/kotlin/KotlinCodeGeneratorTest.kt @@ -142,6 +142,7 @@ class KotlinCodeGeneratorTest { generateKdoc: Boolean = false, generateSpringBootConfig: Boolean = false, implementSerializable: Boolean = false, + springBootConfigurationProperties: Map = emptyMap(), ): KotlinSourceCode { val module = Evaluator.preconfigured().evaluateSchema(ModuleSource.text(pklCode)) @@ -153,6 +154,7 @@ class KotlinCodeGeneratorTest { generateKdoc = generateKdoc, generateSpringBootConfig = generateSpringBootConfig, implementSerializable = implementSerializable, + springBootConfigurationProperties = springBootConfigurationProperties, ), ) 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 fun `import module`() { val library = diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index 03208731f..c7fe48a23 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -16,6 +16,7 @@ package org.pkl.gradle; import java.io.File; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -367,6 +368,8 @@ public class PklPlugin implements Plugin { spec.getGenerateSpringBootConfig().convention(false); + spec.getSpringBootConfigurationProperties().convention(Collections.emptyMap()); + spec.getImplementSerializable().convention(false); spec.getAddGeneratedAnnotation().convention(false); @@ -480,6 +483,7 @@ public class PklPlugin implements Plugin { task.getIndent().set(spec.getIndent()); task.getOutputDir().set(spec.getOutputDir()); task.getGenerateSpringBootConfig().set(spec.getGenerateSpringBootConfig()); + task.getSpringBootConfigurationProperties().set(spec.getSpringBootConfigurationProperties()); task.getImplementSerializable().set(spec.getImplementSerializable()); task.getAddGeneratedAnnotation().set(spec.getAddGeneratedAnnotation()); task.getRenames().set(spec.getRenames()); diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java index 6767bd56a..ceca8f642 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/CodeGenSpec.java @@ -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. @@ -32,6 +32,8 @@ public interface CodeGenSpec extends ModulesSpec { Property getGenerateSpringBootConfig(); + MapProperty getSpringBootConfigurationProperties(); + Property getImplementSerializable(); MapProperty getRenames(); diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java index e23ec9e40..3cb23dbcf 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/CodeGenTask.java @@ -38,6 +38,9 @@ public abstract class CodeGenTask extends ModulesTask { @Input public abstract Property getGenerateSpringBootConfig(); + @Input + public abstract MapProperty getSpringBootConfigurationProperties(); + @Input public abstract Property getImplementSerializable(); diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java index 7b4c25d0e..b8c4d5c2b 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/JavaCodeGenTask.java @@ -56,7 +56,8 @@ public abstract class JavaCodeGenTask extends CodeGenTask { getParamsAnnotation().getOrNull(), getNonNullAnnotation().getOrNull(), getImplementSerializable().get(), - getRenames().get())) + getRenames().get(), + getSpringBootConfigurationProperties().get())) .run(); } } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java index 2f1b8c7f8..61ecdd862 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/KotlinCodeGenTask.java @@ -41,7 +41,8 @@ public abstract class KotlinCodeGenTask extends CodeGenTask { getGenerateSpringBootConfig().get(), getImplementSerializable().get(), getAddGeneratedAnnotation().get(), - getRenames().get())) + getRenames().get(), + getSpringBootConfigurationProperties().get())) .run(); } }