Skip to content

Commit

Permalink
#2035 - Introduce MediaTypeConfigurationCustomizer.
Browse files Browse the repository at this point in the history
HAL and HAL Forms now support customization of the media type-specific configuration via MediaTypeConfigurationCustomizer instances registered in the application context.
  • Loading branch information
odrotbohm committed Oct 16, 2023
1 parent f3a1177 commit a796509
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -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<T> {

/**
* Customize the given configuration instance.
*
* @param configuration will never be {@literal null}.
* @return must not be {@literal null}.
*/
T customize(T configuration);
}
Original file line number Diff line number Diff line change
@@ -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<T, S extends MediaTypeConfigurationCustomizer<T>> {

private final Supplier<T> supplier;
private final Supplier<Stream<S>> 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<T> supplier, Supplier<Stream<S>> 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<T> supplier, ObjectProvider<S> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
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;
Expand All @@ -42,19 +44,19 @@ public class HalMediaTypeConfiguration implements HypermediaMappingInformation {

private final LinkRelationProvider relProvider;
private final ObjectProvider<CurieProvider> curieProvider;
private final ObjectProvider<HalConfiguration> halConfiguration;
private final MediaTypeConfigurationFactory<HalConfiguration, ? extends MediaTypeConfigurationCustomizer<HalConfiguration>> configurationFactory;
private final @Qualifier("messageResolver") MessageResolver resolver;
private final AutowireCapableBeanFactory beanFactory;

private HalConfiguration resolvedConfiguration;

public HalMediaTypeConfiguration(LinkRelationProvider relProvider, ObjectProvider<CurieProvider> curieProvider,
ObjectProvider<HalConfiguration> halConfiguration, MessageResolver resolver,
AutowireCapableBeanFactory beanFactory) {
ObjectProvider<HalConfiguration> halConfiguration,
ObjectProvider<MediaTypeConfigurationCustomizer<HalConfiguration>> 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;
}
Expand All @@ -70,7 +72,7 @@ LinkDiscoverer halLinkDisocoverer() {
*/
@Override
public List<MediaType> getMediaTypes() {
return getResolvedConfiguration().getMediaTypes();
return configurationFactory.getConfiguration().getMediaTypes();
}

/*
Expand All @@ -80,7 +82,7 @@ public List<MediaType> getMediaTypes() {
@Override
public ObjectMapper configureObjectMapper(ObjectMapper mapper) {

HalConfiguration halConfiguration = getResolvedConfiguration();
HalConfiguration halConfiguration = configurationFactory.getConfiguration();

mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.registerModule(new Jackson2HalModule());
Expand All @@ -91,18 +93,4 @@ public ObjectMapper configureObjectMapper(ObjectMapper mapper) {

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
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;
Expand All @@ -45,22 +47,32 @@ class HalFormsMediaTypeConfiguration implements HypermediaMappingInformation {

private final DelegatingLinkRelationProvider relProvider;
private final ObjectProvider<CurieProvider> curieProvider;
private final ObjectProvider<HalFormsConfiguration> halFormsConfiguration;
private final ObjectProvider<HalConfiguration> halConfiguration;
private final MediaTypeConfigurationFactory<HalFormsConfiguration, ? extends MediaTypeConfigurationCustomizer<HalFormsConfiguration>> configurationFactory;
private final MessageResolver resolver;
private final AbstractAutowireCapableBeanFactory beanFactory;

private HalFormsConfiguration resolvedConfiguration;

public HalFormsMediaTypeConfiguration(DelegatingLinkRelationProvider relProvider,
ObjectProvider<CurieProvider> curieProvider, ObjectProvider<HalFormsConfiguration> halFormsConfiguration,
ObjectProvider<HalConfiguration> halConfiguration, MessageResolver resolver,
AbstractAutowireCapableBeanFactory beanFactory) {
ObjectProvider<CurieProvider> curieProvider,
ObjectProvider<HalConfiguration> halConfiguration,
ObjectProvider<MediaTypeConfigurationCustomizer<HalConfiguration>> halCustomizers,
ObjectProvider<HalFormsConfiguration> halFormsConfiguration,
ObjectProvider<MediaTypeConfigurationCustomizer<HalFormsConfiguration>> halFormsCustomizers,
MessageResolver resolver, AbstractAutowireCapableBeanFactory beanFactory) {

this.relProvider = relProvider;
this.curieProvider = curieProvider;
this.halFormsConfiguration = halFormsConfiguration;
this.halConfiguration = halConfiguration;

Supplier<HalFormsConfiguration> defaultConfig = () -> {

MediaTypeConfigurationFactory<HalConfiguration, ?> 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;
}
Expand All @@ -73,7 +85,7 @@ LinkDiscoverer halFormsLinkDiscoverer() {
@Bean
HalFormsTemplatePropertyWriter halFormsTemplatePropertyWriter() {

HalFormsConfiguration configuration = getResolvedConfiguration();
HalFormsConfiguration configuration = configurationFactory.getConfiguration();
HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, resolver);

return new HalFormsTemplatePropertyWriter(builder);
Expand All @@ -86,7 +98,7 @@ HalFormsTemplatePropertyWriter halFormsTemplatePropertyWriter() {
@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);
Expand All @@ -105,18 +117,15 @@ public ObjectMapper configureObjectMapper(ObjectMapper mapper) {
*/
@Override
public List<MediaType> getMediaTypes() {
return getResolvedConfiguration().getMediaTypes();
return configurationFactory.getConfiguration().getMediaTypes();
}

/**
* For testing purposes.
*
* @return
*/
HalFormsConfiguration getResolvedConfiguration() {

Supplier<HalFormsConfiguration> defaultConfig = () -> new HalFormsConfiguration(
halConfiguration.getIfAvailable(HalConfiguration::new));

if (resolvedConfiguration == null) {
this.resolvedConfiguration = halFormsConfiguration.getIfAvailable(defaultConfig);
}

return resolvedConfiguration;
return configurationFactory.getConfiguration();
}
}
Original file line number Diff line number Diff line change
@@ -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<Object> 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());
}
}

0 comments on commit a796509

Please sign in to comment.