From 241a7086c0247a888d7e7f9bc4b95c313e02c310 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 15 Dec 2017 15:05:38 -0800 Subject: [PATCH] Add PropertyMapper utility class Add a utility class that can help when mapping values from `@ConfigurationProperties` to a third-party class. See gh-9018 --- .../context/properties/PropertyMapper.java | 350 ++++++++++++++++++ .../properties/PropertyMapperTests.java | 221 +++++++++++ 2 files changed, 571 insertions(+) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/PropertyMapper.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/PropertyMapper.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/PropertyMapper.java new file mode 100644 index 0000000000..511fff9f22 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/PropertyMapper.java @@ -0,0 +1,350 @@ +/* + * 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.NoSuchElementException; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Utility that can be used to map values from a supplied source to a destination. + * Primarily intended to be help when mapping from + * {@link ConfigurationProperties @ConfigrationProperties} to third-party classes. + *

+ * Can filter values based on predicates and adapt values if needed. For example: + *

+ * PropertyMapper map = PropertyMapper.get();
+ * map.from(source::getName)
+ *   .to(destination::setName);
+ * map.from(source::getTimeout)
+ *   .whenNonNull()
+ *   .asInt(Duration::getSeconds)
+ *   .to(destination::setTimeoutSecs);
+ * map.from(source::isEnabled)
+ *   .whenFalse().
+ *   .toCall(destination::disable);
+ * 
+ *

+ * Mappings can ultimately be applied to a {@link Source#to(Consumer) setter}, trigger a + * {@link Source#toCall(Runnable) method call} or create a + * {@link Source#toInstance(Function) new instance}. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public final class PropertyMapper { + + private static final Predicate ALWAYS = (t) -> true; + + private static final PropertyMapper INSTANCE = new PropertyMapper(null, null); + + private final PropertyMapper parent; + + private final SourceOperator sourceOperator; + + private PropertyMapper(PropertyMapper parent, SourceOperator sourceOperator) { + this.parent = parent; + this.sourceOperator = sourceOperator; + } + + /** + * Return a new {@link PropertyMapper} instance that applies + * {@link Source#whenNonNull() whenNonNull} to every source. + * @return a new property mapper instance + */ + public PropertyMapper alwaysApplyingWhenNonNull() { + return alwaysApplying(this::whenNonNull); + } + + private Source whenNonNull(Source source) { + return source.whenNonNull(); + } + + /** + * Return a new {@link PropertyMapper} instance that applies the given + * {@link SourceOperator} to every source. + * @param operator the source operator to apply + * @return a new property mapper instance + */ + public PropertyMapper alwaysApplying(SourceOperator operator) { + Assert.notNull(operator, "Operator must not be null"); + return new PropertyMapper(this, operator); + } + + /** + * Return a new {@link Source} from the specified value supplier that can be used to + * perform the mapping. + * @param the source type + * @param supplier the value supplier + * @return a {@link Source} that can be used to complete the mapping + */ + public Source from(Supplier supplier) { + Assert.notNull(supplier, "Supplier must not be null"); + Source source = getSource(supplier); + if (this.sourceOperator != null) { + source = this.sourceOperator.apply(source); + } + return source; + } + + @SuppressWarnings("unchecked") + private Source getSource(Supplier supplier) { + if (this.parent != null) { + return this.parent.from(supplier); + } + return new Source(new CachingSupplier<>(supplier), (Predicate) ALWAYS); + } + + /** + * Return the property mapper. + * @return the property mapper + */ + public static PropertyMapper get() { + return INSTANCE; + } + + /** + * Supplier that caches the value to prevent multiple calls. + */ + private static class CachingSupplier implements Supplier { + + private final Supplier supplier; + + private boolean hasResult; + + private T result; + + CachingSupplier(Supplier supplier) { + this.supplier = supplier; + } + + @Override + public T get() { + if (!this.hasResult) { + this.result = this.supplier.get(); + this.hasResult = true; + } + return this.result; + } + + } + + /** + * An operation that can be applied to a {@link Source}. + */ + @FunctionalInterface + public interface SourceOperator { + + /** + * Apply the operation to the given source. + * @param the source type + * @param source the source to operate on + * @return the updated source + */ + Source apply(Source source); + + } + + /** + * A source that is in the process of being mapped. + * @param the source type + */ + public final static class Source { + + private final Supplier supplier; + + private final Predicate predicate; + + private Source(Supplier supplier, Predicate predicate) { + Assert.notNull(predicate, "Arse"); + this.supplier = supplier; + this.predicate = predicate; + } + + /** + * Return an adapted version of the source with {@link Integer} type. + * @param the resulting type + * @param adapter an adapter to convert the current value to a number. + * @return a new adapted source instance + */ + public Source asInt(Function adapter) { + return as(adapter).as(Number::intValue); + } + + /** + * Return an adapted version of the source changed via the given adapter function. + * @param the resulting type + * @param adapter the adapter to apply + * @return a new adapted source instance + */ + public Source as(Function adapter) { + Assert.notNull(adapter, "Adapter must not be null"); + Supplier test = () -> this.predicate.test(this.supplier.get()); + Predicate predicate = (t) -> test.get(); + Supplier supplier = () -> { + if (test.get()) { + return adapter.apply(this.supplier.get()); + } + return null; + }; + return new Source(supplier, predicate); + } + + /** + * Return a filtered version of the source that won't map non-null values or + * suppliers that throw a {@link NullPointerException}. + * @return a new filtered source instance + */ + public Source whenNonNull() { + return new Source<>(new NullPointerExceptionSafeSupplier<>(this.supplier), + Objects::nonNull); + } + + /** + * Return a filtered version of the source that will only map values that + * {@code true}. + * @return a new filtered source instance + */ + public Source whenTrue() { + return when(Boolean.TRUE::equals); + } + + /** + * Return a filtered version of the source that will only map values that + * {@code false}. + * @return a new filtered source instance + */ + public Source whenFalse() { + return when(Boolean.FALSE::equals); + } + + /** + * Return a filtered version of the source that will only map values that have a + * {@code toString()} containing actual text. + * @return a new filtered source instance + */ + public Source whenHasText() { + return when((value) -> StringUtils.hasText(Objects.toString(value, null))); + } + + /** + * Return a filtered version of the source that will only map values equal to the + * specified {@code object}. + * @param object the object to match + * @return a new filtered source instance + */ + public Source whenEqualTo(Object object) { + return when(object::equals); + } + + /** + * Return a filtered version of the source that won't map values that match the + * given predicate. + * @param predicate the predicate used to filter values + * @return a new filtered source instance + */ + public Source whenNot(Predicate predicate) { + Assert.notNull(predicate, "Predicate must not be null"); + return new Source<>(this.supplier, predicate.negate()); + } + + /** + * Return a filtered version of the source that won't map values that don't match + * the given predicate. + * @param predicate the predicate used to filter values + * @return a new filtered source instance + */ + public Source when(Predicate predicate) { + Assert.notNull(predicate, "Predicate must not be null"); + return new Source<>(this.supplier, predicate); + } + + /** + * Complete the mapping by passing any non-filtered value to the specified + * consumer. + * @param consumer the consumer that should accept the value if it's not been + * filtered + */ + public void to(Consumer consumer) { + Assert.notNull(consumer, "Consumer must not be null"); + T value = this.supplier.get(); + if (this.predicate.test(value)) { + consumer.accept(value); + } + } + + /** + * Complete the mapping by creating a new instance from the non-filtered value. + * @param the resulting type + * @param factory the factory used to create the instance + * @return the instance + * @throws NoSuchElementException if the value has been filtered + */ + public R toInstance(Function factory) { + Assert.notNull(factory, "Factory must not be null"); + T value = this.supplier.get(); + if (!this.predicate.test(value)) { + throw new NoSuchElementException("No value present"); + } + return factory.apply(value); + } + + /** + * Complete the mapping by calling the specified method when the value has not + * been filtered. + * @param runnable the method to call if the value has not been filtered + */ + public void toCall(Runnable runnable) { + Assert.notNull(runnable, "Runnable must not be null"); + T value = this.supplier.get(); + if (this.predicate.test(value)) { + runnable.run(); + } + } + + } + + /** + * Supplier that will catch and ignore any {@link NullPointerException}. + */ + private static class NullPointerExceptionSafeSupplier implements Supplier { + + private final Supplier supplier; + + NullPointerExceptionSafeSupplier(Supplier supplier) { + this.supplier = supplier; + } + + @Override + public T get() { + try { + return this.supplier.get(); + } + catch (NullPointerException ex) { + return null; + } + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java new file mode 100644 index 0000000000..f8a9bf554f --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/PropertyMapperTests.java @@ -0,0 +1,221 @@ +/* + * 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.function.Supplier; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertyMapper}. + * + * @author Phillip Webb + */ +public class PropertyMapperTests { + + private PropertyMapper map = PropertyMapper.get(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void fromWhenSupplierIsNullShouldThrowException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Supplier must not be null"); + this.map.from(null); + } + + @Test + public void toWhenConsumerIsNullShouldThrowException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Consumer must not be null"); + this.map.from(() -> "").to(null); + } + + @Test + public void toShouldMapFromSupplier() { + ExampleSource source = new ExampleSource("test"); + ExampleDest dest = new ExampleDest(); + this.map.from(source::getName).to(dest::setName); + assertThat(dest.getName()).isEqualTo("test"); + } + + @Test + public void asIntShouldAdaptSupplier() { + Integer result = this.map.from(() -> "123").asInt(Long::valueOf) + .toInstance(Integer::new); + assertThat(result).isEqualTo(123); + } + + @Test + public void asWhenAdapterIsNullShouldThrowException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Adapter must not be null"); + this.map.from(() -> "").as(null); + } + + @Test + public void asShouldAdaptSupplier() { + ExampleDest dest = new ExampleDest(); + this.map.from(() -> 123).as(String::valueOf).to(dest::setName); + assertThat(dest.getName()).isEqualTo("123"); + } + + @Test + public void whenNonNullWhenSuppliedNullShouldNotMap() { + this.map.from(() -> null).whenNonNull().as(String::valueOf).toCall(Assert::fail); + } + + @Test + public void whenNonNullWhenSuppliedThrowsNullPointerExceptionShouldNotMap() { + this.map.from(() -> { + throw new NullPointerException(); + }).whenNonNull().as(String::valueOf).toCall(Assert::fail); + } + + @Test + public void whenTrueWhenValueIsTrueShouldMap() { + Boolean result = this.map.from(() -> true).whenTrue().toInstance(Boolean::new); + assertThat(result).isTrue(); + } + + @Test + public void whenTrueWhenValueIsFalseShouldNotMap() { + this.map.from(() -> false).whenTrue().toCall(Assert::fail); + } + + @Test + public void whenFalseWhenValueIsFalseShouldMap() { + Boolean result = this.map.from(() -> false).whenFalse().toInstance(Boolean::new); + assertThat(result).isFalse(); + } + + @Test + public void whenFalseWhenValueIsTrueShouldNotMap() { + this.map.from(() -> true).whenFalse().toCall(Assert::fail); + } + + @Test + public void whenHasTextWhenValueIsNullShouldNotMap() { + this.map.from(() -> null).whenHasText().toCall(Assert::fail); + } + + @Test + public void whenHasTextWhenValueIsEmptyShouldNotMap() { + this.map.from(() -> "").whenHasText().toCall(Assert::fail); + } + + @Test + public void whenHasTextWhenValueHasTextShouldMap() { + Integer result = this.map.from(() -> 123).whenHasText().toInstance(Integer::new); + assertThat(result).isEqualTo(123); + } + + @Test + public void whenEqualToWhenValueIsEqualShouldMatch() { + String result = this.map.from(() -> "123").whenEqualTo("123") + .toInstance(String::new); + assertThat(result).isEqualTo("123"); + } + + @Test + public void whenEqualToWhenValueIsNotEqualShouldNotMatch() { + this.map.from(() -> "123").whenEqualTo("321").toCall(Assert::fail); + } + + @Test + public void whenWhenValueMatchesShouldMap() { + String result = this.map.from(() -> "123").when("123"::equals) + .toInstance(String::new); + assertThat(result).isEqualTo("123"); + } + + @Test + public void whenWhenValueDoesNotMatchShouldNotMap() { + this.map.from(() -> "123").when("321"::equals).toCall(Assert::fail); + } + + @Test + public void whenWhenCombinedWithAsUsesSourceValue() { + Count source = new Count<>(() -> "123"); + Long result = this.map.from(source).when("123"::equals).as(Integer::valueOf) + .when((v) -> v == 123).as(Integer::longValue).toInstance(Long::new); + assertThat(result).isEqualTo(123); + assertThat(source.getCount()).isOne(); + } + + @Test + public void alwaysApplyingWhenNonNullShouldAlwaysApplyNonNullToSource() { + this.map.alwaysApplyingWhenNonNull().from(() -> null).toCall(Assert::fail); + } + + private static class Count implements Supplier { + + private final Supplier source; + + private int count; + + Count(Supplier source) { + this.source = source; + } + + @Override + public T get() { + this.count++; + return this.source.get(); + } + + public int getCount() { + return this.count; + } + + } + + private static class ExampleSource { + + private final String name; + + ExampleSource(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + + private static class ExampleDest { + + private String name; + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + +}