From e3881f614965c9e72b620468aebf04c74008a1bc Mon Sep 17 00:00:00 2001 From: Daniel Chao Date: Fri, 11 Sep 2026 15:49:04 -0700 Subject: [PATCH] Fix invalid prefix when generating spring boot config (#1862) Fix invalid prefix when generating spring boot config In Spring Boot, the prefix passed to `@ConfigurationProperties` must be in "canonical form". The presence of this annotation allows spring boot to directly inject properties into a class, but is not actually needed. The correct behavior here is to omit this annotation if the property name does not match Spring Boot's canonical form. --- .../org/pkl/codegen/java/JavaCodeGenerator.kt | 7 ++- .../pkl/codegen/java/JavaCodeGeneratorTest.kt | 50 +++++++++++++++++++ .../pkl/codegen/kotlin/KotlinCodeGenerator.kt | 6 ++- .../codegen/kotlin/KotlinCodeGeneratorTest.kt | 50 +++++++++++++++++++ .../main/kotlin/org/pkl/commons/Strings.kt | 20 +++++++- .../kotlin/org/pkl/commons/StringsTest.kt | 43 ++++++++++++++++ 6 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 pkl-commons/src/test/kotlin/org/pkl/commons/StringsTest.kt 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 891eb200a..7a6762276 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 @@ -36,6 +36,7 @@ import kotlin.let import kotlin.takeIf import kotlin.to import org.pkl.commons.NameMapper +import org.pkl.commons.isValidConfigurationPropertiesPrefix import org.pkl.core.* import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.IoUtils @@ -540,7 +541,11 @@ class JavaCodeGenerator( } propertyType is PType.Class && propertyType.pClass == pClass } - if (modulePropertiesWithMatchingType.size == 1) { + + val singleProperty = modulePropertiesWithMatchingType.singleOrNull() + if ( + singleProperty != null && singleProperty.simpleName.isValidConfigurationPropertiesPrefix + ) { // exactly one module property has this type -> make it available for direct injection // (potential improvement: make type available for direct injection if it occurs exactly // once in property tree) 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 b7323da21..17e68f578 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 @@ -1555,6 +1555,56 @@ class JavaCodeGeneratorTest { assertThat(javaCodeWithoutSpringAnnotations).compilesSuccessfully() } + @Test + fun `spring boot -- single module property has type but invalid prefix`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + fooBar: FooBar + + class FooBar + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateSpringBootConfig = true), + ) + // "fooBar" is not a valid prefix for `@ConfigurationProperties` annotations, so no + // annotation is added + assertThat(javaCode) + .contains( + """ + | + | public static final class FooBar { + """ + .trimMargin() + ) + } + + @Test + fun `spring boot -- single module property has type and valid prefix`() { + val javaCode = + generateJavaCode( + """ + module my.mod + + server: Server + + class Server + """ + .trimIndent(), + JavaCodeGeneratorOptions(generateSpringBootConfig = true), + ) + assertThat(javaCode) + .contains( + """ + | @ConfigurationProperties("server") + | public static final class Server { + """ + .trimMargin() + ) + } + @Test fun `import module`() { val library = 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 c2327c747..1733663da 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 @@ -22,6 +22,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import java.io.StringWriter import java.util.* import org.pkl.commons.NameMapper +import org.pkl.commons.isValidConfigurationPropertiesPrefix import org.pkl.core.* import org.pkl.core.util.CodeGeneratorUtils import org.pkl.core.util.IoUtils @@ -437,7 +438,10 @@ class KotlinCodeGenerator( } propertyType is PType.Class && propertyType.pClass == pClass } - if (modulePropertiesWithMatchingType.size == 1) { + val singleProperty = modulePropertiesWithMatchingType.singleOrNull() + if ( + singleProperty != null && singleProperty.simpleName.isValidConfigurationPropertiesPrefix + ) { // exactly one module property has this type -> make it available for direct injection // (potential improvement: make type available for direct injection if it occurs exactly // once in property tree) 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 0db629455..64f7202a8 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 @@ -1470,6 +1470,56 @@ class KotlinCodeGeneratorTest { assertThat(kotlinCodeWithoutSpringAnnotations).compilesSuccessfully() } + @Test + fun `spring boot -- single module property has type but invalid prefix`() { + val javaCode = + generateKotlinCode( + """ + module my.mod + + fooBar: FooBar + + class FooBar + """ + .trimIndent(), + generateSpringBootConfig = true, + ) + // "fooBar" is not a valid prefix for `@ConfigurationProperties` annotations, so no + // annotation is added + assertThat(javaCode) + .contains( + """ + | + | data class FooBar + """ + .trimMargin() + ) + } + + @Test + fun `spring boot -- single module property has type and valid prefix`() { + val javaCode = + generateKotlinCode( + """ + module my.mod + + server: Server + + class Server + """ + .trimIndent(), + generateSpringBootConfig = true, + ) + assertThat(javaCode) + .contains( + """ + | @ConfigurationProperties("server") + | data class Server + """ + .trimMargin() + ) + } + @Test fun `import module`() { val library = diff --git a/pkl-commons/src/main/kotlin/org/pkl/commons/Strings.kt b/pkl-commons/src/main/kotlin/org/pkl/commons/Strings.kt index b51914431..69b43e114 100644 --- a/pkl-commons/src/main/kotlin/org/pkl/commons/Strings.kt +++ b/pkl-commons/src/main/kotlin/org/pkl/commons/Strings.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. @@ -86,3 +86,21 @@ fun shlex(input: String): List { return result } + +private val springPrefixRegex = + Regex( + """ + (?mx) + ^ + [a-z] # starts with lowercase letter + [a-z0-9]* # followed by zero or more lowercase letters or digits + (?:-[a-z0-9]+)* # followed by possibly kebab-cased + (?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)* # followed by dot-separated nested prefixes + $ + """ + .trimIndent() + ) + +/** Tells if this string is a valid prefix in Spring Boot's `@ConfigurationProperties` annotation */ +val String.isValidConfigurationPropertiesPrefix: Boolean + get() = matches(springPrefixRegex) diff --git a/pkl-commons/src/test/kotlin/org/pkl/commons/StringsTest.kt b/pkl-commons/src/test/kotlin/org/pkl/commons/StringsTest.kt new file mode 100644 index 000000000..3e074f5c2 --- /dev/null +++ b/pkl-commons/src/test/kotlin/org/pkl/commons/StringsTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright © 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.commons + +import org.junit.jupiter.api.Test + +class StringsTest { + @Test + fun isValidConfigurationPropertiesPrefix() { + val passes = listOf("app.datasource", "my-app.service-config", "app2.v1-api") + val negatives = + listOf( + "myApp.service", + "my_app.service", + "1app.service", + "app..service", + ".app.service / app.", + ) + for (value in passes) { + assert(value.isValidConfigurationPropertiesPrefix) { + "$value should have been valid but was not" + } + } + for (value in negatives) { + assert(!value.isValidConfigurationPropertiesPrefix) { + "$value should not have been valid but was" + } + } + } +}