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