From 648ad6f32c0e0d921583bd4de096335648b05f89 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Mon, 20 Jul 2026 16:12:51 +0900 Subject: [PATCH] Support YAML resources in PropertiesLoaderUtils PropertiesLoaderUtils previously only recognized the .xml file extension as a special case, falling back to the standard java.util.Properties parser for anything else. This meant .yml and .yaml resources were parsed as if they were .properties files, producing garbage or throwing an exception. This commit teaches PropertiesLoaderUtils to detect .yml/.yaml filenames and parse them as YAML, flattening nested maps and lists into dotted/indexed property keys, mirroring the behavior of YamlPropertiesFactoryBean in spring-beans. Since spring-core has no hard dependency on spring-beans or SnakeYAML, SnakeYAML is added as an optional dependency and detected at runtime via ClassUtils.isPresent(), following the same soft-dependency pattern already used for Reactor, RxJava, and Kotlin in this module. The actual SnakeYAML API calls are isolated in a private nested class so that its bytecode is only loaded when a YAML resource is actually parsed, keeping the XML/.properties code path completely unaffected when SnakeYAML is absent from the classpath. Closes gh-37066 Signed-off-by: jyx-07 --- spring-core/spring-core.gradle | 1 + .../io/support/PropertiesLoaderUtils.java | 130 ++++++++++++++++++ .../support/PropertiesLoaderUtilsTests.java | 93 +++++++++++++ .../core/io/support/test-multidoc.yml | 5 + .../core/io/support/test-simple.yaml | 2 + .../springframework/core/io/support/test.yml | 13 ++ 6 files changed, 244 insertions(+) create mode 100644 spring-core/src/test/java/org/springframework/core/io/support/PropertiesLoaderUtilsTests.java create mode 100644 spring-core/src/test/resources/org/springframework/core/io/support/test-multidoc.yml create mode 100644 spring-core/src/test/resources/org/springframework/core/io/support/test-simple.yaml create mode 100644 spring-core/src/test/resources/org/springframework/core/io/support/test.yml diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index 4c0df4f03d56..d46393de5d06 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -92,6 +92,7 @@ dependencies { optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.jetbrains.kotlinx:kotlinx-coroutines-core") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + optional("org.yaml:snakeyaml") testCompileOnly("com.google.code.findbugs:jsr305") testFixturesImplementation("io.projectreactor:reactor-test") testFixturesImplementation("org.assertj:assertj-core") diff --git a/spring-core/src/main/java/org/springframework/core/io/support/PropertiesLoaderUtils.java b/spring-core/src/main/java/org/springframework/core/io/support/PropertiesLoaderUtils.java index 0ed034e45882..587209c45527 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/PropertiesLoaderUtils.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/PropertiesLoaderUtils.java @@ -21,10 +21,20 @@ import java.io.Reader; import java.net.URL; import java.net.URLConnection; +import java.util.Collection; +import java.util.Collections; import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Properties; import org.jspecify.annotations.Nullable; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.reader.UnicodeReader; +import org.yaml.snakeyaml.representer.Representer; import org.springframework.core.io.Resource; import org.springframework.util.Assert; @@ -32,6 +42,7 @@ import org.springframework.util.DefaultPropertiesPersister; import org.springframework.util.PropertiesPersister; import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; /** * Convenient utility methods for loading of {@code java.util.Properties}, @@ -40,6 +51,12 @@ *

For more configurable properties loading, including the option of a * customized encoding, consider using the PropertiesLoaderSupport class. * + *

As of 7.1, YAML resources (identified by a {@code .yml} or {@code .yaml} + * filename extension) are supported as well, provided that SnakeYAML is + * present on the classpath. SnakeYAML is an optional dependency of this + * module: if it is not present, attempting to load a YAML resource results + * in an {@link IllegalStateException}. + * * @author Juergen Hoeller * @author Rob Harrop * @author Sebastien Deleuze @@ -50,6 +67,13 @@ public abstract class PropertiesLoaderUtils { private static final String XML_FILE_EXTENSION = ".xml"; + private static final String YAML_FILE_EXTENSION = ".yaml"; + + private static final String YML_FILE_EXTENSION = ".yml"; + + private static final boolean snakeYamlPresent = ClassUtils.isPresent( + "org.yaml.snakeyaml.Yaml", PropertiesLoaderUtils.class.getClassLoader()); + /** * Load properties from the given EncodedResource, @@ -93,6 +117,10 @@ static void fillProperties(Properties props, EncodedResource resource, Propertie stream = resource.getInputStream(); persister.loadFromXml(props, stream); } + else if (isYamlFile(filename)) { + stream = resource.getInputStream(); + fillYamlProperties(props, stream, resource); + } else if (resource.requiresReader()) { reader = resource.getReader(); persister.load(props, reader); @@ -137,6 +165,9 @@ public static void fillProperties(Properties props, Resource resource) throws IO if (filename != null && filename.endsWith(XML_FILE_EXTENSION)) { props.loadFromXML(is); } + else if (isYamlFile(filename)) { + fillYamlProperties(props, is, resource); + } else { props.load(is); } @@ -184,6 +215,9 @@ public static Properties loadAllProperties(String resourceName, @Nullable ClassL if (resourceName.endsWith(XML_FILE_EXTENSION)) { props.loadFromXML(is); } + else if (isYamlFile(resourceName)) { + fillYamlProperties(props, is, url); + } else { props.load(is); } @@ -192,4 +226,100 @@ public static Properties loadAllProperties(String resourceName, @Nullable ClassL return props; } + private static boolean isYamlFile(@Nullable String filename) { + return (filename != null && + (filename.endsWith(YAML_FILE_EXTENSION) || filename.endsWith(YML_FILE_EXTENSION))); + } + + private static void fillYamlProperties(Properties props, InputStream is, Object source) throws IOException { + if (!snakeYamlPresent) { + throw new IllegalStateException( + "Could not detect SnakeYAML library on the classpath - it is required to parse YAML resource: " + source); + } + SnakeYamlPropertiesLoader.fillProperties(props, is); + } + + + /** + * Inner class to avoid a hard dependency on SnakeYAML at runtime. + */ + private static class SnakeYamlPropertiesLoader { + + static void fillProperties(Properties props, InputStream is) throws IOException { + Yaml yaml = createYaml(); + Map flattened = new LinkedHashMap<>(); + try (Reader reader = new UnicodeReader(is)) { + for (Object document : yaml.loadAll(reader)) { + if (document != null) { + buildFlattenedMap(flattened, asMap(document), null); + } + } + } + flattened.forEach((key, value) -> props.setProperty(key, String.valueOf(value))); + } + + private static Yaml createYaml() { + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + loaderOptions.setTagInspector(tag -> false); + DumperOptions dumperOptions = new DumperOptions(); + return new Yaml(new Constructor(loaderOptions), new Representer(dumperOptions), dumperOptions, loaderOptions); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Map asMap(Object object) { + // YAML can have numbers as keys + Map result = new LinkedHashMap<>(); + if (!(object instanceof Map map)) { + // A document can be a text literal + result.put("document", object); + return result; + } + map.forEach((key, value) -> { + if (value instanceof Map) { + value = asMap(value); + } + if (key instanceof CharSequence) { + result.put(key.toString(), value); + } + else { + // It has to be a map key in this case + result.put("[" + key + "]", value); + } + }); + return result; + } + + @SuppressWarnings("unchecked") + private static void buildFlattenedMap(Map result, Map source, @Nullable String path) { + source.forEach((key, value) -> { + if (StringUtils.hasText(path)) { + key = (key.startsWith("[") ? path + key : path + '.' + key); + } + if (value instanceof String) { + result.put(key, value); + } + else if (value instanceof Map map) { + // Need a compound key + buildFlattenedMap(result, (Map) map, key); + } + else if (value instanceof Collection collection) { + // Need a compound key + if (collection.isEmpty()) { + result.put(key, ""); + } + else { + int count = 0; + for (Object object : collection) { + buildFlattenedMap(result, Collections.singletonMap("[" + (count++) + "]", object), key); + } + } + } + else { + result.put(key, (value != null ? value : "")); + } + }); + } + } + } diff --git a/spring-core/src/test/java/org/springframework/core/io/support/PropertiesLoaderUtilsTests.java b/spring-core/src/test/java/org/springframework/core/io/support/PropertiesLoaderUtilsTests.java new file mode 100644 index 000000000000..35cc2b45408a --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/io/support/PropertiesLoaderUtilsTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-present the original author or 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 org.springframework.core.io.support; + +import java.util.Properties; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesLoaderUtils}. + * + * @author jyx-07 + */ +class PropertiesLoaderUtilsTests { + + private static final String PACKAGE_PATH = ClassUtils.classPackageAsResourcePath(PropertiesLoaderUtilsTests.class); + + + @Test + void loadPropertiesFromPropertiesResource() throws Exception { + Properties props = PropertiesLoaderUtils.loadProperties(new ClassPathResource(PACKAGE_PATH + "/test.properties")); + assertThat(props.getProperty("enigma")).isEqualTo("42"); + } + + @Test + void loadPropertiesFromFlatYamlResourceWithYmlExtension() throws Exception { + Properties props = PropertiesLoaderUtils.loadProperties(new ClassPathResource(PACKAGE_PATH + "/test.yml")); + assertThat(props.getProperty("enigma")).isEqualTo("42"); + } + + @Test + void loadPropertiesFromYamlResourceWithYamlExtension() throws Exception { + Properties props = PropertiesLoaderUtils.loadProperties(new ClassPathResource(PACKAGE_PATH + "/test-simple.yaml")); + assertThat(props.getProperty("foo")).isEqualTo("bar"); + assertThat(props.getProperty("baz")).isEqualTo("123"); + } + + @Test + void loadPropertiesFlattensNestedMapsAndLists() throws Exception { + Properties props = PropertiesLoaderUtils.loadProperties(new ClassPathResource(PACKAGE_PATH + "/test.yml")); + + assertThat(props.getProperty("environments.dev.url")).isEqualTo("https://dev.bar.com"); + assertThat(props.getProperty("environments.dev.name")).isEqualTo("Developer Setup"); + assertThat(props.getProperty("environments.prod.url")).isEqualTo("https://foo.bar.com"); + assertThat(props.getProperty("environments.prod.name")).isEqualTo("My Cool App"); + assertThat(props.getProperty("servers[0]")).isEqualTo("dev.bar.com"); + assertThat(props.getProperty("servers[1]")).isEqualTo("foo.bar.com"); + } + + @Test + void loadPropertiesMergesMultipleYamlDocuments() throws Exception { + Properties props = PropertiesLoaderUtils.loadProperties(new ClassPathResource(PACKAGE_PATH + "/test-multidoc.yml")); + + assertThat(props.getProperty("name")).isEqualTo("second"); + assertThat(props.getProperty("value")).isEqualTo("2"); + } + + @Test + void fillPropertiesFromEncodedYamlResource() throws Exception { + EncodedResource resource = new EncodedResource(new ClassPathResource(PACKAGE_PATH + "/test.yml")); + Properties props = new Properties(); + PropertiesLoaderUtils.fillProperties(props, resource); + + assertThat(props.getProperty("enigma")).isEqualTo("42"); + assertThat(props.getProperty("servers[0]")).isEqualTo("dev.bar.com"); + } + + @Test + void loadAllPropertiesFromYamlClasspathResource() throws Exception { + Properties props = PropertiesLoaderUtils.loadAllProperties(PACKAGE_PATH + "/test.yml"); + assertThat(props.getProperty("enigma")).isEqualTo("42"); + } + +} diff --git a/spring-core/src/test/resources/org/springframework/core/io/support/test-multidoc.yml b/spring-core/src/test/resources/org/springframework/core/io/support/test-multidoc.yml new file mode 100644 index 000000000000..1c14989ca1f3 --- /dev/null +++ b/spring-core/src/test/resources/org/springframework/core/io/support/test-multidoc.yml @@ -0,0 +1,5 @@ +name: first +value: 1 +--- +name: second +value: 2 diff --git a/spring-core/src/test/resources/org/springframework/core/io/support/test-simple.yaml b/spring-core/src/test/resources/org/springframework/core/io/support/test-simple.yaml new file mode 100644 index 000000000000..41d81a29c1c3 --- /dev/null +++ b/spring-core/src/test/resources/org/springframework/core/io/support/test-simple.yaml @@ -0,0 +1,2 @@ +foo: bar +baz: 123 diff --git a/spring-core/src/test/resources/org/springframework/core/io/support/test.yml b/spring-core/src/test/resources/org/springframework/core/io/support/test.yml new file mode 100644 index 000000000000..06a757483f27 --- /dev/null +++ b/spring-core/src/test/resources/org/springframework/core/io/support/test.yml @@ -0,0 +1,13 @@ +enigma: 42 + +environments: + dev: + url: https://dev.bar.com + name: Developer Setup + prod: + url: https://foo.bar.com + name: My Cool App + +servers: + - dev.bar.com + - foo.bar.com