Flatten hierarchical JSON objects into property paths.

Hierarchical JSON data stored in Vault is flattened to property paths with dot-notation.

  {
    "database": {
      "password": ...
    },
    "items": ["one", "two"],
    "user.name": ...,
  }

results in

  database.password=...
  items[0]=one
  items[1]=two
  user.name=...

Fixes gh-40.
This commit is contained in:
Mark Paluch
2016-12-15 10:36:19 +01:00
parent 65494e4e63
commit c387aec23e
6 changed files with 294 additions and 25 deletions

View File

@@ -28,6 +28,7 @@ import org.springframework.util.Assert;
import org.springframework.vault.client.VaultException;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.support.JsonMapFlattener;
import org.springframework.vault.support.VaultResponse;
/**
@@ -40,10 +41,12 @@ import org.springframework.vault.support.VaultResponse;
*/
public class VaultPropertySource extends EnumerablePropertySource<VaultOperations> {
protected final static Log logger = LogFactory.getLog(VaultPropertySource.class);
private final static Log logger = LogFactory.getLog(VaultPropertySource.class);
private final String path;
private final Map<String, String> properties = new LinkedHashMap<String, String>();
private final Object lock = new Object();
/**
@@ -129,19 +132,7 @@ public class VaultPropertySource extends EnumerablePropertySource<VaultOperation
* @return
*/
protected Map<String, String> toStringMap(Map<String, Object> data) {
Map<String, String> result = new LinkedHashMap<String, String>();
if (data != null) {
for (String s : data.keySet()) {
Object value = data.get(s);
if (value != null) {
result.put(s, value.toString());
}
}
}
return result;
return JsonMapFlattener.flatten(data);
}
@Override

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2016 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
*
* http://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.vault.support;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Flattens a hierarchical {@link Map} of objects into a property {@link Map}.
* <p>
* Flattening is particularly useful when representing a JSON object as
* {@link java.util.Properties}
* <p>
* {@link JsonMapFlattener} flattens {@link Map maps} containing nested
* {@link java.util.List}, {@link Map} and simple values into a flat representation. The
* hierarchical structure is reflected in properties using dot-notation. Nested maps are
* considered as sub-documents.
* <p>
* Input:
*
* <pre>
* <code>
* {"key": {"nested: "value"}, "another.key": ["one", "two"] }
* </code>
* </pre>
*
* <br/>
* Result
*
* <pre>
* <code> key.nested=value
* another.key[0]=one
* another.key[1]=two
* </code>
* </pre>
*
* @author Mark Paluch
*/
public abstract class JsonMapFlattener {
private JsonMapFlattener() {
}
/**
* Flatten a hierarchical {@link Map} into a flat {@link Map} with key names using
* property dot notation.
*
* @param inputMap must not be {@literal null}.
* @return the resulting {@link Map}.
*/
public static Map<String, String> flatten(Map<String, ? extends Object> inputMap) {
Assert.notNull(inputMap, "Input Map must not be null");
Map<String, String> resultMap = new LinkedHashMap<String, String>();
doFlatten("", inputMap.entrySet().iterator(), resultMap);
return resultMap;
}
private static void doFlatten(String propertyPrefix,
Iterator<? extends Entry<String, ?>> inputMap, Map<String, String> resultMap) {
if (StringUtils.hasText(propertyPrefix)) {
propertyPrefix = propertyPrefix + ".";
}
while (inputMap.hasNext()) {
Entry<String, ? extends Object> entry = inputMap.next();
flattenElement(propertyPrefix + entry.getKey(), entry.getValue(), resultMap);
}
}
@SuppressWarnings("unchecked")
private static void flattenElement(String propertyPrefix, Object source,
Map<String, String> resultMap) {
if (source instanceof Iterable) {
flattenCollection(propertyPrefix, (Iterable<Object>) source, resultMap);
return;
}
if (source instanceof Map) {
doFlatten(propertyPrefix, ((Map<String, ?>) source).entrySet().iterator(),
resultMap);
return;
}
resultMap.put(propertyPrefix, source == null ? null : source.toString());
}
private static void flattenCollection(String propertyPrefix,
Iterable<Object> iterable, Map<String, String> resultMap) {
int counter = 0;
for (Object element : iterable) {
flattenElement(propertyPrefix + "[" + counter + "]", element, resultMap);
counter++;
}
}
}

View File

@@ -15,15 +15,16 @@
*/
package org.springframework.vault.core;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
@@ -31,6 +32,8 @@ import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultResponseSupport;
import org.springframework.vault.util.IntegrationTestSupport;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link VaultTemplate} using the {@code generic} backend.
*
@@ -61,6 +64,37 @@ public class VaultTemplateGenericIntegrationTests extends IntegrationTestSupport
assertThat(read.getData()).containsEntry("hello", "world");
}
@Test
public void readShouldReturnNestedPropertiesKey() throws Exception {
Map map = new ObjectMapper()
.readValue(
"{ \"hello.array[0]\":\"array-value0\", \"hello.array[1]\":\"array-value1\" }",
Map.class);
vaultOperations.write("secret/mykey", map);
VaultResponse read = vaultOperations.read("secret/mykey");
assertThat(read).isNotNull();
assertThat(read.getData()).containsEntry("hello.array[0]", "array-value0");
assertThat(read.getData()).containsEntry("hello.array[1]", "array-value1");
}
@Test
public void readShouldReturnNestedObjects() throws Exception {
Map map = new ObjectMapper().readValue(
"{ \"array\": [ {\"hello\": \"world\"}, {\"hello1\": \"world1\"} ] }",
Map.class);
vaultOperations.write("secret/mykey", map);
VaultResponse read = vaultOperations.read("secret/mykey");
assertThat(read).isNotNull();
assertThat(read.getData()).containsEntry(
"array",
Arrays.asList(Collections.singletonMap("hello", "world"),
Collections.singletonMap("hello1", "world1")));
}
@Test
public void readObjectShouldReadDomainClass() throws Exception {

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.vault.core.env;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -61,6 +62,7 @@ public class VaultPropertySourceUnitTests {
assertThat(vaultPropertySource.getProperty("key")).isEqualTo("value");
assertThat(vaultPropertySource.getProperty("integer")).isEqualTo("1");
assertThat(vaultPropertySource.getProperty("complex.key")).isEqualTo("value");
}
@Test
@@ -71,7 +73,8 @@ public class VaultPropertySourceUnitTests {
VaultPropertySource vaultPropertySource = new VaultPropertySource("hello",
vaultTemplate, "secret/myapp");
assertThat(vaultPropertySource.getPropertyNames()).contains("key", "integer");
assertThat(vaultPropertySource.getPropertyNames()).contains("key", "integer",
"complex.key");
}
private void prepareResponse() {
@@ -79,6 +82,7 @@ public class VaultPropertySourceUnitTests {
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("key", "value");
data.put("integer", 1);
data.put("complex", Collections.singletonMap("key", "value"));
VaultResponse vaultResponse = new VaultResponse();
vaultResponse.setData(data);

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2016 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
*
* http://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.vault.support;
import java.util.Collections;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for {@link JsonMapFlattener}.
*
* @author Mark Paluch
*/
@SuppressWarnings("unchecked")
public class JsonMapFlattenerUnitTests {
private final static ObjectMapper objectMapper = new ObjectMapper();
@Test
public void shouldPreserveFlatMap() throws Exception {
Map<String, String> result = JsonMapFlattener.flatten(Collections.singletonMap(
"key", "value"));
assertThat(result).containsEntry("key", "value");
}
@Test
public void shouldFlattenNestedObject() throws Exception {
Map<String, Object> map = objectMapper.readValue(
"{\"key\": { \"nested\":\"value\"} }", Map.class);
Map<String, String> result = JsonMapFlattener.flatten(map);
assertThat(result).containsEntry("key.nested", "value");
}
@Test
public void shouldFlattenDeeplyNestedObject() throws Exception {
Map<String, Object> map = objectMapper.readValue(
"{\"key\": { \"nested\": {\"anotherLevel\": \"value\"} } }", Map.class);
Map<String, String> result = JsonMapFlattener.flatten(map);
assertThat(result).containsEntry("key.nested.anotherLevel", "value");
}
@Test
public void shouldFlattenNestedListOfSimpleObjects() throws Exception {
Map<String, Object> map = objectMapper.readValue(
"{\"key\": [\"one\", \"two\"], \"dotted.key\": [\"one\", \"two\"] }",
Map.class);
Map<String, String> result = JsonMapFlattener.flatten(map);
assertThat(result).containsEntry("key[0]", "one").containsEntry("key[1]", "two");
assertThat(result).containsEntry("dotted.key[0]", "one").containsEntry(
"dotted.key[1]", "two");
}
@Test
public void shouldFlattenNestedListOfComplexObject() throws Exception {
Map<String, Object> map = objectMapper.readValue(
"{\"key\": [{ \"nested\":\"value\"}, { \"nested\":\"other-value\"}] }",
Map.class);
Map<String, String> result = JsonMapFlattener.flatten(map);
assertThat(result).containsEntry("key[0].nested", "value").containsEntry(
"key[1].nested", "other-value");
}
@Test
public void shouldFlattenDeeplyNestedListOfComplexObject() throws Exception {
Map<String, Object> map = objectMapper
.readValue(
"{\"key\": { \"level1\": [{ \"nested\":\"value\"}, { \"nested\":\"other-value\"}]} }",
Map.class);
Map<String, String> result = JsonMapFlattener.flatten(map);
assertThat(result).containsEntry("key.level1[0].nested", "value").containsEntry(
"key.level1[1].nested", "other-value");
}
}

View File

@@ -347,16 +347,32 @@ manipulation of the set of property sources.
=== @VaultPropertySource
The `@VaultPropertySource` annotation provides a convenient and declarative
mechanism for adding a `PropertySource` to Springs `Environment`.
mechanism for adding a `PropertySource` to Spring's `Environment`
to be used in conjunction with @Configuration classes.
To be used in conjunction with @Configuration classes.
Example usage
`@VaultPropertySource` takes a Vault path such as ``secret/my-application``
and exposes the data stored at the node in a ``PropertySource``.
Given a Vault path `secret/my-application` containing the configuration data
pair `database.password=mysecretpassword`, the following `@Configuration`
class uses `@VaultPropertySource` to contribute `secret/my-application` to
the `Environment`'s set of `PropertySources`.
.Properties stored in Vault
====
[source,json]
----
{
// …
"data": {
"database": {
"password": ...
},
"user.name": ...,
}
// …
}
----
====
.Declaring a `@VaultPropertySource`
====
[source,java]
----
@@ -369,6 +385,7 @@ public class AppConfig {
@Bean
public TestBean testBean() {
TestBean testBean = new TestBean();
testBean.setUser(env.getProperty("user.name"));
testBean.setPassword(env.getProperty("database.password"));
return testBean;
}
@@ -378,7 +395,7 @@ public class AppConfig {
In certain situations, it may not be possible or practical to tightly control
property source ordering when using `@VaultPropertySource` annotations.
For example, if the @Configuration classes above were registered via
For example, if the `@Configuration` classes above were registered via
component-scanning, the ordering is difficult to predict.
In such cases - and if overriding is important - it is recommended that the
user fall back to using the programmatic PropertySource API.