Commit 67fc5ca4 authored by Stephane Nicoll's avatar Stephane Nicoll

Create a public API to bind a ConfigurationProperties object

Previously, the API used to bind a ConfigurationProperties annotated
object was private to the BeanPostProcessor implementation.

This commit moves most of the logic to ConfigurationPropertiesBinder. As
we want this object to have the same state regardless of how it is built,
a builder is now provided that detects the components that the binder
needs if not specified explicitly.

Closes gh-8344
parent 9242def4
/*
* Copyright 2012-2017 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.boot.context.properties;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver;
import org.springframework.boot.context.properties.bind.handler.IgnoreErrorsBindHandler;
import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler;
import org.springframework.boot.context.properties.bind.validation.ValidationBindHandler;
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.context.properties.source.UnboundElementsSourceFilter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
/**
* Bind {@link ConfigurationProperties} annotated object from a configurable list of
* {@link PropertySource}.
*
* @author Stephane Nicoll
* @since 2.0.0
* @see ConfigurationPropertiesBinderBuilder
*/
public class ConfigurationPropertiesBinder {
private final Iterable<PropertySource<?>> propertySources;
private final ConversionService conversionService;
private final Validator validator;
private Iterable<ConfigurationPropertySource> configurationSources;
ConfigurationPropertiesBinder(Iterable<PropertySource<?>> propertySources,
ConversionService conversionService, Validator validator) {
Assert.notNull(propertySources, "PropertySources must not be null");
this.propertySources = propertySources;
this.conversionService = conversionService;
this.validator = validator;
if (propertySources instanceof MutablePropertySources) {
this.configurationSources = ConfigurationPropertySources
.from((MutablePropertySources) propertySources);
}
else {
this.configurationSources = ConfigurationPropertySources
.from(propertySources);
}
}
/**
* Bind the specified {@code target} object if it is annotated with
* {@link ConfigurationProperties}, otherwise ignore it.
* @param target the target to bind the configuration property sources to
* @throws ConfigurationPropertiesBindingException if the binding failed
*/
public void bind(Object target) {
ConfigurationProperties annotation = AnnotationUtils.findAnnotation(
target.getClass(), ConfigurationProperties.class);
if (annotation != null) {
bind(target, annotation);
}
}
/**
* Bind the specified {@code target} object using the configuration defined by
* the specified {@code annotation}.
* @param target the target to bind the configuration property sources to
* @param annotation the binding configuration
* @throws ConfigurationPropertiesBindingException if the binding failed
*/
void bind(Object target, ConfigurationProperties annotation) {
Binder binder = new Binder(this.configurationSources,
new PropertySourcesPlaceholdersResolver(this.propertySources),
this.conversionService);
Validator validator = determineValidator(target);
BindHandler handler = getBindHandler(annotation, validator);
Bindable<?> bindable = Bindable.ofInstance(target);
try {
binder.bind(annotation.prefix(), bindable, handler);
}
catch (Exception ex) {
throw new ConfigurationPropertiesBindingException(target.getClass(),
getAnnotationDetails(annotation), ex);
}
}
/**
* Destroy this binder instance.
*/
void destroy() {
if (this.validator instanceof InternalValidator) {
((InternalValidator) this.validator).destroy();
}
}
private Validator determineValidator(Object bean) {
boolean supportsBean = (this.validator != null
&& this.validator.supports(bean.getClass()));
if (ClassUtils.isAssignable(Validator.class, bean.getClass())) {
if (supportsBean) {
return new ChainingValidator(this.validator, (Validator) bean);
}
return (Validator) bean;
}
return (supportsBean ? this.validator : null);
}
private BindHandler getBindHandler(ConfigurationProperties annotation,
Validator validator) {
BindHandler handler = BindHandler.DEFAULT;
if (annotation.ignoreInvalidFields()) {
handler = new IgnoreErrorsBindHandler(handler);
}
if (!annotation.ignoreUnknownFields()) {
UnboundElementsSourceFilter filter = new UnboundElementsSourceFilter();
handler = new NoUnboundElementsBindHandler(handler, filter);
}
if (validator != null) {
handler = new ValidationBindHandler(handler, validator);
}
return handler;
}
private String getAnnotationDetails(ConfigurationProperties annotation) {
if (annotation == null) {
return "";
}
StringBuilder details = new StringBuilder();
details.append("prefix=").append(annotation.prefix());
details.append(", ignoreInvalidFields=").append(annotation.ignoreInvalidFields());
details.append(", ignoreUnknownFields=").append(annotation.ignoreUnknownFields());
return details.toString();
}
/**
* {@link Validator} extension to be implemented to signal that that validator can
* be destroyed once the binder is no longer in use.
*/
interface InternalValidator extends Validator {
void destroy();
}
/**
* {@link Validator} implementation that wraps {@link Validator} instances and chains
* their execution.
*/
private static class ChainingValidator implements Validator {
private final Validator[] validators;
ChainingValidator(Validator... validators) {
Assert.notNull(validators, "Validators must not be null");
this.validators = validators;
}
@Override
public boolean supports(Class<?> clazz) {
for (Validator validator : this.validators) {
if (validator.supports(clazz)) {
return true;
}
}
return false;
}
@Override
public void validate(Object target, Errors errors) {
for (Validator validator : this.validators) {
if (validator.supports(target.getClass())) {
validator.validate(target, errors);
}
}
}
}
}
/*
* Copyright 2012-2017 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.boot.context.properties;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.util.ClassUtils;
import org.springframework.validation.Validator;
/**
* Builder for creating {@link ConfigurationPropertiesBinder} based on the state of
* the {@link ApplicationContext}.
*
* @author Stephane Nicoll
* @since 2.0.0
*/
public class ConfigurationPropertiesBinderBuilder {
/**
* The bean name of the configuration properties validator.
*/
public static final String VALIDATOR_BEAN_NAME = "configurationPropertiesValidator";
private static final String CONVERSION_SERVICE_BEAN_NAME = ConfigurableApplicationContext.CONVERSION_SERVICE_BEAN_NAME;
private static final String[] VALIDATOR_CLASSES = { "javax.validation.Validator",
"javax.validation.ValidatorFactory" };
private final ApplicationContext applicationContext;
private ConversionService conversionService;
private Validator validator;
private Iterable<PropertySource<?>> propertySources;
/**
* Creates an instance with the {@link ApplicationContext} to use.
* @param applicationContext the application context
*/
public ConfigurationPropertiesBinderBuilder(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
* Specify the {@link ConversionService} to use or {@code null} to use the default.
* <p>
* By default, use a {@link ConversionService} bean named
* {@value #CONVERSION_SERVICE_BEAN_NAME} if any. Otherwise create a
* {@link DefaultConversionService} with any {@link ConfigurationPropertiesBinding}
* qualified {@link Converter} and {@link GenericConverter} beans found in the
* context.
* @param conversionService the conversion service to use or {@code null}
* @return this instance
*/
public ConfigurationPropertiesBinderBuilder withConversionService(
ConversionService conversionService) {
this.conversionService = conversionService;
return this;
}
/**
* Specify the {@link Validator} to use or {@code null} to use the default.
* <p>
* By default, use a {@link Validator} bean named {@value VALIDATOR_BEAN_NAME} if
* any. If not, create a JSR 303 Validator if the necessary libraries are available.
* No validation occurs otherwise.
* @param validator the validator to use or {@code null}
* @return this instance
*/
public ConfigurationPropertiesBinderBuilder withValidator(Validator validator) {
this.validator = validator;
return this;
}
/**
* Specify the {@link PropertySource property sources} to use.
* @param propertySources the configuration the binder should use
* @return this instance
* @see #withEnvironment(ConfigurableEnvironment)
*/
public ConfigurationPropertiesBinderBuilder withPropertySources(
Iterable<PropertySource<?>> propertySources) {
this.propertySources = propertySources;
return this;
}
/**
* Specify the {@link ConfigurableEnvironment Environment} to use, use all
* available {@link PropertySource}.
* @param environment the environment to use
* @return this instance
* @see #withPropertySources(Iterable)
*/
public ConfigurationPropertiesBinderBuilder withEnvironment(
ConfigurableEnvironment environment) {
return withPropertySources(environment.getPropertySources());
}
/**
* Build a {@link ConfigurationPropertiesBinder} based on the state of the builder,
* discovering the {@lik ConversionService} and {@link Validator} if necessary.
* @return a {@link ConfigurationPropertiesBinder}
*/
public ConfigurationPropertiesBinder build() {
return new ConfigurationPropertiesBinder(this.propertySources,
determineConversionService(), determineValidator());
}
private Validator determineValidator() {
if (this.validator != null) {
return this.validator;
}
Validator defaultValidator = getOptionalBean(VALIDATOR_BEAN_NAME, Validator.class);
if (defaultValidator != null) {
return defaultValidator;
}
if (isJsr303Present()) {
return new ValidatedLocalValidatorFactoryBean(this.applicationContext);
}
return null;
}
private ConversionService determineConversionService() {
if (this.conversionService != null) {
return this.conversionService;
}
ConversionService conversionServiceByName = getOptionalBean(
CONVERSION_SERVICE_BEAN_NAME, ConversionService.class);
if (conversionServiceByName != null) {
return conversionServiceByName;
}
return createDefaultConversionService();
}
private ConversionService createDefaultConversionService() {
ConversionServiceFactory conversionServiceFactory = this.applicationContext
.getAutowireCapableBeanFactory().createBean(ConversionServiceFactory.class);
return conversionServiceFactory.createConversionService();
}
private boolean isJsr303Present() {
for (String validatorClass : VALIDATOR_CLASSES) {
if (!ClassUtils.isPresent(validatorClass,
this.applicationContext.getClassLoader())) {
return false;
}
}
return true;
}
private <T> T getOptionalBean(String name, Class<T> type) {
try {
return this.applicationContext.getBean(name, type);
}
catch (NoSuchBeanDefinitionException ex) {
return null;
}
}
private static class ConversionServiceFactory {
private List<Converter<?, ?>> converters = Collections.emptyList();
private List<GenericConverter> genericConverters = Collections.emptyList();
/**
* A list of custom converters (in addition to the defaults) to use when
* converting properties for binding.
* @param converters the converters to set
*/
@Autowired(required = false)
@ConfigurationPropertiesBinding
public void setConverters(List<Converter<?, ?>> converters) {
this.converters = converters;
}
/**
* A list of custom converters (in addition to the defaults) to use when
* converting properties for binding.
* @param converters the converters to set
*/
@Autowired(required = false)
@ConfigurationPropertiesBinding
public void setGenericConverters(List<GenericConverter> converters) {
this.genericConverters = converters;
}
public ConversionService createConversionService() {
DefaultConversionService conversionService = new DefaultConversionService();
for (Converter<?, ?> converter : this.converters) {
conversionService.addConverter(converter);
}
for (GenericConverter genericConverter : this.genericConverters) {
conversionService.addConverter(genericConverter);
}
return conversionService;
}
}
}
/*
* Copyright 2012-2017 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.boot.context.properties;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.util.ClassUtils;
/**
* Exception thrown when a {@code @ConfigurationProperties} annotated object failed
* to be bound.
*
* @author Stephane Nicoll
* @since 2.0.0
*/
public class ConfigurationPropertiesBindingException extends RuntimeException {
private final Class<?> targetClass;
public ConfigurationPropertiesBindingException(Class<?> targetClass,
String message, Throwable cause) {
super("Could not bind properties to '" + ClassUtils.getShortName(targetClass)
+ "': " + message, cause);
this.targetClass = targetClass;
}
/**
* Return the target type of the object that failed to be bound.
* @return the target {@link Class}
*/
public Class<?> getTargetClass() {
return this.targetClass;
}
/**
* Retrieve the innermost cause of this exception, if any.
* @return the innermost exception, or {@code null} if none
*/
public Throwable getRootCause() {
return NestedExceptionUtils.getRootCause(this);
}
}
/*
* Copyright 2012-2017 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.boot.context.properties;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.validation.MessageInterpolatorFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
/**
* {@link LocalValidatorFactoryBean} supports classes annotated with
* {@link Validated @Validated}.
*
* @author Phillip Webb
*/
class ValidatedLocalValidatorFactoryBean extends LocalValidatorFactoryBean
implements ConfigurationPropertiesBinder.InternalValidator {
private static final Log logger = LogFactory
.getLog(ConfigurationPropertiesBindingPostProcessor.class);
ValidatedLocalValidatorFactoryBean(ApplicationContext applicationContext) {
setApplicationContext(applicationContext);
setMessageInterpolator(new MessageInterpolatorFactory().getObject());
afterPropertiesSet();
}
@Override
public boolean supports(Class<?> type) {
if (!super.supports(type)) {
return false;
}
if (AnnotatedElementUtils.hasAnnotation(type, Validated.class)) {
return true;
}
if (type.getPackage() != null && type.getPackage().getName()
.startsWith("org.springframework.boot")) {
return false;
}
if (getConstraintsForClass(type).isBeanConstrained()) {
logger.warn("The @ConfigurationProperties bean " + type
+ " contains validation constraints but had not been annotated "
+ "with @Validated.");
}
return true;
}
}
/*
* Copyright 2012-2017 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.boot.context.properties;
import java.time.Duration;
import javax.validation.constraints.NotNull;
import org.junit.Test;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
import org.springframework.boot.context.properties.bind.validation.ValidationErrors;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.context.support.TestPropertySourceUtils;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link ConfigurationPropertiesBinderBuilder}.
*
* @author Stephane Nicoll
*/
public class ConfigurationPropertiesBinderBuilderTests {
private final StaticApplicationContext applicationContext = new StaticApplicationContext();
private final ConfigurationPropertiesBinderBuilder builder =
new ConfigurationPropertiesBinderBuilder(this.applicationContext);
private final MockEnvironment environment = new MockEnvironment();
@Test
public void useCustomConversionService() {
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new AddressConverter());
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(this.environment,
"test.address=FooStreet 42");
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment)
.withConversionService(conversionService).build();
PropertyWithAddress target = new PropertyWithAddress();
binder.bind(target);
assertThat(target.getAddress()).isNotNull();
assertThat(target.getAddress().streetName).isEqualTo("FooStreet");
assertThat(target.getAddress().number).isEqualTo(42);
}
@Test
public void detectDefaultConversionService() {
this.applicationContext.registerSingleton("conversionService",
DefaultConversionService.class);
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
assertThat(ReflectionTestUtils.getField(binder, "conversionService")).isSameAs(
this.applicationContext.getBean("conversionService"));
}
@Test
public void bindToJavaTimeDuration() {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(this.environment,
"test.duration=PT1M");
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
PropertyWithDuration target = new PropertyWithDuration();
binder.bind(target);
assertThat(target.getDuration().getSeconds()).isEqualTo(60);
}
@Test
public void useCustomValidator() {
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment)
.withValidator(validator).build();
assertThat(ReflectionTestUtils.getField(binder, "validator")).isSameAs(validator);
}
@Test
public void detectDefaultValidator() {
this.applicationContext.registerSingleton("configurationPropertiesValidator",
LocalValidatorFactoryBean.class);
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
assertThat(ReflectionTestUtils.getField(binder, "validator")).isSameAs(
this.applicationContext.getBean("configurationPropertiesValidator"));
}
@Test
public void validationWithoutJsr303() {
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
assertThat(bindWithValidationErrors(binder, new PropertyWithoutJSR303())
.getAllErrors()).hasSize(1);
}
@Test
public void validationWithJsr303() {
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
assertThat(bindWithValidationErrors(binder, new PropertyWithJSR303())
.getAllErrors()).hasSize(2);
}
@Test
public void validationWithJsr303AndValidInput() {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(this.environment,
"test.foo=123456", "test.bar=654321");
ConfigurationPropertiesBinder binder = new ConfigurationPropertiesBinder(
this.environment.getPropertySources(), null, null);
PropertyWithJSR303 target = new PropertyWithJSR303();
binder.bind(target);
assertThat(target.getFoo()).isEqualTo("123456");
assertThat(target.getBar()).isEqualTo("654321");
}
@Test
public void internalValidatorIsClosed() throws Exception {
ConfigurationPropertiesBinder binder = this.builder
.withEnvironment(this.environment).build();
Object validator = ReflectionTestUtils.getField(binder, "validator");
assertThat(validator).isNotNull();
assertThat(validator).isInstanceOf(DisposableBean.class);
DisposableBean validatorSpy = spy((DisposableBean) validator);
ReflectionTestUtils.setField(binder, "validator", validatorSpy);
binder.destroy();
verify(validatorSpy).destroy();
}
private ValidationErrors bindWithValidationErrors(
ConfigurationPropertiesBinder binder, Object target) {
try {
binder.bind(target);
throw new AssertionError("Should have failed to bind " + target);
}
catch (ConfigurationPropertiesBindingException ex) {
Throwable rootCause = ex.getRootCause();
assertThat(rootCause).isInstanceOf(BindValidationException.class);
return ((BindValidationException) rootCause).getValidationErrors();
}
}
@ConfigurationProperties(prefix = "test")
public static class PropertyWithAddress {
private Address address;
public Address getAddress() {
return this.address;
}
public void setAddress(Address address) {
this.address = address;
}
}
private static class Address {
private String streetName;
private Integer number;
Address(String streetName, Integer number) {
this.streetName = streetName;
this.number = number;
}
}
private static class AddressConverter implements Converter<String, Address> {
@Override
public Address convert(String source) {
String[] split = StringUtils.split(source, " ");
return new Address(split[0], Integer.valueOf(split[1]));
}
}
@ConfigurationProperties(prefix = "test")
public static class PropertyWithDuration {
private Duration duration;
public Duration getDuration() {
return this.duration;
}
public void setDuration(Duration duration) {
this.duration = duration;
}
}
@ConfigurationProperties(prefix = "test")
@Validated
public static class PropertyWithoutJSR303 implements Validator {
private String foo;
@Override
public boolean supports(Class<?> clazz) {
return clazz.isAssignableFrom(getClass());
}
@Override
public void validate(Object target, Errors errors) {
ValidationUtils.rejectIfEmpty(errors, "foo", "TEST1");
}
public String getFoo() {
return this.foo;
}
public void setFoo(String foo) {
this.foo = foo;
}
}
@ConfigurationProperties(prefix = "test")
@Validated
public static class PropertyWithJSR303 extends PropertyWithoutJSR303 {
@NotNull
private String bar;
public void setBar(String bar) {
this.bar = bar;
}
public String getBar() {
return this.bar;
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment