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