Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spring-core/spring-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,28 @@
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;
import org.springframework.util.ClassUtils;
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},
Expand All @@ -40,6 +51,12 @@
* <p>For more configurable properties loading, including the option of a
* customized encoding, consider using the PropertiesLoaderSupport class.
*
* <p>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
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<String, Object> 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<String, Object> asMap(Object object) {
// YAML can have numbers as keys
Map<String, Object> 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<String, Object> result, Map<String, Object> 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<String, Object>) 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 : ""));
}
});
}
}

}
Original file line number Diff line number Diff line change
@@ -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");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: first
value: 1
---
name: second
value: 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
foo: bar
baz: 123
Original file line number Diff line number Diff line change
@@ -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