From a796509532bf0bb4294eae0573a9839943c36403 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Mon, 16 Oct 2023 08:25:13 +0200 Subject: [PATCH] #2035 - Introduce MediaTypeConfigurationCustomizer. HAL and HAL Forms now support customization of the media type-specific configuration via MediaTypeConfigurationCustomizer instances registered in the application context. --- .../MediaTypeConfigurationCustomizer.java | 34 ++++++++ .../MediaTypeConfigurationFactory.java | 77 +++++++++++++++++++ .../hal/HalMediaTypeConfiguration.java | 32 +++----- .../forms/HalFormsMediaTypeConfiguration.java | 51 +++++++----- ...ediaTypeConfigurationFactoryUnitTests.java | 60 +++++++++++++++ 5 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationCustomizer.java create mode 100644 src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactory.java create mode 100644 src/test/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactoryUnitTests.java diff --git a/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationCustomizer.java b/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationCustomizer.java new file mode 100644 index 00000000..85512b88 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationCustomizer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2023 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.hateoas.mediatype; + +/** + * Callback interface to customize media type-specific configuration. Declare instances of the interface as bean methods + * in Spring configuration. + * + * @author Oliver Drotbohm + * @since 2.2 + */ +public interface MediaTypeConfigurationCustomizer { + + /** + * Customize the given configuration instance. + * + * @param configuration will never be {@literal null}. + * @return must not be {@literal null}. + */ + T customize(T configuration); +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactory.java b/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactory.java new file mode 100644 index 00000000..3fc674c4 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactory.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023 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.hateoas.mediatype; + +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.util.Assert; + +/** + * Factory to provide instances of media type-specific configuration processed by + * {@link MediaTypeConfigurationCustomizer}s. + * + * @author Oliver Drotbohm + * @since 2.2 + */ +public class MediaTypeConfigurationFactory> { + + private final Supplier supplier; + private final Supplier> customizers; + + private T resolved; + + /** + * Creates a new {@link MediaTypeConfigurationFactory} for the given supplier of the original instance and all + * {@link MediaTypeConfigurationCustomizer}s. + * + * @param supplier must not be {@literal null}. + * @param customizers must not be {@literal null}. + */ + MediaTypeConfigurationFactory(Supplier supplier, Supplier> customizers) { + + Assert.notNull(supplier, "Supplier must not be null!"); + Assert.notNull(customizers, "Customizers must not be null!"); + + this.supplier = supplier; + this.customizers = customizers; + } + + public MediaTypeConfigurationFactory(Supplier supplier, ObjectProvider customizers) { + this(supplier, () -> customizers.orderedStream()); + } + + /** + * Returns the customized configuration instance. + * + * @return will never be {@literal null}. + */ + public T getConfiguration() { + + if (resolved == null) { + + var source = supplier.get(); + + Assert.notNull(source, "Source instance must not be null!"); + + this.resolved = this.customizers.get() + .reduce(source, (config, customizer) -> customizer.customize(config), (__, r) -> r); + } + + return resolved; + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/HalMediaTypeConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/hal/HalMediaTypeConfiguration.java index 833c069a..a11c3611 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/HalMediaTypeConfiguration.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/HalMediaTypeConfiguration.java @@ -24,6 +24,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.hateoas.client.LinkDiscoverer; import org.springframework.hateoas.config.HypermediaMappingInformation; +import org.springframework.hateoas.mediatype.MediaTypeConfigurationCustomizer; +import org.springframework.hateoas.mediatype.MediaTypeConfigurationFactory; import org.springframework.hateoas.mediatype.MessageResolver; import org.springframework.hateoas.server.LinkRelationProvider; import org.springframework.http.MediaType; @@ -42,19 +44,19 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation { private final LinkRelationProvider relProvider; private final ObjectProvider curieProvider; - private final ObjectProvider halConfiguration; + private final MediaTypeConfigurationFactory> configurationFactory; private final @Qualifier("messageResolver") MessageResolver resolver; private final AutowireCapableBeanFactory beanFactory; - private HalConfiguration resolvedConfiguration; - public HalMediaTypeConfiguration(LinkRelationProvider relProvider, ObjectProvider curieProvider, - ObjectProvider halConfiguration, MessageResolver resolver, - AutowireCapableBeanFactory beanFactory) { + ObjectProvider halConfiguration, + ObjectProvider> customizers, + MessageResolver resolver, AutowireCapableBeanFactory beanFactory) { this.relProvider = relProvider; this.curieProvider = curieProvider; - this.halConfiguration = halConfiguration; + this.configurationFactory = new MediaTypeConfigurationFactory<>( + () -> halConfiguration.getIfAvailable(HalConfiguration::new), customizers); this.resolver = resolver; this.beanFactory = beanFactory; } @@ -70,7 +72,7 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation { */ @Override public List getMediaTypes() { - return getResolvedConfiguration().getMediaTypes(); + return configurationFactory.getConfiguration().getMediaTypes(); } /* @@ -80,7 +82,7 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation { @Override public ObjectMapper configureObjectMapper(ObjectMapper mapper) { - HalConfiguration halConfiguration = getResolvedConfiguration(); + HalConfiguration halConfiguration = configurationFactory.getConfiguration(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.registerModule(new Jackson2HalModule()); @@ -91,18 +93,4 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation { return mapper; } - - /** - * Lookup and cache the {@link HalConfiguration} instance to be used. - * - * @return will never be {@literal null}. - */ - private HalConfiguration getResolvedConfiguration() { - - if (resolvedConfiguration == null) { - this.resolvedConfiguration = halConfiguration.getIfAvailable(HalConfiguration::new); - } - - return resolvedConfiguration; - } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsMediaTypeConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsMediaTypeConfiguration.java index b90d877c..f8742d8e 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsMediaTypeConfiguration.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsMediaTypeConfiguration.java @@ -24,6 +24,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.hateoas.client.LinkDiscoverer; import org.springframework.hateoas.config.HypermediaMappingInformation; +import org.springframework.hateoas.mediatype.MediaTypeConfigurationCustomizer; +import org.springframework.hateoas.mediatype.MediaTypeConfigurationFactory; import org.springframework.hateoas.mediatype.MessageResolver; import org.springframework.hateoas.mediatype.hal.CurieProvider; import org.springframework.hateoas.mediatype.hal.HalConfiguration; @@ -45,22 +47,32 @@ class HalFormsMediaTypeConfiguration implements HypermediaMappingInformation { private final DelegatingLinkRelationProvider relProvider; private final ObjectProvider curieProvider; - private final ObjectProvider halFormsConfiguration; - private final ObjectProvider halConfiguration; + private final MediaTypeConfigurationFactory> configurationFactory; private final MessageResolver resolver; private final AbstractAutowireCapableBeanFactory beanFactory; - private HalFormsConfiguration resolvedConfiguration; - public HalFormsMediaTypeConfiguration(DelegatingLinkRelationProvider relProvider, - ObjectProvider curieProvider, ObjectProvider halFormsConfiguration, - ObjectProvider halConfiguration, MessageResolver resolver, - AbstractAutowireCapableBeanFactory beanFactory) { + ObjectProvider curieProvider, + ObjectProvider halConfiguration, + ObjectProvider> halCustomizers, + ObjectProvider halFormsConfiguration, + ObjectProvider> halFormsCustomizers, + MessageResolver resolver, AbstractAutowireCapableBeanFactory beanFactory) { this.relProvider = relProvider; this.curieProvider = curieProvider; - this.halFormsConfiguration = halFormsConfiguration; - this.halConfiguration = halConfiguration; + + Supplier defaultConfig = () -> { + + MediaTypeConfigurationFactory customizedHalConfiguration = new MediaTypeConfigurationFactory<>( + () -> halConfiguration.getIfAvailable(HalConfiguration::new), halCustomizers); + + return new HalFormsConfiguration( + customizedHalConfiguration.getConfiguration()); + }; + + this.configurationFactory = new MediaTypeConfigurationFactory<>( + () -> halFormsConfiguration.getIfAvailable(defaultConfig), halFormsCustomizers); this.resolver = resolver; this.beanFactory = beanFactory; } @@ -73,7 +85,7 @@ class HalFormsMediaTypeConfiguration implements HypermediaMappingInformation { @Bean HalFormsTemplatePropertyWriter halFormsTemplatePropertyWriter() { - HalFormsConfiguration configuration = getResolvedConfiguration(); + HalFormsConfiguration configuration = configurationFactory.getConfiguration(); HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, resolver); return new HalFormsTemplatePropertyWriter(builder); @@ -86,7 +98,7 @@ class HalFormsMediaTypeConfiguration implements HypermediaMappingInformation { @Override public ObjectMapper configureObjectMapper(ObjectMapper mapper) { - HalFormsConfiguration halFormsConfig = getResolvedConfiguration(); + HalFormsConfiguration halFormsConfig = configurationFactory.getConfiguration(); CurieProvider provider = curieProvider.getIfAvailable(() -> CurieProvider.NONE); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); @@ -105,18 +117,15 @@ class HalFormsMediaTypeConfiguration implements HypermediaMappingInformation { */ @Override public List getMediaTypes() { - return getResolvedConfiguration().getMediaTypes(); + return configurationFactory.getConfiguration().getMediaTypes(); } + /** + * For testing purposes. + * + * @return + */ HalFormsConfiguration getResolvedConfiguration() { - - Supplier defaultConfig = () -> new HalFormsConfiguration( - halConfiguration.getIfAvailable(HalConfiguration::new)); - - if (resolvedConfiguration == null) { - this.resolvedConfiguration = halFormsConfiguration.getIfAvailable(defaultConfig); - } - - return resolvedConfiguration; + return configurationFactory.getConfiguration(); } } diff --git a/src/test/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactoryUnitTests.java b/src/test/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactoryUnitTests.java new file mode 100644 index 00000000..acd65342 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/MediaTypeConfigurationFactoryUnitTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023 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.hateoas.mediatype; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * @author Oliver Drotbohm + */ +@ExtendWith(MockitoExtension.class) +class MediaTypeConfigurationFactoryUnitTests { + + @Mock MediaTypeConfigurationCustomizer first, second; + + @Test // GH-2035 + void invokesCustomizers() { + + var source = new Object(); + var afterFirst = new Object(); + var afterSecond = new Object(); + + doReturn(afterFirst).when(first).customize(source); + doReturn(afterSecond).when(second).customize(afterFirst); + + var factory = new MediaTypeConfigurationFactory<>(() -> source, () -> Stream.of(first, second)); + + assertThat(factory.getConfiguration()).isSameAs(afterSecond); + + verify(first, times(1)).customize(source); + verify(second, times(1)).customize(afterFirst); + + assertThat(factory.getConfiguration()).isSameAs(afterSecond); + + // Does not re-process source instance + verify(first, times(1)).customize(any()); + verify(second, times(1)).customize(any()); + } +}