diff --git a/.gitignore b/.gitignore index 58de8c7240..9f107a4d60 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .project .settings .metadata +.factorypath bin build lib/ @@ -28,4 +29,5 @@ overridedb.* *.ipr *.iws .idea -*.jar \ No newline at end of file +*.jar +.DS_Store diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpoint.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpoint.java index 26f7c8def5..ac691c772a 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpoint.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpoint.java @@ -16,21 +16,32 @@ package org.springframework.boot.actuate.endpoint; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeanWrapperImpl; import org.springframework.beans.BeansException; import org.springframework.boot.context.properties.ConfigurationBeanFactoryMetaData; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; -import org.springframework.core.convert.ConversionService; -import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; +import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.SerializationFeature; @@ -64,12 +75,17 @@ public class ConfigurationPropertiesReportEndpoint extends private static final String CGLIB_FILTER_ID = "cglibFilter"; + private static final Log logger = LogFactory + .getLog(ConfigurationPropertiesReportEndpoint.class); + private final Sanitizer sanitizer = new Sanitizer(); private ApplicationContext context; private ConfigurationBeanFactoryMetaData beanFactoryMetaData; + private ConfigurationPropertiesMetaData metadata = new ConfigurationPropertiesMetaData(); + public ConfigurationPropertiesReportEndpoint() { super("configprops"); } @@ -97,7 +113,6 @@ public class ConfigurationPropertiesReportEndpoint extends * Extract beans annotated {@link ConfigurationProperties} and serialize into * {@link Map}. */ - @SuppressWarnings("unchecked") protected Map extract(ApplicationContext context) { Map result = new HashMap(); @@ -116,8 +131,9 @@ public class ConfigurationPropertiesReportEndpoint extends String beanName = entry.getKey(); Object bean = entry.getValue(); Map root = new HashMap(); - root.put("prefix", extractPrefix(context, beanName, bean)); - root.put("properties", sanitize(mapper.convertValue(bean, Map.class))); + String prefix = extractPrefix(context, beanName, bean); + root.put("prefix", prefix); + root.put("properties", sanitize(safeSerialize(mapper, bean, prefix))); result.put(beanName, root); } @@ -128,12 +144,31 @@ public class ConfigurationPropertiesReportEndpoint extends return result; } + /** + * Cautiously serialize the bean to a map (returning a map with an error message + * instead of throwing an exception if there is a problem). + */ + private Map safeSerialize(ObjectMapper mapper, Object bean, + String prefix) { + try { + @SuppressWarnings("unchecked") + Map result = new HashMap(mapper.convertValue( + this.metadata.extractMap(bean, prefix), Map.class)); + return result; + } + catch (Exception e) { + return new HashMap(Collections. singletonMap( + "error", "Cannot serialize '" + prefix + "'")); + } + } + /** * Configure Jackson's {@link ObjectMapper} to be used to serialize the * {@link ConfigurationProperties} objects into a {@link Map} structure. */ protected void configureObjectMapper(ObjectMapper mapper) { mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); applyCglibFilters(mapper); applySerializationModifier(mapper); } @@ -148,7 +183,7 @@ public class ConfigurationPropertiesReportEndpoint extends } /** - * Configure PropertyFiler to make sure Jackson doesn't process CGLIB generated bean + * Configure PropertyFilter to make sure Jackson doesn't process CGLIB generated bean * properties. */ private void applyCglibFilters(ObjectMapper mapper) { @@ -239,25 +274,182 @@ public class ConfigurationPropertiesReportEndpoint extends protected static class GenericSerializerModifier extends BeanSerializerModifier { - private ConversionService conversionService = new DefaultConversionService(); - @Override public List changeProperties(SerializationConfig config, BeanDescription beanDesc, List beanProperties) { List result = new ArrayList(); for (BeanPropertyWriter writer : beanProperties) { - AnnotatedMethod setter = beanDesc.findMethod( - "set" + StringUtils.capitalize(writer.getName()), - new Class[] { writer.getPropertyType() }); - if (setter != null - && this.conversionService.canConvert(String.class, - writer.getPropertyType())) { + boolean readable = isReadable(beanDesc, writer); + if (readable) { result.add(writer); } } return result; } + private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) { + String parenType = beanDesc.getType().getTypeName(); + String type = writer.getType().getTypeName(); + AnnotatedMethod setter = beanDesc.findMethod( + "set" + StringUtils.capitalize(writer.getName()), + new Class[] { writer.getPropertyType() }); + // If there's a setter, we assume it's OK to report on the value, + // similarly, if there's no setter but the package names match, we assume + // that its a nested class used solely for binding to config props, so it + // should be kosher. This filter is not used if there is JSON metadata for + // the property, so it's mainly for user-defined beans. + boolean readable = setter != null + || ClassUtils.getPackageName(parenType).equals( + ClassUtils.getPackageName(type)); + return readable; + } + } + + /** + * Convenience class for grabbing and caching valid property names from + * /META-INF/spring-configuration-metadata.json so that metadata that is known to be + * valid can be used to pull the correct nested properties out of beans that might + * otherwise be tricky (contain cycles or other unserializable properties). + */ + protected static class ConfigurationPropertiesMetaData { + + private Map> matched = new HashMap>(); + private Set keys = null; + + public boolean matches(String prefix) { + if (this.matched.containsKey(prefix)) { + return matchesInternal(prefix); + } + synchronized (this.matched) { + if (this.matched.containsKey(prefix)) { + return matchesInternal(prefix); + } + this.matched.put(prefix, findKeys(prefix)); + } + return matchesInternal(prefix); + } + + private boolean matchesInternal(String prefix) { + if (this.matched.get(prefix) != null) { + return true; + } + else { + return false; + } + } + + private Set findKeys(String prefix) { + + HashSet set = new HashSet(); + + try { + if (this.keys == null) { + + this.keys = new HashSet(); + ObjectMapper mapper = new ObjectMapper(); + Resource[] resources = new PathMatchingResourcePatternResolver() + .getResources("classpath*:/META-INF/*spring-configuration-metadata.json"); + for (Resource resource : resources) { + addKeys(mapper, resource); + } + + } + } + catch (IOException e) { + logger.warn("Could not deserialize config properties metadata", e); + } + for (String key : this.keys) { + if (key.length() > prefix.length() + && key.startsWith(prefix) + && ".".equals(key.substring(prefix.length(), prefix.length() + 1))) { + set.add(key.substring(prefix.length() + 1)); + } + } + if (set.isEmpty()) { + return null; + } + return set; + } + + private void addKeys(ObjectMapper mapper, Resource resource) throws IOException, + JsonParseException, JsonMappingException { + @SuppressWarnings("unchecked") + Map map = mapper.readValue(resource.getInputStream(), + Map.class); + @SuppressWarnings("unchecked") + Collection> metadata = (Collection>) map + .get("properties"); + for (Map value : metadata) { + try { + if (value.containsKey("type")) { + this.keys.add((String) value.get("name")); + } + } + catch (Exception e) { + logger.warn("Could not parse config properties metadata", e); + } + } + } + + public Object extractMap(Object bean, String prefix) { + if (!matches(prefix)) { + return bean; + } + Map map = new HashMap(); + for (String key : this.matched.get(prefix)) { + addProperty(bean, key, map); + } + return map; + } + + private void addProperty(Object bean, String key, Map map) { + String prefix = key.contains(".") ? StringUtils.split(key, ".")[0] : key; + String suffix = key.length() > prefix.length() ? key.substring(prefix + .length() + 1) : null; + String property = prefix; + if (bean instanceof Map) { + @SuppressWarnings("unchecked") + Map value = (Map) bean; + bean = new MapHolder(value); + property = "map[" + property + "]"; + } + BeanWrapperImpl wrapper = new BeanWrapperImpl(bean); + try { + Object value = wrapper.getPropertyValue(property); + if (value instanceof Map) { + Map nested = new HashMap(); + map.put(prefix, nested); + if (suffix != null) { + addProperty(value, suffix, nested); + } + } + else { + map.put(prefix, value); + } + } + catch (Exception e) { + // Probably just lives on a different bean (it happens) + logger.debug("Could not parse config properties metadata '" + key + "': " + + e.getMessage()); + } + } + + protected static class MapHolder { + Map map = new HashMap(); + + public MapHolder(Map bean) { + this.map.putAll(bean); + } + + public Map getMap() { + return this.map; + } + + public void setMap(Map map) { + this.map = map; + } + } + } } diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpointSerializationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpointSerializationTests.java index 70ea3b1fa6..6b4c4a6004 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpointSerializationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ConfigurationPropertiesReportEndpointSerializationTests.java @@ -16,6 +16,8 @@ package org.springframework.boot.actuate.endpoint; +import java.net.InetAddress; +import java.util.List; import java.util.Map; import org.junit.After; @@ -28,6 +30,7 @@ import org.springframework.boot.test.EnvironmentTestUtils; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -51,7 +54,7 @@ public class ConfigurationPropertiesReportEndpointSerializationTests { @Test @SuppressWarnings("unchecked") public void testNaming() throws Exception { - this.context.register(Config.class); + this.context.register(FooConfig.class); EnvironmentTestUtils.addEnvironment(this.context, "foo.name:foo"); this.context.refresh(); ConfigurationPropertiesReportEndpoint report = this.context @@ -64,13 +67,173 @@ public class ConfigurationPropertiesReportEndpointSerializationTests { Map map = (Map) nestedProperties .get("properties"); assertNotNull(map); + assertEquals(2, map.size()); + assertEquals("foo", map.get("name")); + } + + @Test + @SuppressWarnings("unchecked") + public void testNestedNaming() throws Exception { + this.context.register(FooConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "foo.bar.name:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(2, map.size()); + assertEquals("foo", ((Map) map.get("bar")).get("name")); + } + + @Test + @SuppressWarnings("unchecked") + public void testCycle() throws Exception { + this.context.register(CycleConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "foo.name:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + assertEquals("foo", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(1, map.size()); + assertEquals("Cannot serialize 'foo'", map.get("error")); + } + + @Test + @SuppressWarnings("unchecked") + public void testCycleAvoidedThroughManualMetadata() throws Exception { + this.context.register(MetadataCycleConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "bar.name:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + assertEquals("bar", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); assertEquals(1, map.size()); assertEquals("foo", map.get("name")); } + @Test + @SuppressWarnings("unchecked") + public void testMap() throws Exception { + this.context.register(MapConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "foo.map.name:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + assertEquals("foo", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(3, map.size()); + assertEquals("foo", ((Map) map.get("map")).get("name")); + } + + @Test + @SuppressWarnings("unchecked") + public void testEmptyMapIsNotAdded() throws Exception { + this.context.register(MapConfig.class); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + System.err.println(nestedProperties); + assertEquals("foo", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(2, map.size()); + assertEquals(null, (map.get("map"))); + } + + @Test + @SuppressWarnings("unchecked") + public void testList() throws Exception { + this.context.register(ListConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "foo.list[0]:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + assertEquals("foo", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(3, map.size()); + assertEquals("foo", ((List) map.get("list")).get(0)); + } + + @Test + @SuppressWarnings("unchecked") + public void testMapWithMetadata() throws Exception { + this.context.register(MetadataMapConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "spam.map.name:foo"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + assertEquals("spam", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(2, map.size()); + assertEquals("foo", ((Map) map.get("map")).get("name")); + } + + @Test + @SuppressWarnings("unchecked") + public void testInetAddress() throws Exception { + this.context.register(AddressedConfig.class); + EnvironmentTestUtils.addEnvironment(this.context, "foo.address:192.168.1.10"); + this.context.refresh(); + ConfigurationPropertiesReportEndpoint report = this.context + .getBean(ConfigurationPropertiesReportEndpoint.class); + Map properties = report.invoke(); + Map nestedProperties = (Map) properties + .get("foo"); + assertNotNull(nestedProperties); + System.err.println(nestedProperties); + assertEquals("foo", nestedProperties.get("prefix")); + Map map = (Map) nestedProperties + .get("properties"); + assertNotNull(map); + assertEquals(3, map.size()); + assertEquals("192.168.1.10", map.get("address")); + } + @Configuration @EnableConfigurationProperties - public static class Config { + public static class Base { @Bean public ConfigurationPropertiesReportEndpoint endpoint( @@ -80,6 +243,12 @@ public class ConfigurationPropertiesReportEndpointSerializationTests { return endpoint; } + } + + @Configuration + @Import(Base.class) + public static class FooConfig { + @Bean @ConfigurationProperties(prefix = "foo") public Foo foo() { @@ -88,10 +257,104 @@ public class ConfigurationPropertiesReportEndpointSerializationTests { } + @Configuration + @Import(Base.class) + public static class CycleConfig { + + @Bean + @ConfigurationProperties(prefix = "foo") + public Cycle foo() { + return new Cycle(); + } + + } + + @Configuration + @Import(Base.class) + public static class MetadataCycleConfig { + + @Bean + @ConfigurationProperties(prefix = "bar") + public Cycle foo() { + return new Cycle(); + } + + } + + @Configuration + @Import(Base.class) + public static class MapConfig { + + @Bean + @ConfigurationProperties(prefix = "foo") + public MapHolder foo() { + return new MapHolder(); + } + + } + + @Configuration + @Import(Base.class) + public static class ListConfig { + + @Bean + @ConfigurationProperties(prefix = "foo") + public ListHolder foo() { + return new ListHolder(); + } + + } + + @Configuration + @Import(Base.class) + public static class MetadataMapConfig { + + @Bean + @ConfigurationProperties(prefix = "spam") + public MapHolder foo() { + return new MapHolder(); + } + + } + + @Configuration + @Import(Base.class) + public static class AddressedConfig { + + @Bean + @ConfigurationProperties(prefix = "foo") + public Addressed foo() { + return new Addressed(); + } + + } + public static class Foo { private String name = "654321"; + public static class Bar { + private String name = "123456"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + } + + private Bar bar = new Bar(); + + public Bar getBar() { + return this.bar; + } + + public void setBar(Bar bar) { + this.bar = bar; + } + public String getName() { return this.name; } @@ -107,4 +370,61 @@ public class ConfigurationPropertiesReportEndpointSerializationTests { } + public static class Cycle extends Foo { + + private Foo self; + + public Cycle() { + this.self = this; + } + + public Foo getSelf() { + return this.self; + } + + public void setSelf(Foo self) { + this.self = self; + } + } + + public static class MapHolder extends Foo { + + private Map map; + + public Map getMap() { + return this.map; + } + + public void setMap(Map map) { + this.map = map; + } + } + + public static class ListHolder extends Foo { + + private List list; + + public List getList() { + return this.list; + } + + public void setList(List list) { + this.list = list; + } + } + + public static class Addressed extends Foo { + + private InetAddress address; + + public InetAddress getAddress() { + return this.address; + } + + public void setAddress(InetAddress address) { + this.address = address; + } + + } + } diff --git a/spring-boot-actuator/src/test/resources/META-INF/test-spring-configuration-metadata.json b/spring-boot-actuator/src/test/resources/META-INF/test-spring-configuration-metadata.json new file mode 100644 index 0000000000..3363bf04fa --- /dev/null +++ b/spring-boot-actuator/src/test/resources/META-INF/test-spring-configuration-metadata.json @@ -0,0 +1,10 @@ +{"properties": [ + { + "name": "bar.name", + "dataType": "java.lang.String" + }, + { + "name": "spam.map.name", + "dataType": "java.lang.String" + } ]} + diff --git a/spring-boot-samples/spring-boot-sample-actuator/src/test/java/sample/actuator/SampleActuatorApplicationTests.java b/spring-boot-samples/spring-boot-sample-actuator/src/test/java/sample/actuator/SampleActuatorApplicationTests.java index 388077787a..803c273fb2 100644 --- a/spring-boot-samples/spring-boot-sample-actuator/src/test/java/sample/actuator/SampleActuatorApplicationTests.java +++ b/spring-boot-samples/spring-boot-sample-actuator/src/test/java/sample/actuator/SampleActuatorApplicationTests.java @@ -221,6 +221,18 @@ public class SampleActuatorApplicationTests { ((String) body.get("context")).startsWith("application")); } + @Test + public void testConfigProps() throws Exception { + @SuppressWarnings("rawtypes") + ResponseEntity entity = new TestRestTemplate("user", getPassword()) + .getForEntity("http://localhost:" + this.port + "/configprops", Map.class); + assertEquals(HttpStatus.OK, entity.getStatusCode()); + @SuppressWarnings("unchecked") + Map body = entity.getBody(); + assertTrue("Wrong body: " + body, + body.containsKey("spring.datasource.CONFIGURATION_PROPERTIES")); + } + private String getPassword() { return this.security.getUser().getPassword(); } diff --git a/spring-boot-starters/spring-boot-starter-parent/pom.xml b/spring-boot-starters/spring-boot-starter-parent/pom.xml index 50f0ddf527..02057ac861 100644 --- a/spring-boot-starters/spring-boot-starter-parent/pom.xml +++ b/spring-boot-starters/spring-boot-starter-parent/pom.xml @@ -17,7 +17,7 @@ http://www.spring.io - 1.6 + 1.7 @ UTF-8 UTF-8