diff --git a/api/shadow.api b/api/shadow.api index 0d90a6c24..2933d4e6c 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -521,6 +521,24 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFile public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V } +public class com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer : com/github/jengelman/gradle/plugins/shadow/transformers/PatternFilterableResourceTransformer { + public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer$Companion; + public static final field PATH_SPRING_AUTOCONFIGURE_METADATA Ljava/lang/String; + public static final field PATH_SPRING_FACTORIES Ljava/lang/String; + public static final field PATH_SPRING_HANDLERS Ljava/lang/String; + public static final field PATH_SPRING_SCHEMAS Ljava/lang/String; + public static final field PATH_SPRING_TOOLING Ljava/lang/String; + public fun ()V + public fun (Lorg/gradle/api/tasks/util/PatternSet;)V + public synthetic fun (Lorg/gradle/api/tasks/util/PatternSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun hasTransformedResource ()Z + public fun modifyOutputStream (Lorg/apache/tools/zip/ZipOutputStream;Z)V + public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V +} + +public final class com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer$Companion { +} + public final class com/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext { public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext$Companion; public fun (Ljava/lang/String;Ljava/io/InputStream;)V diff --git a/docs/changes/README.md b/docs/changes/README.md index d519966c6..ad3651505 100644 --- a/docs/changes/README.md +++ b/docs/changes/README.md @@ -3,6 +3,10 @@ ## [Unreleased](https://github.com/GradleUp/shadow/compare/9.3.3...HEAD) - 2026-xx-xx +### Added + +- Add `SpringBootTransformer` to handle Spring Boot configuration files merging. ([#1936](https://github.com/GradleUp/shadow/pull/1936)) + ## [9.3.2](https://github.com/GradleUp/shadow/releases/tag/9.3.2) - 2026-02-27 diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformerTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformerTest.kt new file mode 100644 index 000000000..8c1b63846 --- /dev/null +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformerTest.kt @@ -0,0 +1,220 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.github.jengelman.gradle.plugins.shadow.testkit.getContent +import com.github.jengelman.gradle.plugins.shadow.testkit.invariantEolString +import com.github.jengelman.gradle.plugins.shadow.transformers.SpringBootTransformer.Companion.PATH_SPRING_FACTORIES +import com.github.jengelman.gradle.plugins.shadow.transformers.SpringBootTransformer.Companion.PATH_SPRING_HANDLERS +import kotlin.io.path.appendText +import org.junit.jupiter.api.Test + +class SpringBootTransformerTest : BaseTransformerTest() { + + @Test + fun mergeSpringFactories() { + val one = buildJarOne { + insert( + PATH_SPRING_FACTORIES, + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.FooAutoConfiguration", + ) + } + val two = buildJarTwo { + insert( + PATH_SPRING_FACTORIES, + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.BarAutoConfiguration", + ) + } + projectScript.appendText( + transform(dependenciesBlock = implementationFiles(one, two)) + ) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) } + assertThat(content.invariantEolString) + .isEqualTo( + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=" + + "com.example.FooAutoConfiguration,com.example.BarAutoConfiguration\n" + ) + } + + @Test + fun mergeSpringFactoriesWithMultipleKeys() { + val one = buildJarOne { + insert( + PATH_SPRING_FACTORIES, + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.FooAutoConfiguration\n" + + "org.springframework.context.ApplicationListener=com.example.FooListener", + ) + } + val two = buildJarTwo { + insert( + PATH_SPRING_FACTORIES, + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.BarAutoConfiguration\n" + + "org.springframework.context.ApplicationListener=com.example.BarListener", + ) + } + projectScript.appendText( + transform(dependenciesBlock = implementationFiles(one, two)) + ) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) } + assertThat(content.invariantEolString) + .isEqualTo( + "org.springframework.boot.autoconfigure.EnableAutoConfiguration=" + + "com.example.FooAutoConfiguration,com.example.BarAutoConfiguration\n" + + "org.springframework.context.ApplicationListener=" + + "com.example.FooListener,com.example.BarListener\n" + ) + } + + @Test + fun mergeSpringImports() { + val one = buildJarOne { + insert( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "com.example.FooAutoConfiguration", + ) + } + val two = buildJarTwo { + insert( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "com.example.BarAutoConfiguration", + ) + } + projectScript.appendText( + transform(dependenciesBlock = implementationFiles(one, two)) + ) + + runWithSuccess(shadowJarPath) + + val content = + outputShadowedJar.use { + it.getContent( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" + ) + } + assertThat(content.invariantEolString) + .isEqualTo("com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration") + } + + @Test + fun deduplicateSpringImports() { + val one = buildJarOne { + insert( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration", + ) + } + val two = buildJarTwo { + insert( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "com.example.BarAutoConfiguration\ncom.example.BazAutoConfiguration", + ) + } + projectScript.appendText( + transform(dependenciesBlock = implementationFiles(one, two)) + ) + + runWithSuccess(shadowJarPath) + + val content = + outputShadowedJar.use { + it.getContent( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" + ) + } + assertThat(content.invariantEolString) + .isEqualTo( + "com.example.FooAutoConfiguration\ncom.example.BarAutoConfiguration\ncom.example.BazAutoConfiguration" + ) + } + + @Test + fun mergeSpringHandlers() { + val one = buildJarOne { + insert( + PATH_SPRING_HANDLERS, + "http\\://www.example.com/schema/foo=com.example.FooNamespaceHandler", + ) + } + val two = buildJarTwo { + insert( + PATH_SPRING_HANDLERS, + "http\\://www.example.com/schema/bar=com.example.BarNamespaceHandler", + ) + } + projectScript.appendText( + transform(dependenciesBlock = implementationFiles(one, two)) + ) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(PATH_SPRING_HANDLERS) } + assertThat(content.invariantEolString) + .isEqualTo( + "http\\://www.example.com/schema/bar=com.example.BarNamespaceHandler\n" + + "http\\://www.example.com/schema/foo=com.example.FooNamespaceHandler\n" + ) + } + + @Test + fun relocateClassesInSpringFactories() { + val one = buildJarOne { + insert(PATH_SPRING_FACTORIES, "com.example.SomeInterface=com.example.SomeImplementation") + } + projectScript.appendText( + """ + dependencies { + ${implementationFiles(one)} + } + $shadowJarTask { + transform(${SpringBootTransformer::class.java.name}) + relocate('com.example', 'shadow.example') + } + """ + .trimIndent() + ) + + runWithSuccess(shadowJarPath) + + val content = outputShadowedJar.use { it.getContent(PATH_SPRING_FACTORIES) } + assertThat(content.invariantEolString) + .isEqualTo("shadow.example.SomeInterface=shadow.example.SomeImplementation\n") + } + + @Test + fun relocateClassesInSpringImports() { + val one = buildJarOne { + insert( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports", + "com.example.FooAutoConfiguration", + ) + } + projectScript.appendText( + """ + dependencies { + ${implementationFiles(one)} + } + $shadowJarTask { + transform(${SpringBootTransformer::class.java.name}) + relocate('com.example', 'shadow.example') + } + """ + .trimIndent() + ) + + runWithSuccess(shadowJarPath) + + val content = + outputShadowedJar.use { + it.getContent( + "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" + ) + } + assertThat(content.invariantEolString).isEqualTo("shadow.example.FooAutoConfiguration") + } +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer.kt new file mode 100644 index 000000000..a74bf0530 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/SpringBootTransformer.kt @@ -0,0 +1,126 @@ +package com.github.jengelman.gradle.plugins.shadow.transformers + +import com.github.jengelman.gradle.plugins.shadow.internal.ReproducibleProperties +import com.github.jengelman.gradle.plugins.shadow.internal.zipEntry +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.relocation.relocateClass +import com.github.jengelman.gradle.plugins.shadow.relocation.relocatePath +import java.util.Properties +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.util.PatternSet + +/** + * A resource transformer that handles Spring Boot configuration files to enable proper merging when + * creating a shadow JAR. + * + * The following Spring Boot resource files are handled: + * - `META-INF/spring.factories`: Properties file with comma-separated class name values, merged by + * appending values with a comma separator. + * - `META-INF/spring.handlers`: Properties file with class name values, merged by appending with a + * comma separator. + * - `META-INF/spring.schemas`: Properties file with schema URL-to-path mappings, merged by + * appending with a comma separator. + * - `META-INF/spring.tooling`: Properties file with tooling metadata, merged by appending with a + * comma separator. + * - `META-INF/spring-autoconfigure-metadata.properties`: Properties file with autoconfiguration + * metadata, merged by appending with a comma separator. + * - `META-INF/spring/`*`.imports`: Line-based files where each line is a fully qualified class + * name; lines are deduplicated and merged across JAR files. + * + * Class relocation is applied to both the keys and values of properties files (using path-based + * relocation for slash-notation values, and class-based relocation for dot-notation values), as + * well as to each line of `.imports` files. + * + * @see Issue #1489 + */ +@CacheableTransformer +public open class SpringBootTransformer +@JvmOverloads +constructor( + patternSet: PatternSet = + PatternSet() + .include(PATH_SPRING_FACTORIES) + .include(PATH_SPRING_HANDLERS) + .include(PATH_SPRING_SCHEMAS) + .include(PATH_SPRING_TOOLING) + .include(PATH_SPRING_AUTOCONFIGURE_METADATA) + .include("$SPRING_IMPORTS_PREFIX*.imports") +) : PatternFilterableResourceTransformer(patternSet = patternSet) { + @get:Internal internal val propertiesEntries = mutableMapOf() + + @get:Internal internal val importsEntries = mutableMapOf>() + + override fun transform(context: TransformerContext) { + val path = context.path + if (isImportsFile(path)) { + val entries = importsEntries.getOrPut(path) { linkedSetOf() } + context.inputStream + .bufferedReader() + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .map { context.relocators.relocateClass(it) } + .forEach { entries.add(it) } + } else { + val props = propertiesEntries.getOrPut(path) { ReproducibleProperties() } + val incoming = + Properties().apply { load(context.inputStream.bufferedReader(PROPERTIES_CHARSET)) } + incoming.forEach { rawKey, rawValue -> + val key = context.relocators.relocateClass(rawKey as String) + val value = + (rawValue as String).splitToSequence(",").joinToString(",") { part -> + context.relocators.relocateValue(part.trim()) + } + val existing = props.getProperty(key) + if (existing != null) { + props[key] = "$existing,$value" + } else { + props[key] = value + } + } + } + } + + override fun hasTransformedResource(): Boolean = + propertiesEntries.isNotEmpty() || importsEntries.isNotEmpty() + + override fun modifyOutputStream(os: ZipOutputStream, preserveFileTimestamps: Boolean) { + propertiesEntries.forEach { (path, props) -> + os.putNextEntry(zipEntry(path, preserveFileTimestamps)) + props.writeWithoutComments(PROPERTIES_CHARSET, os) + os.closeEntry() + } + importsEntries.forEach { (path, entries) -> + os.putNextEntry(zipEntry(path, preserveFileTimestamps)) + os.write(entries.joinToString("\n").toByteArray()) + os.closeEntry() + } + } + + public companion object { + public const val PATH_SPRING_FACTORIES: String = "META-INF/spring.factories" + public const val PATH_SPRING_HANDLERS: String = "META-INF/spring.handlers" + public const val PATH_SPRING_SCHEMAS: String = "META-INF/spring.schemas" + public const val PATH_SPRING_TOOLING: String = "META-INF/spring.tooling" + public const val PATH_SPRING_AUTOCONFIGURE_METADATA: String = + "META-INF/spring-autoconfigure-metadata.properties" + + private const val SPRING_IMPORTS_PREFIX = "META-INF/spring/" + + private val PROPERTIES_CHARSET = Charsets.ISO_8859_1 + + internal fun isImportsFile(path: String): Boolean = + path.startsWith(SPRING_IMPORTS_PREFIX) && path.endsWith(".imports") + + /** + * Relocates a value that may be either a dot-notation class name (e.g., `com.example.MyClass`) + * or a slash-notation resource path (e.g., `com/example/schema.xsd`). Path-notation values are + * relocated using [Iterable.relocatePath], and class-notation values using + * [Iterable.relocateClass]. + */ + private fun Iterable.relocateValue(value: String): String { + return if (value.contains('/')) relocatePath(value) else relocateClass(value) + } + } +}