From 3ffcf4b9d56301f787a86fde9bd484f715a808d0 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:00 +0100 Subject: [PATCH 01/15] Add configuration load strategy API Introduce ConfigurationLoadStrategy and ConfigurationLoadStrategyType to configure how duplicate configuration resources are handled. --- .../env/ConfigurationLoadStrategy.java | 108 ++++++++++++++++++ .../env/ConfigurationLoadStrategyType.java | 42 +++++++ 2 files changed, 150 insertions(+) create mode 100644 inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java create mode 100644 inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java new file mode 100644 index 00000000000..0fb20e6bce9 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java @@ -0,0 +1,108 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.context.env; + +import io.micronaut.context.exceptions.ConfigurationException; +import org.jspecify.annotations.NullMarked; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Configuration resource loading strategy. + * + * @param type The strategy type. Defaults to {@link ConfigurationLoadStrategyType#FAIL_ON_DUPLICATE}. + * @param warnOnDuplicates Whether to warn when duplicates are found. Applies only to {@link ConfigurationLoadStrategyType#FIRST_MATCH}. + * @param mergeOrder Artifact name regex patterns used to order resources before merging. Applies only to {@link ConfigurationLoadStrategyType#MERGE_ALL}. + * @since 5.0.0 + */ +@NullMarked +public record ConfigurationLoadStrategy( + ConfigurationLoadStrategyType type, + boolean warnOnDuplicates, + List mergeOrder +) { + /** + * Default strategy. + */ + public static ConfigurationLoadStrategy defaultStrategy() { + return builder().build(); + } + + /** + * @return A new {@link Builder}. + */ + public static Builder builder() { + return new Builder(); + } + + public ConfigurationLoadStrategy { + if (type == null) { + type = ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE; + } + if (mergeOrder == null) { + mergeOrder = List.of(); + } else if (!(mergeOrder instanceof ArrayList)) { + mergeOrder = new ArrayList<>(mergeOrder); + } + + if (!mergeOrder.isEmpty() && type != ConfigurationLoadStrategyType.MERGE_ALL) { + throw new ConfigurationException("mergeOrder is only supported when configuration loading strategy type is MERGE_ALL"); + } + + mergeOrder = Collections.unmodifiableList(mergeOrder); + } + + /** + * Mutable builder for {@link ConfigurationLoadStrategy}. + */ + @NullMarked + public static final class Builder { + private ConfigurationLoadStrategyType type = ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE; + private boolean warnOnDuplicates = true; + private List mergeOrder = List.of(); + + public Builder type(ConfigurationLoadStrategyType type) { + this.type = Objects.requireNonNullElse(type, ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE); + return this; + } + + public Builder warnOnDuplicates(boolean warnOnDuplicates) { + this.warnOnDuplicates = warnOnDuplicates; + return this; + } + + public Builder mergeOrder(List mergeOrder) { + this.mergeOrder = Objects.requireNonNullElse(mergeOrder, List.of()); + return this; + } + + public Builder mergeOrder(String... mergeOrder) { + if (mergeOrder == null || mergeOrder.length == 0) { + this.mergeOrder = List.of(); + } else { + this.mergeOrder = List.of(mergeOrder); + } + return this; + } + + public ConfigurationLoadStrategy build() { + return new ConfigurationLoadStrategy(type, warnOnDuplicates, mergeOrder); + } + } +} diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java new file mode 100644 index 00000000000..8bcf35b3cd2 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.context.env; + +import org.jspecify.annotations.NullMarked; + +/** + * Defines how Micronaut should behave when the same configuration resource (for example + * {@code application.yml} or {@code application.properties}) is found more than once on the classpath. + * + * @since 5.0.0 + */ +@NullMarked +public enum ConfigurationLoadStrategyType { + /** + * The first matching resource is used. Duplicates may be logged as a warning. + */ + FIRST_MATCH, + + /** + * All matching resources are read and merged in the configured order. + */ + MERGE_ALL, + + /** + * Fail fast if duplicate configuration resources are detected. + */ + FAIL_ON_DUPLICATE +} From 08bf46538303201a564cd422e4f4067f22ad1745 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:05 +0100 Subject: [PATCH 02/15] Expose configuration load strategy on builders Allow configuring the ConfigurationLoadStrategy via ApplicationContextBuilder and propagate it through DefaultApplicationContextBuilder and Micronaut. --- .../java/io/micronaut/runtime/Micronaut.java | 7 +++++++ .../context/ApplicationContextBuilder.java | 13 +++++++++++++ .../ApplicationContextConfiguration.java | 11 +++++++++++ .../DefaultApplicationContextBuilder.java | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/context/src/main/java/io/micronaut/runtime/Micronaut.java b/context/src/main/java/io/micronaut/runtime/Micronaut.java index c87690aa637..01c0bd48155 100644 --- a/context/src/main/java/io/micronaut/runtime/Micronaut.java +++ b/context/src/main/java/io/micronaut/runtime/Micronaut.java @@ -23,6 +23,7 @@ import io.micronaut.context.banner.MicronautBanner; import io.micronaut.context.banner.ResourceBanner; import io.micronaut.context.env.Environment; +import io.micronaut.context.env.ConfigurationLoadStrategy; import io.micronaut.context.env.PropertySource; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -37,6 +38,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.function.Function; import static io.micronaut.core.reflect.ReflectionUtils.EMPTY_CLASS_ARRAY; @@ -280,6 +282,11 @@ public Micronaut environments(String @Nullable ... environments) { return (Micronaut) super.environments(environments); } + @Override + public Micronaut configurationLoadingStrategy(Consumer builderConsumer) { + return (Micronaut) super.configurationLoadingStrategy(builderConsumer); + } + @Override public Micronaut defaultEnvironments(String @Nullable ... environments) { return (Micronaut) super.defaultEnvironments(environments); diff --git a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java index 1fdb8424058..f1c1285e244 100644 --- a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java @@ -16,6 +16,7 @@ package io.micronaut.context; import io.micronaut.context.annotation.ConfigurationReader; +import io.micronaut.context.env.ConfigurationLoadStrategy; import io.micronaut.context.env.PropertySource; import io.micronaut.context.env.PropertySourcesLocator; import io.micronaut.core.io.scan.ClassPathResourceLoader; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Consumer; /** * An interface for building an application context. @@ -109,6 +111,17 @@ default ApplicationContextBuilder enableDefaultPropertySources(boolean areEnable return this; } + /** + * Configure how Micronaut loads configuration resources when duplicates exist on the classpath. + * + * @param builderConsumer The strategy builder customizer + * @return This builder + * @since 5.0.0 + */ + default ApplicationContextBuilder configurationLoadingStrategy(Consumer builderConsumer) { + return this; + } + /** * Specifies to eager init the given annotated types. * diff --git a/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java b/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java index 18eb8647618..343f976e3eb 100644 --- a/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java +++ b/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java @@ -17,6 +17,7 @@ import io.micronaut.context.env.EnvironmentNamesDeducer; import io.micronaut.context.env.EnvironmentPackagesDeducer; +import io.micronaut.context.env.ConfigurationLoadStrategy; import io.micronaut.context.env.PropertySourcesLocator; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -207,4 +208,14 @@ default Collection getPropertySourcesLocators() { return Collections.emptyList(); } + /** + * Defines how configuration resources are loaded when duplicates exist on the classpath. + * + * @return The configuration loading strategy + * @since 5.0.0 + */ + default ConfigurationLoadStrategy getConfigurationLoadingStrategy() { + return ConfigurationLoadStrategy.defaultStrategy(); + } + } diff --git a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java index e77620561d3..7dec87fb341 100644 --- a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java @@ -16,6 +16,7 @@ package io.micronaut.context; import io.micronaut.context.env.CommandLinePropertySource; +import io.micronaut.context.env.ConfigurationLoadStrategy; import io.micronaut.context.env.Environment; import io.micronaut.context.env.PropertySource; import io.micronaut.context.env.PropertySourcesLocator; @@ -26,6 +27,7 @@ import io.micronaut.core.io.scan.ClassPathResourceLoader; import io.micronaut.core.io.service.SoftServiceLoader; import io.micronaut.core.order.OrderUtil; +import io.micronaut.core.util.ArgumentUtils; import io.micronaut.core.util.StringUtils; import io.micronaut.inject.BeanConfiguration; import io.micronaut.inject.QualifiedBeanType; @@ -41,6 +43,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Predicate; import static io.micronaut.core.util.StringUtils.EMPTY_STRING_ARRAY; @@ -85,6 +88,7 @@ public class DefaultApplicationContextBuilder implements ApplicationContextBuild private Boolean bootstrapEnvironment = null; private boolean enableDefaultPropertySources = true; private BeanResolutionTraceConfiguration traceConfiguration = new BeanResolutionTraceConfiguration(); + private ConfigurationLoadStrategy configurationLoadStrategy = ConfigurationLoadStrategy.defaultStrategy(); private BeanDefinitionsProvider beanDefinitionsProvider = new DefaultBeanDefinitionsProvider(); private boolean eagerBeansEnabled = true; private boolean eventsEnabled = true; @@ -122,6 +126,11 @@ public BeanResolutionTraceConfiguration getTraceConfiguration() { return this.traceConfiguration; } + @Override + public ConfigurationLoadStrategy getConfigurationLoadingStrategy() { + return configurationLoadStrategy; + } + private ClassLoader resolveClassLoader() { final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); if (contextClassLoader != null) { @@ -146,6 +155,15 @@ public ApplicationContextBuilder enableDefaultPropertySources(boolean areEnabled return this; } + @Override + public ApplicationContextBuilder configurationLoadingStrategy(Consumer builderConsumer) { + ArgumentUtils.requireNonNull("builderConsumer", builderConsumer); + ConfigurationLoadStrategy.Builder builder = ConfigurationLoadStrategy.builder(); + builderConsumer.accept(builder); + this.configurationLoadStrategy = builder.build(); + return this; + } + @Override public boolean isEnableDefaultPropertySources() { return enableDefaultPropertySources; From b25b2483b76d065a5b70604ae37449a37281beb2 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:12 +0100 Subject: [PATCH 03/15] Fix constant property source loader initialization Resolve constant property sources dynamically so default loaders reflect StaticOptimizations changes in tests and early environment construction. --- .../env/ConstantPropertySourceLoader.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/inject/src/main/java/io/micronaut/context/env/ConstantPropertySourceLoader.java b/inject/src/main/java/io/micronaut/context/env/ConstantPropertySourceLoader.java index c65a3c40588..0450db8c2c8 100644 --- a/inject/src/main/java/io/micronaut/context/env/ConstantPropertySourceLoader.java +++ b/inject/src/main/java/io/micronaut/context/env/ConstantPropertySourceLoader.java @@ -39,22 +39,20 @@ @Internal public final class ConstantPropertySourceLoader implements PropertySourceLoader { - private final List constantPropertySources; - - public ConstantPropertySourceLoader() { - constantPropertySources = StaticOptimizations.get(ConstantPropertySources.class) - .map(ConstantPropertySources::getSources) - .orElse(Collections.emptyList()); + private static List getConstantPropertySources() { + return StaticOptimizations.get(ConstantPropertySources.class) + .map(ConstantPropertySources::getSources) + .orElse(Collections.emptyList()); } @Override public boolean isEnabled() { - return !constantPropertySources.isEmpty(); + return !getConstantPropertySources().isEmpty(); } @Override public Optional load(String resourceName, ResourceLoader resourceLoader) { - for (PropertySource p : constantPropertySources) { + for (PropertySource p : getConstantPropertySources()) { if (p.getName().equals(resourceName)) { return Optional.of(p); } @@ -64,7 +62,7 @@ public Optional load(String resourceName, ResourceLoader resourc @Override public Optional loadEnv(String resourceName, ResourceLoader resourceLoader, ActiveEnvironment activeEnvironment) { - for (PropertySource p : constantPropertySources) { + for (PropertySource p : getConstantPropertySources()) { if (p.getName().equals(resourceName + "-" + activeEnvironment.getName())) { return Optional.of(p); } From 84ed2bb9a2969e8034959b3d55174d75d204fb97 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:17 +0100 Subject: [PATCH 04/15] Handle duplicate configuration resources Add configurable strategies (fail fast, first match, merge all) when the same application configuration resource appears multiple times on the classpath. --- .../context/env/DefaultEnvironment.java | 174 +++++++++++++++++- 1 file changed, 171 insertions(+), 3 deletions(-) diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index d4570693c1f..f867ba2dfbf 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -53,14 +53,17 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; import java.util.stream.Stream; /** @@ -525,17 +528,182 @@ private Collection evaluatePropertySourceLoaders() { } private void loadPropertySourceFromLoader(String name, PropertySourceLoader propertySourceLoader, List propertySources, ResourceLoader resourceLoader) { - Optional defaultPropertySource = propertySourceLoader.load(name, resourceLoader); + ConfigurationLoadStrategy strategy = configuration.getConfigurationLoadingStrategy(); + + Optional defaultPropertySource = loadPropertySourceFromLoader(name, propertySourceLoader, resourceLoader, strategy); defaultPropertySource.ifPresent(propertySources::add); + Set activeNames = getActiveNames(); int i = 0; - for (String activeName: activeNames) { - Optional propertySource = propertySourceLoader.loadEnv(name, resourceLoader, ActiveEnvironment.of(activeName, i)); + for (String activeName : activeNames) { + ActiveEnvironment activeEnvironment = ActiveEnvironment.of(activeName, i); + Optional propertySource = loadPropertySourceFromLoader(name, propertySourceLoader, resourceLoader, activeEnvironment, strategy); propertySource.ifPresent(propertySources::add); i++; } } + private Optional loadPropertySourceFromLoader(String name, + PropertySourceLoader propertySourceLoader, + ResourceLoader resourceLoader, + ConfigurationLoadStrategy strategy) { + if (propertySourceLoader instanceof AbstractPropertySourceLoader abstractPropertySourceLoader) { + return loadPropertySourceFromAbstractLoader(name, abstractPropertySourceLoader, resourceLoader, abstractPropertySourceLoader.getOrder(), strategy); + } + return propertySourceLoader.load(name, resourceLoader); + } + + private Optional loadPropertySourceFromLoader(String name, + PropertySourceLoader propertySourceLoader, + ResourceLoader resourceLoader, + ActiveEnvironment activeEnvironment, + ConfigurationLoadStrategy strategy) { + if (propertySourceLoader instanceof AbstractPropertySourceLoader abstractPropertySourceLoader) { + String envName = name + "-" + activeEnvironment.getName(); + int order = abstractPropertySourceLoader.getOrder() + 1 + activeEnvironment.getPriority(); + return loadPropertySourceFromAbstractLoader(envName, abstractPropertySourceLoader, resourceLoader, order, strategy); + } + return propertySourceLoader.loadEnv(name, resourceLoader, activeEnvironment); + } + + private Optional loadPropertySourceFromAbstractLoader(String fileName, + AbstractPropertySourceLoader propertySourceLoader, + ResourceLoader resourceLoader, + int order, + ConfigurationLoadStrategy strategy) { + if (!propertySourceLoader.isEnabled()) { + return Optional.empty(); + } + + for (String ext : propertySourceLoader.getExtensions()) { + String fileExt = fileName + "." + ext; + List urls = resourceLoader.getResources(fileExt).toList(); + + Map merged = Collections.emptyMap(); + if (strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL && urls.size() > 1) { + List orderedUrls = urls; + if (!strategy.mergeOrder().isEmpty()) { + orderedUrls = orderByArtifactPatterns(urls, strategy.mergeOrder()); + } + + if (LOG.isInfoEnabled()) { + LOG.info("Merging configuration resources '{}' in order: {}", fileExt, orderedUrls); + } + + Map mergedMap = new LinkedHashMap<>(64); + for (URL url : orderedUrls) { + try (InputStream input = url.openStream()) { + mergedMap.putAll(propertySourceLoader.read(fileName, input)); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "] from [" + url + "]: " + e.getMessage(), e); + } + } + merged = mergedMap; + } else { + if (urls.size() > 1) { + handleDuplicateResources(fileExt, urls, strategy); + } + + Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); + if (config.isPresent()) { + try (InputStream input = config.get()) { + merged = propertySourceLoader.read(fileName, input); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "]: " + e.getMessage(), e); + } + } + } + + if (!merged.isEmpty()) { + return Optional.of( + propertySourceLoader.createPropertySource(fileName, merged, order, PropertySource.Origin.of(fileExt)) + ); + } + } + + return Optional.empty(); + } + + private void handleDuplicateResources(String resourceName, + List urls, + ConfigurationLoadStrategy strategy) { + ConfigurationLoadStrategyType type = strategy.type(); + if (type == ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE) { + throw new ConfigurationException(buildDuplicateConfigurationMessage(resourceName, urls)); + } + if (type == ConfigurationLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates() && LOG.isWarnEnabled()) { + URL chosen = urls.getFirst(); + List duplicates = urls.subList(1, urls.size()); + LOG.warn("Duplicate configuration resource '{}' found on the classpath. Using: {}. Duplicates: {}", resourceName, chosen, duplicates); + } + } + + private static String buildDuplicateConfigurationMessage(String resourceName, List urls) { + StringBuilder sb = new StringBuilder(128); + sb.append("Duplicate configuration resource '").append(resourceName).append("' found on the classpath:"); + for (URL url : urls) { + sb.append("\n - ").append(url.toExternalForm()); + } + return sb.toString(); + } + + private static List orderByArtifactPatterns(List urls, List artifactPatterns) { + final List patterns = new ArrayList<>(artifactPatterns.size()); + for (String p : artifactPatterns) { + try { + patterns.add(Pattern.compile(p)); + } catch (Exception e) { + throw new ConfigurationException("Invalid mergeOrder regex pattern: " + p, e); + } + } + + record IndexedUrl(int originalIndex, int patternIndex, URL url) { + } + + List indexed = new ArrayList<>(urls.size()); + for (int i = 0; i < urls.size(); i++) { + URL url = urls.get(i); + String artifactName = artifactName(url); + int index = patterns.size(); + for (int p = 0; p < patterns.size(); p++) { + if (patterns.get(p).matcher(artifactName).matches()) { + index = p; + break; + } + } + indexed.add(new IndexedUrl(i, index, url)); + } + + indexed.sort(Comparator + .comparingInt(IndexedUrl::patternIndex) + .thenComparingInt(IndexedUrl::originalIndex)); + + List ordered = new ArrayList<>(urls.size()); + for (IndexedUrl idx : indexed) { + ordered.add(idx.url()); + } + return ordered; + } + + private static String artifactName(URL url) { + String external = url.toExternalForm(); + + int bangIndex = external.indexOf("!/"); + String withoutEntry = bangIndex > -1 ? external.substring(0, bangIndex) : external; + if (withoutEntry.startsWith("jar:")) { + withoutEntry = withoutEntry.substring(4); + } + if (withoutEntry.startsWith("file:")) { + withoutEntry = withoutEntry.substring(5); + } + + int slashIndex = withoutEntry.lastIndexOf('/'); + if (slashIndex > -1 && slashIndex < withoutEntry.length() - 1) { + return withoutEntry.substring(slashIndex + 1); + } + return withoutEntry; + } + /** * Read the property source. * From 09a8e777d28c194f2712879583ba6ea5dff936f7 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:23 +0100 Subject: [PATCH 05/15] Add tests for configuration loading strategy Cover default fail-fast behavior and the FIRST_MATCH and MERGE_ALL strategies, including mergeOrder and environment-specific duplicates. --- .../env/ConfigurationLoadStrategySpec.groovy | 196 ++++++++++++++++++ .../env/ConstantPropertySourceSpec.groovy | 8 +- 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy new file mode 100644 index 00000000000..211ddc74ced --- /dev/null +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy @@ -0,0 +1,196 @@ +package io.micronaut.context.env + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.exceptions.ConfigurationException +import spock.lang.Specification + +import java.net.URL +import java.net.URLClassLoader +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream + +class ConfigurationLoadStrategySpec extends Specification { + + void "default strategy fails on duplicate configuration resources"() { + given: + def jars = duplicateJars( + "app-1.0.jar", + "lib-1.0.jar", + "application.properties", + "foo=app\n", + "foo=lib\n" + ) + + when: + try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { + ApplicationContext.builder(cl).start() + } + + then: + def e = thrown(ConfigurationException) + e.message.contains("Duplicate configuration resource 'application.properties'") + e.message.contains("app-1.0.jar") + e.message.contains("lib-1.0.jar") + } + + void "FIRST_MATCH uses first match and can disable warning"() { + given: + def jars = duplicateJars( + "app-1.0.jar", + "lib-1.0.jar", + "application.properties", + "foo=app\n", + "foo=lib\n" + ) + + when: + String value + try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy { b -> + b.type(ConfigurationLoadStrategyType.FIRST_MATCH) + b.warnOnDuplicates(false) + } + .start()) { + + value = ctx.environment.getProperty("foo", String).orElse(null) + } + + then: + value == "app" + } + + void "MERGE_ALL merges duplicates in classpath order"() { + given: + def jars = duplicateJars( + "app-1.0.jar", + "lib-1.0.jar", + "application.properties", + "foo=app\nappOnly=yes\n", + "foo=lib\nlibOnly=yes\n" + ) + + when: + Map props + try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy { b -> + b.type(ConfigurationLoadStrategyType.MERGE_ALL) + } + .start()) { + props = [ + foo: ctx.environment.getProperty("foo", String).orElse(null), + appOnly: ctx.environment.getProperty("appOnly", String).orElse(null), + libOnly: ctx.environment.getProperty("libOnly", String).orElse(null) + ] + } + + then: + props.foo == "lib" + props.appOnly == "yes" + props.libOnly == "yes" + } + + void "MERGE_ALL mergeOrder can reorder by jar name"() { + given: + def jars = duplicateJars( + "app-1.0.jar", + "lib-1.0.jar", + "application.properties", + "foo=app\n", + "foo=lib\n" + ) + + when: + String value + try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy { b -> + b.type(ConfigurationLoadStrategyType.MERGE_ALL) + b.mergeOrder("lib-.*\\.jar", "app-.*\\.jar") + } + .start()) { + + value = ctx.environment.getProperty("foo", String).orElse(null) + } + + then: + value == "app" + } + + void "mergeOrder is rejected when strategy type is not MERGE_ALL"() { + given: + def jars = duplicateJars( + "app-1.0.jar", + "lib-1.0.jar", + "application.properties", + "foo=app\n", + "foo=lib\n" + ) + + when: + try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { + ApplicationContext.builder(cl) + .configurationLoadingStrategy { b -> + b.mergeOrder("app-.*\\.jar") + } + } + + then: + thrown(ConfigurationException) + } + + void "duplicates are detected for environment-specific resources too"() { + given: + Path dir = Files.createTempDirectory("mn-config-strategy") + Path jar1 = createJar(dir.resolve("app-1.0.jar"), [ + ("application-test.properties"): "bar=app\n" + ]) + Path jar2 = createJar(dir.resolve("lib-1.0.jar"), [ + ("application-test.properties"): "bar=lib\n" + ]) + + when: + try (URLClassLoader cl = new URLClassLoader([jar1.toUri().toURL(), jar2.toUri().toURL()] as URL[], getClass().classLoader)) { + ApplicationContext.builder(cl) + .environments("test") + .start() + } + + then: + def e = thrown(ConfigurationException) + e.message.contains("application-test.properties") + } + + private static List duplicateJars(String jar1Name, + String jar2Name, + String resourceName, + String jar1Content, + String jar2Content) { + Path dir = Files.createTempDirectory("mn-config-strategy") + Path jar1 = createJar(dir.resolve(jar1Name), [(resourceName): jar1Content]) + Path jar2 = createJar(dir.resolve(jar2Name), [(resourceName): jar2Content]) + return [jar1, jar2] + } + + private static Path createJar(Path jarPath, Map entries) { + Files.createDirectories(jarPath.parent) + jarPath.toFile().withOutputStream { os -> + JarOutputStream jos = new JarOutputStream(os) + try { + entries.each { String name, String content -> + JarEntry entry = new JarEntry(name) + jos.putNextEntry(entry) + jos.write(content.getBytes(StandardCharsets.UTF_8)) + jos.closeEntry() + } + } finally { + jos.close() + } + } + return jarPath + } +} diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy index 0945d9ed519..e631c556c15 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy @@ -18,7 +18,13 @@ class ConstantPropertySourceSpec extends Specification { )) when: - def env = new DefaultEnvironment(Micronaut.build().environments(name)) + def configuration = Micronaut.build() + .environments(name) + .configurationLoadingStrategy { b -> + b.type(ConfigurationLoadStrategyType.FIRST_MATCH) + b.warnOnDuplicates(false) + } + def env = new DefaultEnvironment(configuration) env.start() then: From 04344996b80b5b3163984a27af55550cbe0a812d Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 11:56:31 +0100 Subject: [PATCH 06/15] Document duplicate configuration resource handling Document the new default fail-fast behavior and how to configure FIRST_MATCH or MERGE_ALL using snippet-backed examples. --- src/main/docs/guide/appendix/breaks.adoc | 10 +++ .../docs/guide/config/propertySource.adoc | 24 ++++++ .../ConfigurationLoadStrategySnippet.groovy | 71 ++++++++++++++++++ .../env/ConfigurationLoadStrategySnippet.kt | 71 ++++++++++++++++++ .../env/ConfigurationLoadStrategySnippet.java | 74 +++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt create mode 100644 test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java diff --git a/src/main/docs/guide/appendix/breaks.adoc b/src/main/docs/guide/appendix/breaks.adoc index 40a163b46f8..81a784771cc 100644 --- a/src/main/docs/guide/appendix/breaks.adoc +++ b/src/main/docs/guide/appendix/breaks.adoc @@ -5,6 +5,16 @@ This section documents breaking changes between Micronaut versions === Core Changes +==== Duplicate configuration resources now fail fast by default + +In Micronaut Framework 5, if a configuration file such as `application.properties` or `application.yml` is present more than once on the classpath, Micronaut now fails fast by default with a api:io.micronaut.context.exceptions.ConfigurationException[] describing the conflicting locations. + +See <> for more options (including merging duplicates). + +To restore the previous behavior (first match wins), configure the application context builder: + +snippet::io.micronaut.docs.context.env.ConfigurationLoadStrategySnippet[tags="restoreFirstMatch",indent=0,title="Restoring FIRST_MATCH behavior"] + ==== Update to Jackson 3 Micronaut Jackson Databind uses https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.0[Jackson 3]. diff --git a/src/main/docs/guide/config/propertySource.adoc b/src/main/docs/guide/config/propertySource.adoc index 6e7000526cd..28df39b3bd4 100644 --- a/src/main/docs/guide/config/propertySource.adoc +++ b/src/main/docs/guide/config/propertySource.adoc @@ -25,6 +25,30 @@ NOTE: 'micronaut.config.files' will be ignored in bootstrap.yml or application.y TIP: `.properties`, `.json`, `.yml` are supported out of the box. For Groovy users `.groovy` is supported as well. +[[duplicateConfigurationResources]] +=== Duplicate Configuration Resources + +If a configuration file (for example `application.properties` or `application.yml`) is present more than once on the classpath, Micronaut can be configured to: + +* fail fast with a clear error describing the conflicting locations, +* take the first match (with optional warning), or +* merge all matching resources. + +The behavior can be customized using the api:io.micronaut.context.ApplicationContextBuilder[] (including `Micronaut`). + +snippet::io.micronaut.docs.context.env.ConfigurationLoadStrategySnippet[tags="firstMatch",indent=0,title="Using FIRST_MATCH"] + +To merge duplicates, set the strategy type to `MERGE_ALL`: + +snippet::io.micronaut.docs.context.env.ConfigurationLoadStrategySnippet[tags="mergeAll",indent=0,title="Merging duplicates"] + +When using `MERGE_ALL`, you can optionally specify a merge order based on artifact (JAR) name patterns: + +snippet::io.micronaut.docs.context.env.ConfigurationLoadStrategySnippet[tags="mergeOrder",indent=0,title="MERGE_ALL with mergeOrder"] + +NOTE: `mergeOrder` is only supported when the strategy type is `MERGE_ALL`. +When resources are merged, later resources override earlier ones when the same property key is present. + Note that if you want full control of where your application loads configuration from you can disable the default `PropertySourceLoader` implementations listed above by calling the `enableDefaultPropertySources(false)` method of the api:context.ApplicationContextBuilder[] interface when starting your application. In this case only explicit api:context.env.PropertySource[] instances that you add via the `propertySources(..)` method of the api:context.ApplicationContextBuilder[] interface will be used. diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy new file mode 100644 index 00000000000..d8b7c8695f7 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy @@ -0,0 +1,71 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.docs.context.env + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.env.ConfigurationLoadStrategyType +import io.micronaut.runtime.Micronaut + +final class ConfigurationLoadStrategySnippet { + + void firstMatch(String[] args) { + // tag::firstMatch[] + Micronaut.build(args) + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.FIRST_MATCH) + s.warnOnDuplicates(true) + } + .start() + // end::firstMatch[] + } + + void mergeAll() { + // tag::mergeAll[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.MERGE_ALL) + } + .start() + // end::mergeAll[] + + ctx.close() + } + + void mergeOrder() { + // tag::mergeOrder[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.MERGE_ALL) + s.mergeOrder('lib-.*\\.jar', 'app-.*\\.jar') + } + .start() + // end::mergeOrder[] + + ctx.close() + } + + void restoreFirstMatch() { + // tag::restoreFirstMatch[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.FIRST_MATCH) + } + .start() + // end::restoreFirstMatch[] + + ctx.close() + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt new file mode 100644 index 00000000000..61605df2982 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.docs.context.env + +import io.micronaut.context.ApplicationContext +import io.micronaut.context.env.ConfigurationLoadStrategyType +import io.micronaut.runtime.Micronaut + +internal class ConfigurationLoadStrategySnippet { + + fun firstMatch(args: Array) { + // tag::firstMatch[] + Micronaut.build(*args) + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.FIRST_MATCH) + s.warnOnDuplicates(true) + } + .start() + // end::firstMatch[] + } + + fun mergeAll() { + // tag::mergeAll[] + val ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.MERGE_ALL) + } + .start() + // end::mergeAll[] + + ctx.close() + } + + fun mergeOrder() { + // tag::mergeOrder[] + val ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.MERGE_ALL) + s.mergeOrder("lib-.*\\.jar", "app-.*\\.jar") + } + .start() + // end::mergeOrder[] + + ctx.close() + } + + fun restoreFirstMatch() { + // tag::restoreFirstMatch[] + val ctx = ApplicationContext.builder() + .configurationLoadingStrategy { s -> + s.type(ConfigurationLoadStrategyType.FIRST_MATCH) + } + .start() + // end::restoreFirstMatch[] + + ctx.close() + } +} diff --git a/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java new file mode 100644 index 00000000000..e0ecba5b890 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.docs.context.env; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.ConfigurationLoadStrategyType; +import io.micronaut.runtime.Micronaut; + +final class ConfigurationLoadStrategySnippet { + + private ConfigurationLoadStrategySnippet() { + } + + void firstMatch(String[] args) { + // tag::firstMatch[] + Micronaut.build(args) + .configurationLoadingStrategy(s -> s + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(true) + ) + .start(); + // end::firstMatch[] + } + + void mergeAll() { + // tag::mergeAll[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy(s -> s + .type(ConfigurationLoadStrategyType.MERGE_ALL) + ) + .start(); + // end::mergeAll[] + + ctx.close(); + } + + void mergeOrder() { + // tag::mergeOrder[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy(s -> s + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar") + ) + .start(); + // end::mergeOrder[] + + ctx.close(); + } + + void restoreFirstMatch() { + // tag::restoreFirstMatch[] + ApplicationContext ctx = ApplicationContext.builder() + .configurationLoadingStrategy(s -> s + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + ) + .start(); + // end::restoreFirstMatch[] + + ctx.close(); + } +} From c15759e79c9011a1af020038e71862bb7c3e2b0e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:31:00 +0100 Subject: [PATCH 07/15] Address PR review feedback for duplicate configuration resource handling (#12384) * Fix ConfigurationLoadStrategy to always defensively copy mergeOrder Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> * Optimize performance for FIRST_MATCH with no warnings Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> * Add cleanup for temp directories/JARs in tests Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> * Add cross-extension duplicate detection in DefaultEnvironment Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> * Address code review feedback and add documentation Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: graemerocher <66626+graemerocher@users.noreply.github.com> --- .../env/ConfigurationLoadStrategy.java | 3 +- .../context/env/DefaultEnvironment.java | 87 ++++++++++++++----- .../env/ConfigurationLoadStrategySpec.groovy | 72 ++++++++++++--- 3 files changed, 129 insertions(+), 33 deletions(-) diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java index 0fb20e6bce9..5c8c4200811 100644 --- a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java +++ b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java @@ -57,7 +57,8 @@ public static Builder builder() { } if (mergeOrder == null) { mergeOrder = List.of(); - } else if (!(mergeOrder instanceof ArrayList)) { + } else { + // Always create a defensive copy to prevent external mutation mergeOrder = new ArrayList<>(mergeOrder); } diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index f867ba2dfbf..909f79383d6 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -59,6 +59,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -575,35 +576,81 @@ private Optional loadPropertySourceFromAbstractLoader(String fil return Optional.empty(); } - for (String ext : propertySourceLoader.getExtensions()) { + // Precompute resources for all supported extensions so we can detect + // conflicts across extensions for the same logical base name. + String[] extensions = propertySourceLoader.getExtensions().toArray(new String[0]); + Map> extensionResources = new LinkedHashMap<>(extensions.length); + int extensionsWithResources = 0; + for (String ext : extensions) { String fileExt = fileName + "." + ext; List urls = resourceLoader.getResources(fileExt).toList(); + extensionResources.put(ext, urls); + if (!urls.isEmpty()) { + extensionsWithResources++; + } + } + // If multiple extensions provide resources for the same base name, + // treat this as a duplicate configuration across extensions and let + // the existing strategy decide how to react. This keeps the existing + // precedence (first non-empty extension wins) but no longer ignores + // cross-extension conflicts silently. + if (extensionsWithResources > 1) { + List allUrls = new ArrayList<>(); + for (List urls : extensionResources.values()) { + allUrls.addAll(urls); + } + if (!allUrls.isEmpty()) { + handleDuplicateResources(fileName, allUrls, strategy); + } + } + + for (String ext : extensions) { + String fileExt = fileName + "." + ext; + List urls = Objects.requireNonNullElse(extensionResources.get(ext), Collections.emptyList()); + + // Short-circuit when duplicates are neither warned about nor merged/failed + boolean needsAllResources = strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL + || strategy.type() == ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE + || (strategy.type() == ConfigurationLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates()); + Map merged = Collections.emptyMap(); - if (strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL && urls.size() > 1) { - List orderedUrls = urls; - if (!strategy.mergeOrder().isEmpty()) { - orderedUrls = orderByArtifactPatterns(urls, strategy.mergeOrder()); - } + if (needsAllResources) { + if (strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL && urls.size() > 1) { + List orderedUrls = urls; + if (!strategy.mergeOrder().isEmpty()) { + orderedUrls = orderByArtifactPatterns(urls, strategy.mergeOrder()); + } - if (LOG.isInfoEnabled()) { - LOG.info("Merging configuration resources '{}' in order: {}", fileExt, orderedUrls); - } + if (LOG.isInfoEnabled()) { + LOG.info("Merging configuration resources '{}' in order: {}", fileExt, orderedUrls); + } - Map mergedMap = new LinkedHashMap<>(64); - for (URL url : orderedUrls) { - try (InputStream input = url.openStream()) { - mergedMap.putAll(propertySourceLoader.read(fileName, input)); - } catch (IOException e) { - throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "] from [" + url + "]: " + e.getMessage(), e); + Map mergedMap = new LinkedHashMap<>(64); + for (URL url : orderedUrls) { + try (InputStream input = url.openStream()) { + mergedMap.putAll(propertySourceLoader.read(fileName, input)); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "] from [" + url + "]: " + e.getMessage(), e); + } + } + merged = mergedMap; + } else { + if (urls.size() > 1) { + handleDuplicateResources(fileExt, urls, strategy); + } + + Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); + if (config.isPresent()) { + try (InputStream input = config.get()) { + merged = propertySourceLoader.read(fileName, input); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "]: " + e.getMessage(), e); + } } } - merged = mergedMap; } else { - if (urls.size() > 1) { - handleDuplicateResources(fileExt, urls, strategy); - } - + // FIRST_MATCH with warnOnDuplicates=false: use first resource without enumerating all Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); if (config.isPresent()) { try (InputStream input = config.get()) { diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy index 211ddc74ced..bc636667d56 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy @@ -7,8 +7,11 @@ import spock.lang.Specification import java.net.URL import java.net.URLClassLoader import java.nio.charset.StandardCharsets +import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes import java.util.jar.JarEntry import java.util.jar.JarOutputStream @@ -16,7 +19,7 @@ class ConfigurationLoadStrategySpec extends Specification { void "default strategy fails on duplicate configuration resources"() { given: - def jars = duplicateJars( + def result = duplicateJars( "app-1.0.jar", "lib-1.0.jar", "application.properties", @@ -25,7 +28,7 @@ class ConfigurationLoadStrategySpec extends Specification { ) when: - try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { ApplicationContext.builder(cl).start() } @@ -34,11 +37,14 @@ class ConfigurationLoadStrategySpec extends Specification { e.message.contains("Duplicate configuration resource 'application.properties'") e.message.contains("app-1.0.jar") e.message.contains("lib-1.0.jar") + + cleanup: + deleteDirectory(result.dir) } void "FIRST_MATCH uses first match and can disable warning"() { given: - def jars = duplicateJars( + def result = duplicateJars( "app-1.0.jar", "lib-1.0.jar", "application.properties", @@ -48,7 +54,7 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value - try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) .configurationLoadingStrategy { b -> b.type(ConfigurationLoadStrategyType.FIRST_MATCH) @@ -61,11 +67,14 @@ class ConfigurationLoadStrategySpec extends Specification { then: value == "app" + + cleanup: + deleteDirectory(result.dir) } void "MERGE_ALL merges duplicates in classpath order"() { given: - def jars = duplicateJars( + def result = duplicateJars( "app-1.0.jar", "lib-1.0.jar", "application.properties", @@ -75,7 +84,7 @@ class ConfigurationLoadStrategySpec extends Specification { when: Map props - try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) .configurationLoadingStrategy { b -> b.type(ConfigurationLoadStrategyType.MERGE_ALL) @@ -92,11 +101,14 @@ class ConfigurationLoadStrategySpec extends Specification { props.foo == "lib" props.appOnly == "yes" props.libOnly == "yes" + + cleanup: + deleteDirectory(result.dir) } void "MERGE_ALL mergeOrder can reorder by jar name"() { given: - def jars = duplicateJars( + def result = duplicateJars( "app-1.0.jar", "lib-1.0.jar", "application.properties", @@ -106,7 +118,7 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value - try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) .configurationLoadingStrategy { b -> b.type(ConfigurationLoadStrategyType.MERGE_ALL) @@ -119,11 +131,14 @@ class ConfigurationLoadStrategySpec extends Specification { then: value == "app" + + cleanup: + deleteDirectory(result.dir) } void "mergeOrder is rejected when strategy type is not MERGE_ALL"() { given: - def jars = duplicateJars( + def result = duplicateJars( "app-1.0.jar", "lib-1.0.jar", "application.properties", @@ -132,7 +147,7 @@ class ConfigurationLoadStrategySpec extends Specification { ) when: - try (URLClassLoader cl = new URLClassLoader(jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { ApplicationContext.builder(cl) .configurationLoadingStrategy { b -> b.mergeOrder("app-.*\\.jar") @@ -141,6 +156,9 @@ class ConfigurationLoadStrategySpec extends Specification { then: thrown(ConfigurationException) + + cleanup: + deleteDirectory(result.dir) } void "duplicates are detected for environment-specific resources too"() { @@ -163,9 +181,21 @@ class ConfigurationLoadStrategySpec extends Specification { then: def e = thrown(ConfigurationException) e.message.contains("application-test.properties") + + cleanup: + deleteDirectory(dir) } - private static List duplicateJars(String jar1Name, + /** + * Helper method to create duplicate JAR files for testing. + * @param jar1Name Name of the first JAR file + * @param jar2Name Name of the second JAR file + * @param resourceName Name of the resource to include in both JARs + * @param jar1Content Content of the resource in the first JAR + * @param jar2Content Content of the resource in the second JAR + * @return A map containing 'dir' (Path to temp directory) and 'jars' (List of Path to JAR files) + */ + private static Map duplicateJars(String jar1Name, String jar2Name, String resourceName, String jar1Content, @@ -173,7 +203,7 @@ class ConfigurationLoadStrategySpec extends Specification { Path dir = Files.createTempDirectory("mn-config-strategy") Path jar1 = createJar(dir.resolve(jar1Name), [(resourceName): jar1Content]) Path jar2 = createJar(dir.resolve(jar2Name), [(resourceName): jar2Content]) - return [jar1, jar2] + return [dir: dir, jars: [jar1, jar2]] } private static Path createJar(Path jarPath, Map entries) { @@ -193,4 +223,22 @@ class ConfigurationLoadStrategySpec extends Specification { } return jarPath } + + private static void deleteDirectory(Path directory) { + if (directory != null && Files.exists(directory)) { + Files.walkFileTree(directory, new SimpleFileVisitor() { + @Override + FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + @Override + FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }) + } + } } From 35b5f968afc75709d9df6bf6ffbb5f43a1244226 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Mon, 9 Feb 2026 15:21:31 +0100 Subject: [PATCH 08/15] fix tests --- .../micronaut/context/env/DefaultEnvironment.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index 909f79383d6..0f89021c712 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -583,7 +583,7 @@ private Optional loadPropertySourceFromAbstractLoader(String fil int extensionsWithResources = 0; for (String ext : extensions) { String fileExt = fileName + "." + ext; - List urls = resourceLoader.getResources(fileExt).toList(); + List urls = listUniqueResources(resourceLoader, fileExt); extensionResources.put(ext, urls); if (!urls.isEmpty()) { extensionsWithResources++; @@ -751,6 +751,18 @@ private static String artifactName(URL url) { return withoutEntry; } + private static List listUniqueResources(ResourceLoader resourceLoader, String resourceName) { + Stream stream = resourceLoader.getResources(resourceName); + if (stream == null) { + return List.of(); + } + try (stream) { + LinkedHashMap unique = new LinkedHashMap<>(); + stream.forEach(url -> unique.putIfAbsent(url.toExternalForm(), url)); + return List.copyOf(unique.values()); + } + } + /** * Read the property source. * From 79e3daf8806ecb1b49e560e658ecf176d76b8b43 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 11 Feb 2026 08:31:44 +0100 Subject: [PATCH 09/15] add test coverage --- ...ropertySourceFromAbstractLoaderSpec.groovy | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy diff --git a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy new file mode 100644 index 00000000000..26c5f939548 --- /dev/null +++ b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy @@ -0,0 +1,193 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.context.env + +import io.micronaut.context.DefaultApplicationContextBuilder +import io.micronaut.context.exceptions.ConfigurationException +import io.micronaut.core.io.ResourceLoader +import io.micronaut.core.io.scan.ClassPathResourceLoader +import spock.lang.Specification + +import java.nio.charset.StandardCharsets +import java.util.stream.Stream + +class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specification { + + void "loadPropertySourceFromAbstractLoader tolerates null getResources()"() { + given: + def loader = new InMemoryClassPathResourceLoader( + resources: ["application.yml": "foo: bar\n"], + resourcesByName: [:], + returnNullResourceStream: true + ) + + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + } + + when: + def env = new DefaultEnvironment(configuration).start() + + then: + env.getProperty("foo", String).orElse(null) == "bar" + + cleanup: + env?.close() + } + + void "loadPropertySourceFromAbstractLoader de-dupes identical URLs returned by getResources()"() { + given: + URL url = new URL("file:/test/application.yml") + def loader = new InMemoryClassPathResourceLoader( + resources: ["application.yml": "foo: bar\n"], + resourcesByName: ["application.yml": [url, url]], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + } + + when: + def env = new DefaultEnvironment(configuration).start() + + then: + env.getProperty("foo", String).orElse(null) == "bar" + + cleanup: + env?.close() + } + + void "loadPropertySourceFromAbstractLoader fails fast when both application.yml and application.yaml exist"() { + given: + URL yml = new URL("file:/test/application.yml") + URL yaml = new URL("file:/test/application.yaml") + def loader = new InMemoryClassPathResourceLoader( + resources: [:], + resourcesByName: [ + "application.yml" : [yml], + "application.yaml": [yaml] + ], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + } + + when: + new DefaultEnvironment(configuration).start() + + then: + def e = thrown(ConfigurationException) + e.message.contains("Duplicate configuration resource 'application'") + e.message.contains("application.yml") + e.message.contains("application.yaml") + } + + void "loadPropertySourceFromAbstractLoader keeps FIRST_MATCH semantics across yml/yaml when warnings are disabled"() { + given: + URL yml = new URL("file:/test/application.yml") + URL yaml = new URL("file:/test/application.yaml") + def loader = new InMemoryClassPathResourceLoader( + resources: [ + "application.yml" : "foo: from-yml\n", + "application.yaml": "foo: from-yaml\n", + ], + resourcesByName: [ + "application.yml" : [yml], + "application.yaml": [yaml] + ], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + }.configurationLoadingStrategy { b -> + b.type(ConfigurationLoadStrategyType.FIRST_MATCH) + b.warnOnDuplicates(false) + } + + when: + def env = new DefaultEnvironment(configuration).start() + + then: + env.getProperty("foo", String).orElse(null) == "from-yml" + + cleanup: + env?.close() + } + + private static final class InMemoryClassPathResourceLoader implements ClassPathResourceLoader { + Map resources = [:] + Map> resourcesByName = [:] + boolean returnNullResourceStream + + @Override + Optional getResourceAsStream(String path) { + String content = resources.get(path) + if (content == null) { + return Optional.empty() + } + return Optional.of(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) + } + + @Override + Optional getResource(String path) { + List urls = resourcesByName.get(path) + if (urls == null || urls.isEmpty()) { + return Optional.empty() + } + return Optional.of(urls.get(0)) + } + + @Override + Stream getResources(String name) { + if (returnNullResourceStream) { + return null + } + List urls = resourcesByName.get(name) + if (urls == null) { + return Stream.empty() + } + return urls.stream() + } + + @Override + boolean supportsPrefix(String path) { + return false + } + + @Override + ResourceLoader forBase(String basePath) { + return this + } + + @Override + ClassLoader getClassLoader() { + return getClass().getClassLoader() + } + } +} From 435f2d671432b873b566058f96e6593cfa579fe1 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 11 Feb 2026 15:37:36 +0100 Subject: [PATCH 10/15] CR --- .../java/io/micronaut/runtime/Micronaut.java | 5 ++- .../core/io/scan/ClassPathResourceLoader.java | 25 ++++++++++++++ .../context/ApplicationContextBuilder.java | 5 ++- .../DefaultApplicationContextBuilder.java | 9 ++--- .../env/ConfigurationLoadStrategy.java | 7 ++-- .../context/env/DefaultEnvironment.java | 14 +------- ...ropertySourceFromAbstractLoaderSpec.groovy | 7 ++-- .../ConfigurationLoadStrategySnippet.groovy | 25 +++++++------- .../env/ConfigurationLoadStrategySnippet.kt | 33 +++++++++++-------- .../env/ConfigurationLoadStrategySpec.groovy | 24 ++++++-------- .../env/ConstantPropertySourceSpec.groovy | 7 ++-- .../env/ConfigurationLoadStrategySnippet.java | 21 +++++------- 12 files changed, 92 insertions(+), 90 deletions(-) diff --git a/context/src/main/java/io/micronaut/runtime/Micronaut.java b/context/src/main/java/io/micronaut/runtime/Micronaut.java index 01c0bd48155..ebc8b3607a5 100644 --- a/context/src/main/java/io/micronaut/runtime/Micronaut.java +++ b/context/src/main/java/io/micronaut/runtime/Micronaut.java @@ -38,7 +38,6 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import java.util.function.Function; import static io.micronaut.core.reflect.ReflectionUtils.EMPTY_CLASS_ARRAY; @@ -283,8 +282,8 @@ public Micronaut environments(String @Nullable ... environments) { } @Override - public Micronaut configurationLoadingStrategy(Consumer builderConsumer) { - return (Micronaut) super.configurationLoadingStrategy(builderConsumer); + public Micronaut configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { + return (Micronaut) super.configurationLoadingStrategy(builder); } @Override diff --git a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java index 29122c7b0ea..9634c96e00a 100644 --- a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java @@ -20,6 +20,11 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.stream.Stream; + /** * Abstraction to load resources from the classpath. * @@ -63,4 +68,24 @@ static ClassPathResourceLoader defaultLoader(@Nullable ClassLoader classLoader) } return new DefaultClassPathResourceLoader(classLoader); } + + /** + * List resources for the given name, handling duplicate URLs and implementations that may return {@code null}. + * + * @param resourceLoader The resource loader + * @param name The resource name + * @return An immutable list of unique URLs in encounter order + * @since 5.0.0 + */ + static List listUniqueResources(ResourceLoader resourceLoader, String name) { + @Nullable Stream stream = resourceLoader.getResources(name); + if (stream == null) { + return List.of(); + } + try (stream) { + LinkedHashMap unique = new LinkedHashMap<>(); + stream.forEach(url -> unique.putIfAbsent(url.toExternalForm(), url)); + return List.copyOf(unique.values()); + } + } } diff --git a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java index f1c1285e244..078ac12ac09 100644 --- a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java @@ -29,7 +29,6 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.function.Consumer; /** * An interface for building an application context. @@ -114,11 +113,11 @@ default ApplicationContextBuilder enableDefaultPropertySources(boolean areEnable /** * Configure how Micronaut loads configuration resources when duplicates exist on the classpath. * - * @param builderConsumer The strategy builder customizer + * @param builder The strategy builder * @return This builder * @since 5.0.0 */ - default ApplicationContextBuilder configurationLoadingStrategy(Consumer builderConsumer) { + default ApplicationContextBuilder configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { return this; } diff --git a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java index 7dec87fb341..033d2a667d4 100644 --- a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java @@ -27,7 +27,6 @@ import io.micronaut.core.io.scan.ClassPathResourceLoader; import io.micronaut.core.io.service.SoftServiceLoader; import io.micronaut.core.order.OrderUtil; -import io.micronaut.core.util.ArgumentUtils; import io.micronaut.core.util.StringUtils; import io.micronaut.inject.BeanConfiguration; import io.micronaut.inject.QualifiedBeanType; @@ -41,9 +40,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.function.Consumer; import java.util.function.Predicate; import static io.micronaut.core.util.StringUtils.EMPTY_STRING_ARRAY; @@ -156,10 +155,8 @@ public ApplicationContextBuilder enableDefaultPropertySources(boolean areEnabled } @Override - public ApplicationContextBuilder configurationLoadingStrategy(Consumer builderConsumer) { - ArgumentUtils.requireNonNull("builderConsumer", builderConsumer); - ConfigurationLoadStrategy.Builder builder = ConfigurationLoadStrategy.builder(); - builderConsumer.accept(builder); + public ApplicationContextBuilder configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { + Objects.requireNonNull(builder, "builder"); this.configurationLoadStrategy = builder.build(); return this; } diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java index 5c8c4200811..2200f06502e 100644 --- a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java +++ b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java @@ -17,6 +17,7 @@ import io.micronaut.context.exceptions.ConfigurationException; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.Collections; @@ -78,7 +79,7 @@ public static final class Builder { private boolean warnOnDuplicates = true; private List mergeOrder = List.of(); - public Builder type(ConfigurationLoadStrategyType type) { + public Builder type(@Nullable ConfigurationLoadStrategyType type) { this.type = Objects.requireNonNullElse(type, ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE); return this; } @@ -88,12 +89,12 @@ public Builder warnOnDuplicates(boolean warnOnDuplicates) { return this; } - public Builder mergeOrder(List mergeOrder) { + public Builder mergeOrder(@Nullable List mergeOrder) { this.mergeOrder = Objects.requireNonNullElse(mergeOrder, List.of()); return this; } - public Builder mergeOrder(String... mergeOrder) { + public Builder mergeOrder(@Nullable String... mergeOrder) { if (mergeOrder == null || mergeOrder.length == 0) { this.mergeOrder = List.of(); } else { diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index 0f89021c712..ba8d8200884 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -583,7 +583,7 @@ private Optional loadPropertySourceFromAbstractLoader(String fil int extensionsWithResources = 0; for (String ext : extensions) { String fileExt = fileName + "." + ext; - List urls = listUniqueResources(resourceLoader, fileExt); + List urls = ClassPathResourceLoader.listUniqueResources(resourceLoader, fileExt); extensionResources.put(ext, urls); if (!urls.isEmpty()) { extensionsWithResources++; @@ -751,18 +751,6 @@ private static String artifactName(URL url) { return withoutEntry; } - private static List listUniqueResources(ResourceLoader resourceLoader, String resourceName) { - Stream stream = resourceLoader.getResources(resourceName); - if (stream == null) { - return List.of(); - } - try (stream) { - LinkedHashMap unique = new LinkedHashMap<>(); - stream.forEach(url -> unique.putIfAbsent(url.toExternalForm(), url)); - return List.copyOf(unique.values()); - } - } - /** * Read the property source. * diff --git a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy index 26c5f939548..28da936543b 100644 --- a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy +++ b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy @@ -125,10 +125,9 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy { b -> - b.type(ConfigurationLoadStrategyType.FIRST_MATCH) - b.warnOnDuplicates(false) - } + }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)) when: def env = new DefaultEnvironment(configuration).start() diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy index d8b7c8695f7..4e833643d33 100644 --- a/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy @@ -16,6 +16,7 @@ package io.micronaut.docs.context.env import io.micronaut.context.ApplicationContext +import io.micronaut.context.env.ConfigurationLoadStrategy import io.micronaut.context.env.ConfigurationLoadStrategyType import io.micronaut.runtime.Micronaut @@ -24,10 +25,9 @@ final class ConfigurationLoadStrategySnippet { void firstMatch(String[] args) { // tag::firstMatch[] Micronaut.build(args) - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.FIRST_MATCH) - s.warnOnDuplicates(true) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(true)) .start() // end::firstMatch[] } @@ -35,9 +35,8 @@ final class ConfigurationLoadStrategySnippet { void mergeAll() { // tag::mergeAll[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.MERGE_ALL) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL)) .start() // end::mergeAll[] @@ -47,10 +46,9 @@ final class ConfigurationLoadStrategySnippet { void mergeOrder() { // tag::mergeOrder[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.MERGE_ALL) - s.mergeOrder('lib-.*\\.jar', 'app-.*\\.jar') - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder('lib-.*\\.jar', 'app-.*\\.jar')) .start() // end::mergeOrder[] @@ -60,9 +58,8 @@ final class ConfigurationLoadStrategySnippet { void restoreFirstMatch() { // tag::restoreFirstMatch[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.FIRST_MATCH) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH)) .start() // end::restoreFirstMatch[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt index 61605df2982..f70de3c0ffe 100644 --- a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt @@ -16,6 +16,7 @@ package io.micronaut.docs.context.env import io.micronaut.context.ApplicationContext +import io.micronaut.context.env.ConfigurationLoadStrategy import io.micronaut.context.env.ConfigurationLoadStrategyType import io.micronaut.runtime.Micronaut @@ -24,10 +25,11 @@ internal class ConfigurationLoadStrategySnippet { fun firstMatch(args: Array) { // tag::firstMatch[] Micronaut.build(*args) - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.FIRST_MATCH) - s.warnOnDuplicates(true) - } + .configurationLoadingStrategy( + ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(true) + ) .start() // end::firstMatch[] } @@ -35,9 +37,10 @@ internal class ConfigurationLoadStrategySnippet { fun mergeAll() { // tag::mergeAll[] val ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.MERGE_ALL) - } + .configurationLoadingStrategy( + ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + ) .start() // end::mergeAll[] @@ -47,10 +50,11 @@ internal class ConfigurationLoadStrategySnippet { fun mergeOrder() { // tag::mergeOrder[] val ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.MERGE_ALL) - s.mergeOrder("lib-.*\\.jar", "app-.*\\.jar") - } + .configurationLoadingStrategy( + ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar") + ) .start() // end::mergeOrder[] @@ -60,9 +64,10 @@ internal class ConfigurationLoadStrategySnippet { fun restoreFirstMatch() { // tag::restoreFirstMatch[] val ctx = ApplicationContext.builder() - .configurationLoadingStrategy { s -> - s.type(ConfigurationLoadStrategyType.FIRST_MATCH) - } + .configurationLoadingStrategy( + ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + ) .start() // end::restoreFirstMatch[] diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy index bc636667d56..00f82b7b06b 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy @@ -56,10 +56,9 @@ class ConfigurationLoadStrategySpec extends Specification { String value try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy { b -> - b.type(ConfigurationLoadStrategyType.FIRST_MATCH) - b.warnOnDuplicates(false) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)) .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) @@ -86,9 +85,8 @@ class ConfigurationLoadStrategySpec extends Specification { Map props try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy { b -> - b.type(ConfigurationLoadStrategyType.MERGE_ALL) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL)) .start()) { props = [ foo: ctx.environment.getProperty("foo", String).orElse(null), @@ -120,10 +118,9 @@ class ConfigurationLoadStrategySpec extends Specification { String value try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy { b -> - b.type(ConfigurationLoadStrategyType.MERGE_ALL) - b.mergeOrder("lib-.*\\.jar", "app-.*\\.jar") - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) @@ -149,9 +146,8 @@ class ConfigurationLoadStrategySpec extends Specification { when: try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { ApplicationContext.builder(cl) - .configurationLoadingStrategy { b -> - b.mergeOrder("app-.*\\.jar") - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .mergeOrder("app-.*\\.jar")) } then: diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy index e631c556c15..44e09e769d3 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy @@ -20,10 +20,9 @@ class ConstantPropertySourceSpec extends Specification { when: def configuration = Micronaut.build() .environments(name) - .configurationLoadingStrategy { b -> - b.type(ConfigurationLoadStrategyType.FIRST_MATCH) - b.warnOnDuplicates(false) - } + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)) def env = new DefaultEnvironment(configuration) env.start() diff --git a/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java index e0ecba5b890..77c97af1c0b 100644 --- a/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java +++ b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java @@ -16,6 +16,7 @@ package io.micronaut.docs.context.env; import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.ConfigurationLoadStrategy; import io.micronaut.context.env.ConfigurationLoadStrategyType; import io.micronaut.runtime.Micronaut; @@ -27,10 +28,9 @@ private ConfigurationLoadStrategySnippet() { void firstMatch(String[] args) { // tag::firstMatch[] Micronaut.build(args) - .configurationLoadingStrategy(s -> s + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() .type(ConfigurationLoadStrategyType.FIRST_MATCH) - .warnOnDuplicates(true) - ) + .warnOnDuplicates(true)) .start(); // end::firstMatch[] } @@ -38,9 +38,8 @@ void firstMatch(String[] args) { void mergeAll() { // tag::mergeAll[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(s -> s - .type(ConfigurationLoadStrategyType.MERGE_ALL) - ) + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL)) .start(); // end::mergeAll[] @@ -50,10 +49,9 @@ void mergeAll() { void mergeOrder() { // tag::mergeOrder[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(s -> s + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() .type(ConfigurationLoadStrategyType.MERGE_ALL) - .mergeOrder("lib-.*\\.jar", "app-.*\\.jar") - ) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) .start(); // end::mergeOrder[] @@ -63,9 +61,8 @@ void mergeOrder() { void restoreFirstMatch() { // tag::restoreFirstMatch[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(s -> s - .type(ConfigurationLoadStrategyType.FIRST_MATCH) - ) + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH)) .start(); // end::restoreFirstMatch[] From 8304bc4fecf43ca0fd8880ad37ece8e3bd2e414a Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 11 Feb 2026 17:31:30 +0100 Subject: [PATCH 11/15] Fix nullability in configuration load strategy Handle non-conforming ResourceLoader#getResources implementations and avoid assigning null under NullMarked in ConfigurationLoadStrategy.Builder. --- .../io/micronaut/core/io/scan/ClassPathResourceLoader.java | 6 ++---- .../io/micronaut/context/env/ConfigurationLoadStrategy.java | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java index 9634c96e00a..1d351898cab 100644 --- a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java @@ -23,6 +23,7 @@ import java.net.URL; import java.util.LinkedHashMap; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; /** @@ -78,10 +79,7 @@ static ClassPathResourceLoader defaultLoader(@Nullable ClassLoader classLoader) * @since 5.0.0 */ static List listUniqueResources(ResourceLoader resourceLoader, String name) { - @Nullable Stream stream = resourceLoader.getResources(name); - if (stream == null) { - return List.of(); - } + Stream stream = Optional.ofNullable(resourceLoader.getResources(name)).orElseGet(Stream::empty); try (stream) { LinkedHashMap unique = new LinkedHashMap<>(); stream.forEach(url -> unique.putIfAbsent(url.toExternalForm(), url)); diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java index 2200f06502e..a5bfe69eeff 100644 --- a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java +++ b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java @@ -80,7 +80,7 @@ public static final class Builder { private List mergeOrder = List.of(); public Builder type(@Nullable ConfigurationLoadStrategyType type) { - this.type = Objects.requireNonNullElse(type, ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE); + this.type = type == null ? ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE : type; return this; } @@ -90,7 +90,7 @@ public Builder warnOnDuplicates(boolean warnOnDuplicates) { } public Builder mergeOrder(@Nullable List mergeOrder) { - this.mergeOrder = Objects.requireNonNullElse(mergeOrder, List.of()); + this.mergeOrder = mergeOrder == null ? List.of() : mergeOrder; return this; } From bf5e272faeb2eac6547ed401f705303b322e2a9f Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 11 Feb 2026 17:31:45 +0100 Subject: [PATCH 12/15] Add tests for duplicate configuration resource strategies Cover MERGE_ALL merging and mergeOrder behavior, invalid mergeOrder patterns, and FAIL_ON_DUPLICATE duplicates within the same extension. Add a context-level test to exercise Micronaut fluent configurationLoadingStrategy. --- ...onautConfigurationLoadingStrategyTest.java | 38 +++++ ...ropertySourceFromAbstractLoaderSpec.groovy | 154 +++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java diff --git a/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java b/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java new file mode 100644 index 00000000000..1c07344e9a9 --- /dev/null +++ b/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.runtime; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.env.ConfigurationLoadStrategy; +import io.micronaut.context.env.ConfigurationLoadStrategyType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class MicronautConfigurationLoadingStrategyTest { + + @Test + void configurationLoadingStrategyIsFluent() { + Micronaut micronaut = Micronaut.build(new String[0]) + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)); + + try (ApplicationContext ctx = micronaut.start()) { + assertNotNull(ctx); + } + } +} diff --git a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy index 28da936543b..f15fbbd14eb 100644 --- a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy +++ b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy @@ -22,6 +22,9 @@ import io.micronaut.core.io.scan.ClassPathResourceLoader import spock.lang.Specification import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream import java.util.stream.Stream class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specification { @@ -139,6 +142,147 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi env?.close() } + void "loadPropertySourceFromAbstractLoader MERGE_ALL merges duplicate resources"() { + given: + def jar1 = createJarWithEntry("lib-1.jar", "application.yml", "foo: one\nbar: one\n") + def jar2 = createJarWithEntry("app-1.jar", "application.yml", "foo: two\nbaz: two\n") + def loader = new InMemoryClassPathResourceLoader( + resources: [:], + resourcesByName: ["application.yml": [ + jarUrl(jar1, "application.yml"), + jarUrl(jar2, "application.yml") + ]], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL)) + + when: + def env = new DefaultEnvironment(configuration).start() + + then: + env.getProperty("foo", String).orElse(null) == "two" + env.getProperty("bar", String).orElse(null) == "one" + env.getProperty("baz", String).orElse(null) == "two" + + cleanup: + env?.close() + } + + void "loadPropertySourceFromAbstractLoader MERGE_ALL mergeOrder affects merge order"() { + given: + def jarLib = createJarWithEntry("lib-1.jar", "application.yml", "foo: lib\n") + def jarApp = createJarWithEntry("app-1.jar", "application.yml", "foo: app\n") + def loader = new InMemoryClassPathResourceLoader( + resources: [:], + // Intentionally reversed so mergeOrder has an effect + resourcesByName: ["application.yml": [ + jarUrl(jarApp, "application.yml"), + jarUrl(jarLib, "application.yml") + ]], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) + + when: + def env = new DefaultEnvironment(configuration).start() + + then: + env.getProperty("foo", String).orElse(null) == "app" + + cleanup: + env?.close() + } + + void "loadPropertySourceFromAbstractLoader MERGE_ALL rejects invalid mergeOrder patterns"() { + given: + def jar1 = createJarWithEntry("lib-1.jar", "application.yml", "foo: one\n") + def jar2 = createJarWithEntry("app-1.jar", "application.yml", "foo: two\n") + def loader = new InMemoryClassPathResourceLoader( + resources: [:], + resourcesByName: ["application.yml": [ + jarUrl(jar1, "application.yml"), + jarUrl(jar2, "application.yml") + ]], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("[")) + + when: + new DefaultEnvironment(configuration).start() + + then: + def e = thrown(ConfigurationException) + e.message.contains("Invalid mergeOrder regex pattern") + } + + void "loadPropertySourceFromAbstractLoader FAIL_ON_DUPLICATE fails for duplicates within the same extension"() { + given: + def jar1 = createJarWithEntry("a.jar", "application.yml", "foo: one\n") + def jar2 = createJarWithEntry("b.jar", "application.yml", "foo: two\n") + def loader = new InMemoryClassPathResourceLoader( + resources: [:], + resourcesByName: ["application.yml": [ + jarUrl(jar1, "application.yml"), + jarUrl(jar2, "application.yml") + ]], + returnNullResourceStream: false + ) + def configuration = new DefaultApplicationContextBuilder() { + @Override + ClassPathResourceLoader getResourceLoader() { + return loader + } + }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE)) + + when: + new DefaultEnvironment(configuration).start() + + then: + def e = thrown(ConfigurationException) + e.message.contains("Duplicate configuration resource 'application.yml'") + } + + private static File createJarWithEntry(String fileName, String entryName, String content) { + File jar = Files.createTempFile(fileName.replace('.jar', ''), ".jar").toFile() + jar.deleteOnExit() + JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar)) + try { + JarEntry entry = new JarEntry(entryName) + jos.putNextEntry(entry) + jos.write(content.getBytes(StandardCharsets.UTF_8)) + jos.closeEntry() + } finally { + jos.close() + } + return jar + } + + private static URL jarUrl(File jar, String entryName) { + URL fileUrl = jar.toURI().toURL() + return new URL("jar:" + fileUrl.toExternalForm() + "!/" + entryName) + } + private static final class InMemoryClassPathResourceLoader implements ClassPathResourceLoader { Map resources = [:] Map> resourcesByName = [:] @@ -148,7 +292,15 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi Optional getResourceAsStream(String path) { String content = resources.get(path) if (content == null) { - return Optional.empty() + List urls = resourcesByName.get(path) + if (urls == null || urls.isEmpty()) { + return Optional.empty() + } + try { + return Optional.of(urls.get(0).openStream()) + } catch (IOException e) { + return Optional.empty() + } } return Optional.of(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) } From 4996ba9809c23a67e20f88384d0332f6c14d9767 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 12 Feb 2026 04:34:44 -0400 Subject: [PATCH 13/15] Remove stray semicolons in ConfigurationLoadStrategySpec Address Sonar Groovy convention findings. --- .../env/ConfigurationLoadStrategySpec.groovy | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy index 00f82b7b06b..cd49c9108f8 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy @@ -54,12 +54,12 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value - try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) - .warnOnDuplicates(false)) - .start()) { + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)) + .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) } @@ -83,11 +83,11 @@ class ConfigurationLoadStrategySpec extends Specification { when: Map props - try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL)) - .start()) { + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL)) + .start()) { props = [ foo: ctx.environment.getProperty("foo", String).orElse(null), appOnly: ctx.environment.getProperty("appOnly", String).orElse(null), @@ -116,11 +116,11 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value - try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader); + try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) - .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) + .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .type(ConfigurationLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) From 8b64d534792eb1485733f4d7c1007903d96b73e0 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 25 Feb 2026 12:11:15 -0400 Subject: [PATCH 14/15] Move resource load strategy to core ClassPathResourceLoader now resolves duplicates and signals conflicts; DefaultEnvironment handles merge/fail behavior and snippets/tests are updated. --- .../java/io/micronaut/runtime/Micronaut.java | 4 +- ...onautConfigurationLoadingStrategyTest.java | 8 +- .../core/io/ResourceConflictException.java | 64 ++++++++ .../core/io/ResourceDuplicateException.java | 63 ++++++++ .../core/io/ResourceLoadStrategy.java | 150 ++++++++++++++++++ .../core/io/ResourceLoadStrategyType.java | 22 +-- .../core/io/scan/ClassPathResourceLoader.java | 35 ++++ .../context/ApplicationContextBuilder.java | 4 +- .../ApplicationContextConfiguration.java | 6 +- .../DefaultApplicationContextBuilder.java | 8 +- .../env/ConfigurationLoadStrategy.java | 110 ------------- .../context/env/DefaultEnvironment.java | 142 +++++++++-------- ...ropertySourceFromAbstractLoaderSpec.groovy | 22 +-- .../ConfigurationLoadStrategySnippet.groovy | 20 +-- .../env/ConfigurationLoadStrategySnippet.kt | 20 +-- .../env/ConfigurationLoadStrategySpec.groovy | 32 ++-- .../env/ConstantPropertySourceSpec.groovy | 6 +- .../env/ConfigurationLoadStrategySnippet.java | 20 +-- 18 files changed, 475 insertions(+), 261 deletions(-) create mode 100644 core/src/main/java/io/micronaut/core/io/ResourceConflictException.java create mode 100644 core/src/main/java/io/micronaut/core/io/ResourceDuplicateException.java create mode 100644 core/src/main/java/io/micronaut/core/io/ResourceLoadStrategy.java rename inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java => core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java (54%) delete mode 100644 inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java diff --git a/context/src/main/java/io/micronaut/runtime/Micronaut.java b/context/src/main/java/io/micronaut/runtime/Micronaut.java index ebc8b3607a5..52d171b78dc 100644 --- a/context/src/main/java/io/micronaut/runtime/Micronaut.java +++ b/context/src/main/java/io/micronaut/runtime/Micronaut.java @@ -23,7 +23,7 @@ import io.micronaut.context.banner.MicronautBanner; import io.micronaut.context.banner.ResourceBanner; import io.micronaut.context.env.Environment; -import io.micronaut.context.env.ConfigurationLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategy; import io.micronaut.context.env.PropertySource; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -282,7 +282,7 @@ public Micronaut environments(String @Nullable ... environments) { } @Override - public Micronaut configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { + public Micronaut configurationLoadingStrategy(ResourceLoadStrategy.Builder builder) { return (Micronaut) super.configurationLoadingStrategy(builder); } diff --git a/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java b/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java index 1c07344e9a9..a3508356977 100644 --- a/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java +++ b/context/src/test/java/io/micronaut/runtime/MicronautConfigurationLoadingStrategyTest.java @@ -16,8 +16,8 @@ package io.micronaut.runtime; import io.micronaut.context.ApplicationContext; -import io.micronaut.context.env.ConfigurationLoadStrategy; -import io.micronaut.context.env.ConfigurationLoadStrategyType; +import io.micronaut.core.io.ResourceLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategyType; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -27,8 +27,8 @@ class MicronautConfigurationLoadingStrategyTest { @Test void configurationLoadingStrategyIsFluent() { Micronaut micronaut = Micronaut.build(new String[0]) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(false)); try (ApplicationContext ctx = micronaut.start()) { diff --git a/core/src/main/java/io/micronaut/core/io/ResourceConflictException.java b/core/src/main/java/io/micronaut/core/io/ResourceConflictException.java new file mode 100644 index 00000000000..a6e46e6863e --- /dev/null +++ b/core/src/main/java/io/micronaut/core/io/ResourceConflictException.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.core.io; + +import org.jspecify.annotations.NullMarked; + +import java.net.URL; +import java.util.List; + +/** + * Exception thrown when multiple resources are found and the configured strategy requires + * the caller to merge them. + * + * @since 5.0.0 + */ +@NullMarked +public final class ResourceConflictException extends RuntimeException { + private final String resourceName; + private final List resources; + + /** + * @param resourceName The resource name + * @param resources The resolved resources + * @since 5.0.0 + */ + public ResourceConflictException(String resourceName, List resources) { + super("Conflicting resources detected: " + resourceName); + this.resourceName = resourceName; + this.resources = List.copyOf(resources); + } + + /** + * Returns the resource name. + * + * @return The resource name + * @since 5.0.0 + */ + public String getResourceName() { + return resourceName; + } + + /** + * Returns the resolved resources. + * + * @return The resolved resources + * @since 5.0.0 + */ + public List getResources() { + return resources; + } +} diff --git a/core/src/main/java/io/micronaut/core/io/ResourceDuplicateException.java b/core/src/main/java/io/micronaut/core/io/ResourceDuplicateException.java new file mode 100644 index 00000000000..2ca0eb27ed9 --- /dev/null +++ b/core/src/main/java/io/micronaut/core/io/ResourceDuplicateException.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.core.io; + +import org.jspecify.annotations.NullMarked; + +import java.net.URL; +import java.util.List; + +/** + * Exception thrown when duplicate resources are detected. + * + * @since 5.0.0 + */ +@NullMarked +public final class ResourceDuplicateException extends RuntimeException { + private final String resourceName; + private final List resources; + + /** + * @param resourceName The resource name + * @param resources The resolved resources + * @since 5.0.0 + */ + public ResourceDuplicateException(String resourceName, List resources) { + super("Duplicate resource detected: " + resourceName); + this.resourceName = resourceName; + this.resources = List.copyOf(resources); + } + + /** + * Returns the resource name. + * + * @return The resource name + * @since 5.0.0 + */ + public String getResourceName() { + return resourceName; + } + + /** + * Returns the resolved resources. + * + * @return The resolved resources + * @since 5.0.0 + */ + public List getResources() { + return resources; + } +} diff --git a/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategy.java b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategy.java new file mode 100644 index 00000000000..f865366aced --- /dev/null +++ b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategy.java @@ -0,0 +1,150 @@ +/* + * Copyright 2017-2026 original authors + * + * 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 io.micronaut.core.io; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Resource loading strategy. + * + * @param type The strategy type. Defaults to {@link ResourceLoadStrategyType#FAIL_ON_DUPLICATE}. + * @param warnOnDuplicates Whether to warn when duplicates are found. Applies only to {@link ResourceLoadStrategyType#FIRST_MATCH}. + * @param mergeOrder Artifact name regex patterns used to order resources before merging. Applies only to {@link ResourceLoadStrategyType#MERGE_ALL}. + * @since 5.0.0 + */ +@NullMarked +public record ResourceLoadStrategy( + ResourceLoadStrategyType type, + boolean warnOnDuplicates, + List mergeOrder +) { + + public ResourceLoadStrategy { + if (type == null) { + type = ResourceLoadStrategyType.FAIL_ON_DUPLICATE; + } + if (mergeOrder == null) { + mergeOrder = List.of(); + } else { + mergeOrder = new ArrayList<>(mergeOrder); + } + + if (!mergeOrder.isEmpty() && type != ResourceLoadStrategyType.MERGE_ALL) { + throw new IllegalArgumentException("mergeOrder is only supported when resource loading strategy type is MERGE_ALL"); + } + + mergeOrder = Collections.unmodifiableList(mergeOrder); + } + + /** + * Returns the default resource load strategy. + * + * @return The default resource load strategy. + * @since 5.0.0 + */ + public static ResourceLoadStrategy defaultStrategy() { + return builder().build(); + } + + /** + * Creates a new builder. + * + * @return A new builder. + * @since 5.0.0 + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link ResourceLoadStrategy}. + * + * @since 5.0.0 + */ + @NullMarked + public static final class Builder { + private ResourceLoadStrategyType type = ResourceLoadStrategyType.FAIL_ON_DUPLICATE; + private boolean warnOnDuplicates = true; + private List mergeOrder = List.of(); + + /** + * Sets the strategy type. + * + * @param type The strategy type + * @return This builder + * @since 5.0.0 + */ + public Builder type(@Nullable ResourceLoadStrategyType type) { + this.type = type == null ? ResourceLoadStrategyType.FAIL_ON_DUPLICATE : type; + return this; + } + + /** + * Applies only to {@link ResourceLoadStrategyType#FIRST_MATCH}. + * + * @param warnOnDuplicates Whether to warn when duplicates are found + * @return This builder + * @since 5.0.0 + */ + public Builder warnOnDuplicates(boolean warnOnDuplicates) { + this.warnOnDuplicates = warnOnDuplicates; + return this; + } + + /** + * Applies only to {@link ResourceLoadStrategyType#MERGE_ALL}. + * + * @param mergeOrder Resource ordering patterns + * @return This builder + * @since 5.0.0 + */ + public Builder mergeOrder(@Nullable List mergeOrder) { + this.mergeOrder = mergeOrder == null ? List.of() : mergeOrder; + return this; + } + + /** + * Applies only to {@link ResourceLoadStrategyType#MERGE_ALL}. + * + * @param mergeOrder Resource ordering patterns + * @return This builder + * @since 5.0.0 + */ + public Builder mergeOrder(@Nullable String... mergeOrder) { + if (mergeOrder == null || mergeOrder.length == 0) { + this.mergeOrder = List.of(); + } else { + this.mergeOrder = List.of(mergeOrder); + } + return this; + } + + /** + * Builds a new {@link ResourceLoadStrategy}. + * + * @return A new {@link ResourceLoadStrategy}. + * @since 5.0.0 + */ + public ResourceLoadStrategy build() { + return new ResourceLoadStrategy(type, warnOnDuplicates, mergeOrder); + } + } +} diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java similarity index 54% rename from inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java rename to core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java index 8bcf35b3cd2..c258ad6a665 100644 --- a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategyType.java +++ b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java @@ -13,30 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.context.env; +package io.micronaut.core.io; import org.jspecify.annotations.NullMarked; /** - * Defines how Micronaut should behave when the same configuration resource (for example - * {@code application.yml} or {@code application.properties}) is found more than once on the classpath. + * Resource loading strategy. * * @since 5.0.0 */ @NullMarked -public enum ConfigurationLoadStrategyType { - /** - * The first matching resource is used. Duplicates may be logged as a warning. - */ +public enum ResourceLoadStrategyType { FIRST_MATCH, - - /** - * All matching resources are read and merged in the configured order. - */ - MERGE_ALL, - - /** - * Fail fast if duplicate configuration resources are detected. - */ - FAIL_ON_DUPLICATE + FAIL_ON_DUPLICATE, + MERGE_ALL } diff --git a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java index 1d351898cab..806cf716379 100644 --- a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java @@ -15,6 +15,10 @@ */ package io.micronaut.core.io.scan; +import io.micronaut.core.io.ResourceConflictException; +import io.micronaut.core.io.ResourceDuplicateException; +import io.micronaut.core.io.ResourceLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategyType; import io.micronaut.core.io.ResourceLoader; import org.jspecify.annotations.NullMarked; @@ -37,6 +41,8 @@ public interface ClassPathResourceLoader extends ResourceLoader { /** + * Returns the underlying classloader used by this {@link ClassPathResourceLoader}. + * * @return The underlying classloader used by this {@link ClassPathResourceLoader} */ ClassLoader getClassLoader(); @@ -86,4 +92,33 @@ static List listUniqueResources(ResourceLoader resourceLoader, String name) return List.copyOf(unique.values()); } } + + /** + * Resolve resources for the given name, applying the configured strategy. + * + * @param resourceLoader The resource loader + * @param name The resource name + * @param strategy The strategy + * @return An immutable list of unique URLs in encounter order + * @throws ResourceDuplicateException If multiple resources are found and the configured strategy is + * {@link ResourceLoadStrategyType#FAIL_ON_DUPLICATE} + * @throws ResourceConflictException If multiple resources are found and the configured strategy is + * {@link ResourceLoadStrategyType#MERGE_ALL} + * @since 5.0.0 + */ + static List resolveResources(ResourceLoader resourceLoader, String name, ResourceLoadStrategy strategy) { + List urls = listUniqueResources(resourceLoader, name); + if (urls.size() <= 1) { + return urls; + } + + ResourceLoadStrategyType type = strategy.type(); + if (type == ResourceLoadStrategyType.FAIL_ON_DUPLICATE) { + throw new ResourceDuplicateException(name, urls); + } + if (type == ResourceLoadStrategyType.MERGE_ALL) { + throw new ResourceConflictException(name, urls); + } + return urls; + } } diff --git a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java index 078ac12ac09..9a3d22b70bb 100644 --- a/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/ApplicationContextBuilder.java @@ -16,7 +16,7 @@ package io.micronaut.context; import io.micronaut.context.annotation.ConfigurationReader; -import io.micronaut.context.env.ConfigurationLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategy; import io.micronaut.context.env.PropertySource; import io.micronaut.context.env.PropertySourcesLocator; import io.micronaut.core.io.scan.ClassPathResourceLoader; @@ -117,7 +117,7 @@ default ApplicationContextBuilder enableDefaultPropertySources(boolean areEnable * @return This builder * @since 5.0.0 */ - default ApplicationContextBuilder configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { + default ApplicationContextBuilder configurationLoadingStrategy(ResourceLoadStrategy.Builder builder) { return this; } diff --git a/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java b/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java index 343f976e3eb..68a9b875d33 100644 --- a/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java +++ b/inject/src/main/java/io/micronaut/context/ApplicationContextConfiguration.java @@ -17,7 +17,7 @@ import io.micronaut.context.env.EnvironmentNamesDeducer; import io.micronaut.context.env.EnvironmentPackagesDeducer; -import io.micronaut.context.env.ConfigurationLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategy; import io.micronaut.context.env.PropertySourcesLocator; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -214,8 +214,8 @@ default Collection getPropertySourcesLocators() { * @return The configuration loading strategy * @since 5.0.0 */ - default ConfigurationLoadStrategy getConfigurationLoadingStrategy() { - return ConfigurationLoadStrategy.defaultStrategy(); + default ResourceLoadStrategy getConfigurationLoadingStrategy() { + return ResourceLoadStrategy.defaultStrategy(); } } diff --git a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java index 033d2a667d4..ddf4becbcca 100644 --- a/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java +++ b/inject/src/main/java/io/micronaut/context/DefaultApplicationContextBuilder.java @@ -16,7 +16,7 @@ package io.micronaut.context; import io.micronaut.context.env.CommandLinePropertySource; -import io.micronaut.context.env.ConfigurationLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategy; import io.micronaut.context.env.Environment; import io.micronaut.context.env.PropertySource; import io.micronaut.context.env.PropertySourcesLocator; @@ -87,7 +87,7 @@ public class DefaultApplicationContextBuilder implements ApplicationContextBuild private Boolean bootstrapEnvironment = null; private boolean enableDefaultPropertySources = true; private BeanResolutionTraceConfiguration traceConfiguration = new BeanResolutionTraceConfiguration(); - private ConfigurationLoadStrategy configurationLoadStrategy = ConfigurationLoadStrategy.defaultStrategy(); + private ResourceLoadStrategy configurationLoadStrategy = ResourceLoadStrategy.defaultStrategy(); private BeanDefinitionsProvider beanDefinitionsProvider = new DefaultBeanDefinitionsProvider(); private boolean eagerBeansEnabled = true; private boolean eventsEnabled = true; @@ -126,7 +126,7 @@ public BeanResolutionTraceConfiguration getTraceConfiguration() { } @Override - public ConfigurationLoadStrategy getConfigurationLoadingStrategy() { + public ResourceLoadStrategy getConfigurationLoadingStrategy() { return configurationLoadStrategy; } @@ -155,7 +155,7 @@ public ApplicationContextBuilder enableDefaultPropertySources(boolean areEnabled } @Override - public ApplicationContextBuilder configurationLoadingStrategy(ConfigurationLoadStrategy.Builder builder) { + public ApplicationContextBuilder configurationLoadingStrategy(ResourceLoadStrategy.Builder builder) { Objects.requireNonNull(builder, "builder"); this.configurationLoadStrategy = builder.build(); return this; diff --git a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java b/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java deleted file mode 100644 index a5bfe69eeff..00000000000 --- a/inject/src/main/java/io/micronaut/context/env/ConfigurationLoadStrategy.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2017-2026 original authors - * - * 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 io.micronaut.context.env; - -import io.micronaut.context.exceptions.ConfigurationException; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -/** - * Configuration resource loading strategy. - * - * @param type The strategy type. Defaults to {@link ConfigurationLoadStrategyType#FAIL_ON_DUPLICATE}. - * @param warnOnDuplicates Whether to warn when duplicates are found. Applies only to {@link ConfigurationLoadStrategyType#FIRST_MATCH}. - * @param mergeOrder Artifact name regex patterns used to order resources before merging. Applies only to {@link ConfigurationLoadStrategyType#MERGE_ALL}. - * @since 5.0.0 - */ -@NullMarked -public record ConfigurationLoadStrategy( - ConfigurationLoadStrategyType type, - boolean warnOnDuplicates, - List mergeOrder -) { - /** - * Default strategy. - */ - public static ConfigurationLoadStrategy defaultStrategy() { - return builder().build(); - } - - /** - * @return A new {@link Builder}. - */ - public static Builder builder() { - return new Builder(); - } - - public ConfigurationLoadStrategy { - if (type == null) { - type = ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE; - } - if (mergeOrder == null) { - mergeOrder = List.of(); - } else { - // Always create a defensive copy to prevent external mutation - mergeOrder = new ArrayList<>(mergeOrder); - } - - if (!mergeOrder.isEmpty() && type != ConfigurationLoadStrategyType.MERGE_ALL) { - throw new ConfigurationException("mergeOrder is only supported when configuration loading strategy type is MERGE_ALL"); - } - - mergeOrder = Collections.unmodifiableList(mergeOrder); - } - - /** - * Mutable builder for {@link ConfigurationLoadStrategy}. - */ - @NullMarked - public static final class Builder { - private ConfigurationLoadStrategyType type = ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE; - private boolean warnOnDuplicates = true; - private List mergeOrder = List.of(); - - public Builder type(@Nullable ConfigurationLoadStrategyType type) { - this.type = type == null ? ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE : type; - return this; - } - - public Builder warnOnDuplicates(boolean warnOnDuplicates) { - this.warnOnDuplicates = warnOnDuplicates; - return this; - } - - public Builder mergeOrder(@Nullable List mergeOrder) { - this.mergeOrder = mergeOrder == null ? List.of() : mergeOrder; - return this; - } - - public Builder mergeOrder(@Nullable String... mergeOrder) { - if (mergeOrder == null || mergeOrder.length == 0) { - this.mergeOrder = List.of(); - } else { - this.mergeOrder = List.of(mergeOrder); - } - return this; - } - - public ConfigurationLoadStrategy build() { - return new ConfigurationLoadStrategy(type, warnOnDuplicates, mergeOrder); - } - } -} diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index ba8d8200884..f1c8ddd8434 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -22,6 +22,10 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.convert.DefaultMutableConversionService; import io.micronaut.core.convert.MutableConversionService; +import io.micronaut.core.io.ResourceConflictException; +import io.micronaut.core.io.ResourceDuplicateException; +import io.micronaut.core.io.ResourceLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategyType; import io.micronaut.core.io.ResourceLoader; import io.micronaut.core.io.ResourceResolver; import io.micronaut.core.io.file.DefaultFileSystemResourceLoader; @@ -529,7 +533,7 @@ private Collection evaluatePropertySourceLoaders() { } private void loadPropertySourceFromLoader(String name, PropertySourceLoader propertySourceLoader, List propertySources, ResourceLoader resourceLoader) { - ConfigurationLoadStrategy strategy = configuration.getConfigurationLoadingStrategy(); + ResourceLoadStrategy strategy = configuration.getConfigurationLoadingStrategy(); Optional defaultPropertySource = loadPropertySourceFromLoader(name, propertySourceLoader, resourceLoader, strategy); defaultPropertySource.ifPresent(propertySources::add); @@ -545,9 +549,9 @@ private void loadPropertySourceFromLoader(String name, PropertySourceLoader prop } private Optional loadPropertySourceFromLoader(String name, - PropertySourceLoader propertySourceLoader, - ResourceLoader resourceLoader, - ConfigurationLoadStrategy strategy) { + PropertySourceLoader propertySourceLoader, + ResourceLoader resourceLoader, + ResourceLoadStrategy strategy) { if (propertySourceLoader instanceof AbstractPropertySourceLoader abstractPropertySourceLoader) { return loadPropertySourceFromAbstractLoader(name, abstractPropertySourceLoader, resourceLoader, abstractPropertySourceLoader.getOrder(), strategy); } @@ -555,10 +559,10 @@ private Optional loadPropertySourceFromLoader(String name, } private Optional loadPropertySourceFromLoader(String name, - PropertySourceLoader propertySourceLoader, - ResourceLoader resourceLoader, - ActiveEnvironment activeEnvironment, - ConfigurationLoadStrategy strategy) { + PropertySourceLoader propertySourceLoader, + ResourceLoader resourceLoader, + ActiveEnvironment activeEnvironment, + ResourceLoadStrategy strategy) { if (propertySourceLoader instanceof AbstractPropertySourceLoader abstractPropertySourceLoader) { String envName = name + "-" + activeEnvironment.getName(); int order = abstractPropertySourceLoader.getOrder() + 1 + activeEnvironment.getPriority(); @@ -571,36 +575,65 @@ private Optional loadPropertySourceFromAbstractLoader(String fil AbstractPropertySourceLoader propertySourceLoader, ResourceLoader resourceLoader, int order, - ConfigurationLoadStrategy strategy) { + ResourceLoadStrategy strategy) { if (!propertySourceLoader.isEnabled()) { return Optional.empty(); } - // Precompute resources for all supported extensions so we can detect - // conflicts across extensions for the same logical base name. + final boolean needsAllResources = strategy.type() == ResourceLoadStrategyType.MERGE_ALL + || strategy.type() == ResourceLoadStrategyType.FAIL_ON_DUPLICATE + || (strategy.type() == ResourceLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates()); + String[] extensions = propertySourceLoader.getExtensions().toArray(new String[0]); + if (!needsAllResources) { + // FIRST_MATCH with warnOnDuplicates=false: use first resource without enumerating all + for (String ext : extensions) { + String fileExt = fileName + "." + ext; + Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); + if (config.isPresent()) { + try (InputStream input = config.get()) { + Map merged = propertySourceLoader.read(fileName, input); + if (!merged.isEmpty()) { + return Optional.of(propertySourceLoader.createPropertySource(fileName, merged, order, PropertySource.Origin.of(fileExt))); + } + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "]: " + e.getMessage(), e); + } + } + } + return Optional.empty(); + } + Map> extensionResources = new LinkedHashMap<>(extensions.length); + Set mergeExtensions = new HashSet<>(); int extensionsWithResources = 0; + for (String ext : extensions) { String fileExt = fileName + "." + ext; - List urls = ClassPathResourceLoader.listUniqueResources(resourceLoader, fileExt); + List urls; + try { + urls = ClassPathResourceLoader.resolveResources(resourceLoader, fileExt, strategy); + } catch (ResourceDuplicateException e) { + throw new ConfigurationException(buildDuplicateConfigurationMessage(e.getResourceName(), e.getResources())); + } catch (ResourceConflictException e) { + urls = e.getResources(); + mergeExtensions.add(ext); + } extensionResources.put(ext, urls); if (!urls.isEmpty()) { extensionsWithResources++; } } - // If multiple extensions provide resources for the same base name, - // treat this as a duplicate configuration across extensions and let - // the existing strategy decide how to react. This keeps the existing - // precedence (first non-empty extension wins) but no longer ignores - // cross-extension conflicts silently. - if (extensionsWithResources > 1) { + if (extensionsWithResources > 1 && strategy.type() != ResourceLoadStrategyType.MERGE_ALL) { List allUrls = new ArrayList<>(); for (List urls : extensionResources.values()) { allUrls.addAll(urls); } if (!allUrls.isEmpty()) { + if (strategy.type() == ResourceLoadStrategyType.FAIL_ON_DUPLICATE) { + throw new ConfigurationException(buildDuplicateConfigurationMessage(fileName, allUrls)); + } handleDuplicateResources(fileName, allUrls, strategy); } } @@ -608,49 +641,40 @@ private Optional loadPropertySourceFromAbstractLoader(String fil for (String ext : extensions) { String fileExt = fileName + "." + ext; List urls = Objects.requireNonNullElse(extensionResources.get(ext), Collections.emptyList()); - - // Short-circuit when duplicates are neither warned about nor merged/failed - boolean needsAllResources = strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL - || strategy.type() == ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE - || (strategy.type() == ConfigurationLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates()); - Map merged = Collections.emptyMap(); - if (needsAllResources) { - if (strategy.type() == ConfigurationLoadStrategyType.MERGE_ALL && urls.size() > 1) { - List orderedUrls = urls; - if (!strategy.mergeOrder().isEmpty()) { - orderedUrls = orderByArtifactPatterns(urls, strategy.mergeOrder()); - } + if (!urls.isEmpty() && urls.size() > 1) { + handleDuplicateResources(fileExt, urls, strategy); + } - if (LOG.isInfoEnabled()) { - LOG.info("Merging configuration resources '{}' in order: {}", fileExt, orderedUrls); + if (urls.isEmpty()) { + Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); + if (config.isPresent()) { + try (InputStream input = config.get()) { + merged = propertySourceLoader.read(fileName, input); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "]: " + e.getMessage(), e); } + } + } else if (mergeExtensions.contains(ext) && urls.size() > 1) { + List orderedUrls = urls; + if (!strategy.mergeOrder().isEmpty()) { + orderedUrls = orderByArtifactPatterns(urls, strategy.mergeOrder()); + } - Map mergedMap = new LinkedHashMap<>(64); - for (URL url : orderedUrls) { - try (InputStream input = url.openStream()) { - mergedMap.putAll(propertySourceLoader.read(fileName, input)); - } catch (IOException e) { - throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "] from [" + url + "]: " + e.getMessage(), e); - } - } - merged = mergedMap; - } else { - if (urls.size() > 1) { - handleDuplicateResources(fileExt, urls, strategy); - } + if (LOG.isInfoEnabled()) { + LOG.info("Merging configuration resources '{}' in order: {}", fileExt, orderedUrls); + } - Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); - if (config.isPresent()) { - try (InputStream input = config.get()) { - merged = propertySourceLoader.read(fileName, input); - } catch (IOException e) { - throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "]: " + e.getMessage(), e); - } + Map mergedMap = new LinkedHashMap<>(64); + for (URL url : orderedUrls) { + try (InputStream input = url.openStream()) { + mergedMap.putAll(propertySourceLoader.read(fileName, input)); + } catch (IOException e) { + throw new ConfigurationException("I/O exception occurred reading [" + fileExt + "] from [" + url + "]: " + e.getMessage(), e); } } + merged = mergedMap; } else { - // FIRST_MATCH with warnOnDuplicates=false: use first resource without enumerating all Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); if (config.isPresent()) { try (InputStream input = config.get()) { @@ -662,9 +686,7 @@ private Optional loadPropertySourceFromAbstractLoader(String fil } if (!merged.isEmpty()) { - return Optional.of( - propertySourceLoader.createPropertySource(fileName, merged, order, PropertySource.Origin.of(fileExt)) - ); + return Optional.of(propertySourceLoader.createPropertySource(fileName, merged, order, PropertySource.Origin.of(fileExt))); } } @@ -673,12 +695,8 @@ private Optional loadPropertySourceFromAbstractLoader(String fil private void handleDuplicateResources(String resourceName, List urls, - ConfigurationLoadStrategy strategy) { - ConfigurationLoadStrategyType type = strategy.type(); - if (type == ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE) { - throw new ConfigurationException(buildDuplicateConfigurationMessage(resourceName, urls)); - } - if (type == ConfigurationLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates() && LOG.isWarnEnabled()) { + ResourceLoadStrategy strategy) { + if (strategy.type() == ResourceLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates() && LOG.isWarnEnabled()) { URL chosen = urls.getFirst(); List duplicates = urls.subList(1, urls.size()); LOG.warn("Duplicate configuration resource '{}' found on the classpath. Using: {}. Duplicates: {}", resourceName, chosen, duplicates); diff --git a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy index f15fbbd14eb..35ad60a5605 100644 --- a/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy +++ b/inject/src/test/groovy/io/micronaut/context/env/DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec.groovy @@ -18,6 +18,8 @@ package io.micronaut.context.env import io.micronaut.context.DefaultApplicationContextBuilder import io.micronaut.context.exceptions.ConfigurationException import io.micronaut.core.io.ResourceLoader +import io.micronaut.core.io.ResourceLoadStrategy +import io.micronaut.core.io.ResourceLoadStrategyType import io.micronaut.core.io.scan.ClassPathResourceLoader import spock.lang.Specification @@ -128,8 +130,8 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + }.configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(false)) when: @@ -159,8 +161,8 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL)) + }.configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL)) when: def env = new DefaultEnvironment(configuration).start() @@ -192,8 +194,8 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + }.configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) when: @@ -223,8 +225,8 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + }.configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) .mergeOrder("[")) when: @@ -252,8 +254,8 @@ class DefaultEnvironmentLoadPropertySourceFromAbstractLoaderSpec extends Specifi ClassPathResourceLoader getResourceLoader() { return loader } - }.configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FAIL_ON_DUPLICATE)) + }.configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FAIL_ON_DUPLICATE)) when: new DefaultEnvironment(configuration).start() diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy index 4e833643d33..239fc8bb849 100644 --- a/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.groovy @@ -16,8 +16,8 @@ package io.micronaut.docs.context.env import io.micronaut.context.ApplicationContext -import io.micronaut.context.env.ConfigurationLoadStrategy -import io.micronaut.context.env.ConfigurationLoadStrategyType +import io.micronaut.core.io.ResourceLoadStrategy +import io.micronaut.core.io.ResourceLoadStrategyType import io.micronaut.runtime.Micronaut final class ConfigurationLoadStrategySnippet { @@ -25,8 +25,8 @@ final class ConfigurationLoadStrategySnippet { void firstMatch(String[] args) { // tag::firstMatch[] Micronaut.build(args) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(true)) .start() // end::firstMatch[] @@ -35,8 +35,8 @@ final class ConfigurationLoadStrategySnippet { void mergeAll() { // tag::mergeAll[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL)) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL)) .start() // end::mergeAll[] @@ -46,8 +46,8 @@ final class ConfigurationLoadStrategySnippet { void mergeOrder() { // tag::mergeOrder[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) .mergeOrder('lib-.*\\.jar', 'app-.*\\.jar')) .start() // end::mergeOrder[] @@ -58,8 +58,8 @@ final class ConfigurationLoadStrategySnippet { void restoreFirstMatch() { // tag::restoreFirstMatch[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH)) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH)) .start() // end::restoreFirstMatch[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt index f70de3c0ffe..0e20b996079 100644 --- a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.kt @@ -16,8 +16,8 @@ package io.micronaut.docs.context.env import io.micronaut.context.ApplicationContext -import io.micronaut.context.env.ConfigurationLoadStrategy -import io.micronaut.context.env.ConfigurationLoadStrategyType +import io.micronaut.core.io.ResourceLoadStrategy +import io.micronaut.core.io.ResourceLoadStrategyType import io.micronaut.runtime.Micronaut internal class ConfigurationLoadStrategySnippet { @@ -26,8 +26,8 @@ internal class ConfigurationLoadStrategySnippet { // tag::firstMatch[] Micronaut.build(*args) .configurationLoadingStrategy( - ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(true) ) .start() @@ -38,8 +38,8 @@ internal class ConfigurationLoadStrategySnippet { // tag::mergeAll[] val ctx = ApplicationContext.builder() .configurationLoadingStrategy( - ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) ) .start() // end::mergeAll[] @@ -51,8 +51,8 @@ internal class ConfigurationLoadStrategySnippet { // tag::mergeOrder[] val ctx = ApplicationContext.builder() .configurationLoadingStrategy( - ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) .mergeOrder("lib-.*\\.jar", "app-.*\\.jar") ) .start() @@ -65,8 +65,8 @@ internal class ConfigurationLoadStrategySnippet { // tag::restoreFirstMatch[] val ctx = ApplicationContext.builder() .configurationLoadingStrategy( - ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) ) .start() // end::restoreFirstMatch[] diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy index cd49c9108f8..32d25fc81ae 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConfigurationLoadStrategySpec.groovy @@ -2,6 +2,8 @@ package io.micronaut.context.env import io.micronaut.context.ApplicationContext import io.micronaut.context.exceptions.ConfigurationException +import io.micronaut.core.io.ResourceLoadStrategy +import io.micronaut.core.io.ResourceLoadStrategyType import spock.lang.Specification import java.net.URL @@ -55,11 +57,11 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) - ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) - .warnOnDuplicates(false)) - .start()) { + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) + .warnOnDuplicates(false)) + .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) } @@ -84,10 +86,10 @@ class ConfigurationLoadStrategySpec extends Specification { when: Map props try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) - ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL)) - .start()) { + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL)) + .start()) { props = [ foo: ctx.environment.getProperty("foo", String).orElse(null), appOnly: ctx.environment.getProperty("appOnly", String).orElse(null), @@ -117,10 +119,10 @@ class ConfigurationLoadStrategySpec extends Specification { when: String value try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader) - ApplicationContext ctx = ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) - .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) + ApplicationContext ctx = ApplicationContext.builder(cl) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) + .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) .start()) { value = ctx.environment.getProperty("foo", String).orElse(null) @@ -146,12 +148,12 @@ class ConfigurationLoadStrategySpec extends Specification { when: try (URLClassLoader cl = new URLClassLoader(result.jars*.toUri()*.toURL() as URL[], getClass().classLoader)) { ApplicationContext.builder(cl) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() + .configurationLoadingStrategy(ResourceLoadStrategy.builder() .mergeOrder("app-.*\\.jar")) } then: - thrown(ConfigurationException) + thrown(IllegalArgumentException) cleanup: deleteDirectory(result.dir) diff --git a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy index 44e09e769d3..f3bafdab67d 100644 --- a/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy +++ b/test-suite-property-source/src/test/groovy/io/micronaut/context/env/ConstantPropertySourceSpec.groovy @@ -1,6 +1,8 @@ package io.micronaut.context.env import io.micronaut.core.optim.StaticOptimizations +import io.micronaut.core.io.ResourceLoadStrategy +import io.micronaut.core.io.ResourceLoadStrategyType import io.micronaut.runtime.Micronaut import spock.lang.Specification @@ -20,8 +22,8 @@ class ConstantPropertySourceSpec extends Specification { when: def configuration = Micronaut.build() .environments(name) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(false)) def env = new DefaultEnvironment(configuration) env.start() diff --git a/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java index 77c97af1c0b..fdd54b358d1 100644 --- a/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java +++ b/test-suite/src/test/java/io/micronaut/docs/context/env/ConfigurationLoadStrategySnippet.java @@ -16,8 +16,8 @@ package io.micronaut.docs.context.env; import io.micronaut.context.ApplicationContext; -import io.micronaut.context.env.ConfigurationLoadStrategy; -import io.micronaut.context.env.ConfigurationLoadStrategyType; +import io.micronaut.core.io.ResourceLoadStrategy; +import io.micronaut.core.io.ResourceLoadStrategyType; import io.micronaut.runtime.Micronaut; final class ConfigurationLoadStrategySnippet { @@ -28,8 +28,8 @@ private ConfigurationLoadStrategySnippet() { void firstMatch(String[] args) { // tag::firstMatch[] Micronaut.build(args) - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH) .warnOnDuplicates(true)) .start(); // end::firstMatch[] @@ -38,8 +38,8 @@ void firstMatch(String[] args) { void mergeAll() { // tag::mergeAll[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL)) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL)) .start(); // end::mergeAll[] @@ -49,8 +49,8 @@ void mergeAll() { void mergeOrder() { // tag::mergeOrder[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.MERGE_ALL) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.MERGE_ALL) .mergeOrder("lib-.*\\.jar", "app-.*\\.jar")) .start(); // end::mergeOrder[] @@ -61,8 +61,8 @@ void mergeOrder() { void restoreFirstMatch() { // tag::restoreFirstMatch[] ApplicationContext ctx = ApplicationContext.builder() - .configurationLoadingStrategy(ConfigurationLoadStrategy.builder() - .type(ConfigurationLoadStrategyType.FIRST_MATCH)) + .configurationLoadingStrategy(ResourceLoadStrategy.builder() + .type(ResourceLoadStrategyType.FIRST_MATCH)) .start(); // end::restoreFirstMatch[] From 856aec4d2ede63c70e0a30e5e1114097621e721f Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Wed, 25 Feb 2026 15:44:52 -0400 Subject: [PATCH 15/15] move more duplicate detection logic to core --- .../core/io/ResourceLoadStrategyType.java | 11 ++++++++++ .../io/micronaut/core/io/ResourceLoader.java | 14 +++++++++++++ .../core/io/scan/ClassPathResourceLoader.java | 18 +++++++++++++++++ .../scan/DefaultClassPathResourceLoader.java | 8 ++++++++ .../context/env/DefaultEnvironment.java | 20 ++----------------- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java index c258ad6a665..9aea641a7e9 100644 --- a/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java +++ b/core/src/main/java/io/micronaut/core/io/ResourceLoadStrategyType.java @@ -24,7 +24,18 @@ */ @NullMarked public enum ResourceLoadStrategyType { + /** + * Uses the first matching resource. + */ FIRST_MATCH, + + /** + * Fails fast if duplicate resources are detected. + */ FAIL_ON_DUPLICATE, + + /** + * Signals that all matching resources should be merged by the caller. + */ MERGE_ALL } diff --git a/core/src/main/java/io/micronaut/core/io/ResourceLoader.java b/core/src/main/java/io/micronaut/core/io/ResourceLoader.java index 6f7f79428d2..fdb51448188 100644 --- a/core/src/main/java/io/micronaut/core/io/ResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/ResourceLoader.java @@ -19,6 +19,7 @@ import java.io.InputStream; import java.net.URL; +import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -55,6 +56,19 @@ public interface ResourceLoader { */ Stream getResources(String name); + /** + * Hook to handle duplicate resources for {@link ResourceLoadStrategyType#FIRST_MATCH}. + * Default implementation is a no-op. + * + * @param resourceName The resource name + * @param chosen The chosen resource URL + * @param duplicates The duplicate resource URLs + * @since 5.0.0 + */ + default void reportResourceDuplicates(String resourceName, URL chosen, List duplicates) { + // no-op + } + /** * @param path The path to a resource including a prefix * appended by a colon. Ex (classpath:, file:) diff --git a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java index 806cf716379..98dee09aec0 100644 --- a/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/scan/ClassPathResourceLoader.java @@ -40,6 +40,18 @@ @NullMarked public interface ClassPathResourceLoader extends ResourceLoader { + /** + * Hook to handle duplicate resources for {@link ResourceLoadStrategyType#FIRST_MATCH}. + * Default implementation is a no-op. + * + * @param resourceName The resource name + * @param chosen The chosen resource URL + * @param duplicates The duplicate resource URLs + * @since 5.0.0 + */ + default void handleResourceDuplicates(String resourceName, URL chosen, List duplicates) { + } + /** * Returns the underlying classloader used by this {@link ClassPathResourceLoader}. * @@ -119,6 +131,12 @@ static List resolveResources(ResourceLoader resourceLoader, String name, Re if (type == ResourceLoadStrategyType.MERGE_ALL) { throw new ResourceConflictException(name, urls); } + + if (strategy.warnOnDuplicates() && resourceLoader instanceof ClassPathResourceLoader) { + URL chosen = urls.getFirst(); + List duplicates = urls.subList(1, urls.size()); + ((ClassPathResourceLoader) resourceLoader).handleResourceDuplicates(name, chosen, duplicates); + } return urls; } } diff --git a/core/src/main/java/io/micronaut/core/io/scan/DefaultClassPathResourceLoader.java b/core/src/main/java/io/micronaut/core/io/scan/DefaultClassPathResourceLoader.java index 470a3e14d69..adc88e8b2b2 100644 --- a/core/src/main/java/io/micronaut/core/io/scan/DefaultClassPathResourceLoader.java +++ b/core/src/main/java/io/micronaut/core/io/scan/DefaultClassPathResourceLoader.java @@ -41,6 +41,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -117,6 +118,13 @@ public DefaultClassPathResourceLoader(ClassLoader classLoader, @Nullable String this.checkBase = checkBase; } + @Override + public void reportResourceDuplicates(String resourceName, URL chosen, List duplicates) { + if (log.isWarnEnabled()) { + log.warn("Duplicate resource '{}' found on the classpath. Using: {}. Duplicates: {}", resourceName, chosen, duplicates); + } + } + /** * Obtains a resource as a stream. * diff --git a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java index f1c8ddd8434..b58f7fce6e1 100644 --- a/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java +++ b/inject/src/main/java/io/micronaut/context/env/DefaultEnvironment.java @@ -630,11 +630,8 @@ private Optional loadPropertySourceFromAbstractLoader(String fil for (List urls : extensionResources.values()) { allUrls.addAll(urls); } - if (!allUrls.isEmpty()) { - if (strategy.type() == ResourceLoadStrategyType.FAIL_ON_DUPLICATE) { - throw new ConfigurationException(buildDuplicateConfigurationMessage(fileName, allUrls)); - } - handleDuplicateResources(fileName, allUrls, strategy); + if (!allUrls.isEmpty() && strategy.type() == ResourceLoadStrategyType.FAIL_ON_DUPLICATE) { + throw new ConfigurationException(buildDuplicateConfigurationMessage(fileName, allUrls)); } } @@ -642,9 +639,6 @@ private Optional loadPropertySourceFromAbstractLoader(String fil String fileExt = fileName + "." + ext; List urls = Objects.requireNonNullElse(extensionResources.get(ext), Collections.emptyList()); Map merged = Collections.emptyMap(); - if (!urls.isEmpty() && urls.size() > 1) { - handleDuplicateResources(fileExt, urls, strategy); - } if (urls.isEmpty()) { Optional config = propertySourceLoader.readInput(resourceLoader, fileExt); @@ -693,16 +687,6 @@ private Optional loadPropertySourceFromAbstractLoader(String fil return Optional.empty(); } - private void handleDuplicateResources(String resourceName, - List urls, - ResourceLoadStrategy strategy) { - if (strategy.type() == ResourceLoadStrategyType.FIRST_MATCH && strategy.warnOnDuplicates() && LOG.isWarnEnabled()) { - URL chosen = urls.getFirst(); - List duplicates = urls.subList(1, urls.size()); - LOG.warn("Duplicate configuration resource '{}' found on the classpath. Using: {}. Duplicates: {}", resourceName, chosen, duplicates); - } - } - private static String buildDuplicateConfigurationMessage(String resourceName, List urls) { StringBuilder sb = new StringBuilder(128); sb.append("Duplicate configuration resource '").append(resourceName).append("' found on the classpath:");