From c561822a4520d8cd4f920b6bea8efff0d2d120a4 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Tue, 26 Jan 2021 20:03:02 +0100 Subject: [PATCH] #1453 - Switch to new converter / codec registration APIs in Spring 5.3.4. Changed the configuration of media type specific representation model rendering to rather use the existing MappingJackson2JsonConverter than registering custom HttpMessageConverters ourselves. The same applies to codecs in WebFlux. Added HypermediaMappingInformationComparator to be able to sort HypermediaMappingInformation instances by their corresponding media type configuration on @EnableHypermediaSupport. --- .../hateoas/config/HateoasConfiguration.java | 6 +- ...HypermediaConfigurationImportSelector.java | 20 +- ...ypermediaMappingInformationComparator.java | 71 ++++ .../HypermediaRestTemplateConfigurer.java | 3 +- .../config/HypermediaWebClientConfigurer.java | 28 +- .../hateoas/config/WebClientConfigurer.java | 11 +- .../config/WebClientHateoasConfiguration.java | 6 +- .../hateoas/config/WebConverters.java | 91 ++-- .../config/WebFluxHateoasConfiguration.java | 68 +-- .../config/WebMvcHateoasConfiguration.java | 2 +- .../config/WebfluxCodecCustomizer.java | 99 +++++ .../CollectionJsonMediaTypeConfiguration.java | 2 +- .../uber/UberMediaTypeConfiguration.java | 2 +- .../config/CustomHypermediaWebFluxTest.java | 3 +- ...nableHypermediaSupportIntegrationTest.java | 402 +++++------------- ...ediaRestTemplateBeanPostProcessorTest.java | 28 +- .../HypermediaRestTemplateConfigurerTest.java | 28 +- .../HypermediaWebClientConfigurerTest.java | 46 +- .../HypermediaWebFluxConfigurerTest.java | 23 +- .../HypermediaWebMvcConfigurerTest.java | 13 +- .../hateoas/mediatype/MediaTypeTestUtils.java | 61 ++- 21 files changed, 514 insertions(+), 499 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/config/HypermediaMappingInformationComparator.java create mode 100644 src/main/java/org/springframework/hateoas/config/WebfluxCodecCustomizer.java diff --git a/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java index 058d27fc..f83ae91e 100644 --- a/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java @@ -20,6 +20,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Properties; import java.util.stream.Collectors; @@ -75,7 +76,10 @@ public class HateoasConfiguration { @Bean WebConverters hypermediaWebMvcConverters(ObjectProvider mapper, - List information) { + List information, Optional comparator) { + + comparator.ifPresent(information::sort); + return WebConverters.of(mapper.getIfUnique(ObjectMapper::new), information); } diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java b/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java index 8d5c9a60..c1a2f23f 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java @@ -22,6 +22,10 @@ import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.ResourceLoaderAware; import org.springframework.context.annotation.ImportSelector; import org.springframework.core.io.ResourceLoader; @@ -38,11 +42,12 @@ import org.springframework.util.ClassUtils; * @author Oliver Drotbohm * @author Greg Turnquist */ -class HypermediaConfigurationImportSelector implements ImportSelector, ResourceLoaderAware { +class HypermediaConfigurationImportSelector implements ImportSelector, ResourceLoaderAware, BeanFactoryAware { public static final String SPRING_TEST = "org.springframework.test.web.reactive.server.WebTestClient"; private ResourceLoader resourceLoader; + private ConfigurableBeanFactory beanFactory; /* * (non-Javadoc) @@ -53,6 +58,15 @@ class HypermediaConfigurationImportSelector implements ImportSelector, ResourceL this.resourceLoader = resourceLoader; } + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org.springframework.beans.factory.BeanFactory) + */ + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = (ConfigurableBeanFactory) beanFactory; + } + /* * (non-Javadoc) * @see org.springframework.context.annotation.ImportSelector#selectImports(org.springframework.core.type.AnnotationMetadata) @@ -68,6 +82,10 @@ class HypermediaConfigurationImportSelector implements ImportSelector, ResourceL .flatMap(it -> it.getMediaTypes().stream()) // .collect(Collectors.toList()); + if (!beanFactory.containsBean("hateoasMediaTypeConfigurer")) { + beanFactory.registerSingleton("hateoasMediaTypeConfigurer", new HypermediaMappingInformationComparator(types)); + } + List configurationProviders = SpringFactoriesLoader.loadFactories( MediaTypeConfigurationProvider.class, HypermediaConfigurationImportSelector.class.getClassLoader()); diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformationComparator.java b/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformationComparator.java new file mode 100644 index 00000000..f50fd01e --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformationComparator.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021 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.config; + +import java.util.Comparator; +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A {@link Comparator} over {@link HypermediaMappingInformation} to sort them by the appearance of the + * {@link MediaType}s configured. I.e. given the media types UBER and HAL FORMS, the + * {@link HypermediaMappingInformation} supporting the former will be ordered before the one for the latter. + * + * @author Oliver Drotbohm + */ +class HypermediaMappingInformationComparator implements Comparator { + + private final List mediaTypes; + + /** + * Creates a new {@link HypermediaMappingInformationComparator} using the given reference {@link MediaType}s. + * + * @param mediaTypes must not be {@literal null}. + */ + HypermediaMappingInformationComparator(List mediaTypes) { + + Assert.notEmpty(mediaTypes, "MediaTypes must not be empty!"); + + this.mediaTypes = mediaTypes; + } + + /* + * (non-Javadoc) + * @see java.util.Comparator#compare(java.lang.Object, java.lang.Object) + */ + @Override + public int compare(@Nullable HypermediaMappingInformation left, @Nullable HypermediaMappingInformation right) { + + for (MediaType mediaType : mediaTypes) { + + boolean leftSupports = left != null && left.getMediaTypes().contains(mediaType); + boolean rightSupports = right != null && right.getMediaTypes().contains(mediaType); + + if (leftSupports && !rightSupports) { + return -1; + } + + if (!leftSupports && rightSupports) { + return 1; + } + } + + return 0; + } +} diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurer.java b/src/main/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurer.java index 3b81e6c7..1a400b5f 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurer.java @@ -44,7 +44,8 @@ public class HypermediaRestTemplateConfigurer { */ public RestTemplate registerHypermediaTypes(RestTemplate template) { - template.setMessageConverters(converters.and(template.getMessageConverters())); + converters.augmentClient(template.getMessageConverters()); + return template; } } diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaWebClientConfigurer.java b/src/main/java/org/springframework/hateoas/config/HypermediaWebClientConfigurer.java index 7b9f179c..9caa22dd 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaWebClientConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaWebClientConfigurer.java @@ -15,14 +15,8 @@ */ package org.springframework.hateoas.config; -import java.util.List; -import java.util.function.Consumer; - -import org.springframework.http.codec.ClientCodecConfigurer; import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.util.Assert; -import org.springframework.util.MimeType; import org.springframework.web.reactive.function.client.WebClient; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,7 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; */ public class HypermediaWebClientConfigurer { - final Consumer configurer; + final WebfluxCodecCustomizer customizer; /** * Creates a new {@link HypermediaWebClientConfigurer} for the given {@link ObjectMapper} and @@ -46,19 +40,8 @@ public class HypermediaWebClientConfigurer { * @param mapper must not be {@literal null}. * @param hypermediaTypes must not be {@literal null}. */ - HypermediaWebClientConfigurer(ObjectMapper mapper, List hypermediaTypes) { - - Assert.notNull(mapper, "ObjectMapper must not be null!"); - Assert.notNull(hypermediaTypes, "HypermediaMappingInformations must not be null!"); - - this.configurer = clientCodecConfigurer -> hypermediaTypes.forEach(hypermediaType -> { - - ObjectMapper objectMapper = hypermediaType.configureObjectMapper(mapper.copy()); - MimeType[] mimeTypes = hypermediaType.getMediaTypes().toArray(new MimeType[0]); - - clientCodecConfigurer.customCodecs().registerWithDefaultConfig(new Jackson2JsonEncoder(objectMapper, mimeTypes)); - clientCodecConfigurer.customCodecs().registerWithDefaultConfig(new Jackson2JsonDecoder(objectMapper, mimeTypes)); - }); + HypermediaWebClientConfigurer(WebfluxCodecCustomizer customizer) { + this.customizer = customizer; } /** @@ -68,6 +51,9 @@ public class HypermediaWebClientConfigurer { * @return {@link WebClient.Builder} registered to handle hypermedia types. */ public WebClient.Builder registerHypermediaTypes(WebClient.Builder builder) { - return builder.codecs(this.configurer); + + return builder.codecs(it -> { + it.defaultCodecs().configureDefaultCodec(customizer); + }); } } diff --git a/src/main/java/org/springframework/hateoas/config/WebClientConfigurer.java b/src/main/java/org/springframework/hateoas/config/WebClientConfigurer.java index 0c061679..4e594fdc 100644 --- a/src/main/java/org/springframework/hateoas/config/WebClientConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/WebClientConfigurer.java @@ -34,7 +34,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @Deprecated public class WebClientConfigurer { - private final HypermediaWebClientConfigurer hypermediaWebClientConfigurer; + private final WebfluxCodecCustomizer customizer; /** * Creates a new {@link WebClientConfigurer} for the given {@link ObjectMapper} and @@ -44,7 +44,7 @@ public class WebClientConfigurer { * @param hypermediaTypes must not be {@literal null}. */ public WebClientConfigurer(ObjectMapper mapper, List hypermediaTypes) { - this.hypermediaWebClientConfigurer = new HypermediaWebClientConfigurer(mapper, hypermediaTypes); + this.customizer = new WebfluxCodecCustomizer(hypermediaTypes, mapper); } /** @@ -55,7 +55,7 @@ public class WebClientConfigurer { public ExchangeStrategies hypermediaExchangeStrategies() { return ExchangeStrategies.builder() // - .codecs(this.hypermediaWebClientConfigurer.configurer) // + .codecs(it -> it.defaultCodecs().configureDefaultCodec(customizer)) // .build(); } @@ -66,6 +66,9 @@ public class WebClientConfigurer { * @return mutated webClient with hypermedia support. */ public WebClient registerHypermediaTypes(WebClient webClient) { - return this.hypermediaWebClientConfigurer.registerHypermediaTypes(webClient.mutate()).build(); + + return webClient.mutate() + .codecs(it -> it.defaultCodecs().configureDefaultCodec(customizer)) + .build(); } } diff --git a/src/main/java/org/springframework/hateoas/config/WebClientHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/WebClientHateoasConfiguration.java index faedb148..3ca06b7b 100644 --- a/src/main/java/org/springframework/hateoas/config/WebClientHateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/WebClientHateoasConfiguration.java @@ -42,7 +42,11 @@ class WebClientHateoasConfiguration { @Lazy HypermediaWebClientConfigurer webClientConfigurer(ObjectProvider mapper, List hypermediaTypes) { - return new HypermediaWebClientConfigurer(mapper.getIfAvailable(ObjectMapper::new), hypermediaTypes); + + WebfluxCodecCustomizer withGenericJsonTypes = new WebfluxCodecCustomizer(hypermediaTypes, + mapper.getIfAvailable(ObjectMapper::new)).withGenericJsonTypes(); + + return new HypermediaWebClientConfigurer(withGenericJsonTypes); } @Bean diff --git a/src/main/java/org/springframework/hateoas/config/WebConverters.java b/src/main/java/org/springframework/hateoas/config/WebConverters.java index b879a0b8..4d26abee 100644 --- a/src/main/java/org/springframework/hateoas/config/WebConverters.java +++ b/src/main/java/org/springframework/hateoas/config/WebConverters.java @@ -15,15 +15,15 @@ */ package org.springframework.hateoas.config; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; -import org.springframework.hateoas.RepresentationModel; -import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; +import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.util.Assert; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,7 +35,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; */ class WebConverters { - private final List> converters; + private static MediaType ANY_JSON = MediaType.parseMediaType("application/*+json"); + + private final List infos; + private final ObjectMapper mapper; /** * Creates a new {@link WebConverters} from the given {@link ObjectMapper} and {@link HypermediaMappingInformation}s. @@ -45,9 +48,8 @@ class WebConverters { */ private WebConverters(ObjectMapper mapper, List mappingInformation) { - this.converters = mappingInformation.stream() // - .map(it -> createMessageConverter(it, it.configureObjectMapper(mapper.copy()))) // - .collect(Collectors.toList()); + this.mapper = mapper; + this.infos = mappingInformation; } /** @@ -65,46 +67,63 @@ class WebConverters { return new WebConverters(mapper, mappingInformations); } + List getSupportedMediaTypes() { + + return infos.stream() // + .flatMap(it -> it.getMediaTypes().stream()) + .collect(Collectors.toList()); + } + /** * Augments the given {@link List} of {@link HttpMessageConverter}s with the hypermedia enabled ones. * * @param converters must not be {@literal null}. */ - public void augment(List> converters) { + public void augmentServer(List> converters) { + augment(converters, false); + } + + public void augmentClient(List> converters) { + augment(converters, true); + } + + private void augment(List> converters, boolean includeGenericJsonTypes) { Assert.notNull(converters, "HttpMessageConverters must not be null!"); - this.converters.forEach(it -> converters.add(0, it)); - } + MappingJackson2HttpMessageConverter converter = converters.stream() + .filter(MappingJackson2HttpMessageConverter.class::isInstance) + .map(MappingJackson2HttpMessageConverter.class::cast) + .findFirst() + .orElseGet(() -> new MappingJackson2HttpMessageConverter(mapper)); - /** - * Returns a new {@link List} of {@link HttpMessageConverter}s consisting of both the hypermedia based ones as well as - * the given ones. - * - * @param converters must not be {@literal null}. - */ - public List> and(Collection> converters) { + ObjectMapper first = null; - Assert.notNull(converters, "HttpMessageConverters must not be null!"); + for (HypermediaMappingInformation info : infos) { - List> result = new ArrayList<>(this.converters); - result.addAll(converters); + Class rootType = info.getRootType(); + ObjectMapper objectMapper = info.configureObjectMapper(mapper.copy()); - return result; - } + if (first == null) { + first = objectMapper; + } - /** - * Creates a new {@link TypeConstrainedMappingJackson2HttpMessageConverter} to handle {@link RepresentationModel} for - * the given {@link HypermediaMappingInformation} using a copy of the given {@link ObjectMapper}. - * - * @param type must not be {@literal null}. - * @param mapper must not be {@literal null}. - * @return - */ - private static AbstractJackson2HttpMessageConverter createMessageConverter(HypermediaMappingInformation type, - ObjectMapper mapper) { + Map mappers = info.getMediaTypes().stream().distinct() + .collect(Collectors.toMap(Function.identity(), __ -> objectMapper)); - return new TypeConstrainedMappingJackson2HttpMessageConverter(type.getRootType(), type.getMediaTypes(), - type.configureObjectMapper(mapper)); + converter.registerObjectMappersForType(rootType, map -> map.putAll(mappers)); + } + + if (!includeGenericJsonTypes) { + return; + } + + Class rootType = infos.get(0).getRootType(); + ObjectMapper mapper = first; + + converter.registerObjectMappersForType(rootType, map -> { + Stream.of(MediaType.APPLICATION_JSON, ANY_JSON) + .forEach(it -> map.put(it, mapper)); + }); } } diff --git a/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java index 21038bdd..a6566abe 100644 --- a/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java @@ -15,21 +15,14 @@ */ package org.springframework.hateoas.config; -import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import org.springframework.core.codec.Decoder; -import org.springframework.core.codec.Encoder; -import org.springframework.http.MediaType; -import org.springframework.http.codec.CodecConfigurer.CustomCodecs; import org.springframework.http.codec.ServerCodecConfigurer; -import org.springframework.http.codec.json.Jackson2JsonDecoder; -import org.springframework.http.codec.json.Jackson2JsonEncoder; -import org.springframework.util.MimeType; import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.reactive.config.WebFluxConfigurer; @@ -45,19 +38,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; @Configuration(proxyBeanMethods = false) class WebFluxHateoasConfiguration { - @Bean - WebFluxCodecs hypermediaConverters(ObjectProvider mapper, - List mappingInformation) { - return new WebFluxCodecs(mapper.getIfAvailable(ObjectMapper::new), mappingInformation); - } - @Bean HypermediaWebFluxConfigurer hypermediaWebFluxConfigurer(ObjectProvider mapper, - List mappingInformation) { + List mappingInformation, + Optional comparator) { - WebFluxCodecs codecs = new WebFluxCodecs(mapper.getIfAvailable(ObjectMapper::new), mappingInformation); + comparator.ifPresent(mappingInformation::sort); - return new HypermediaWebFluxConfigurer(codecs); + WebfluxCodecCustomizer customizer = new WebfluxCodecCustomizer(mappingInformation, + mapper.getIfAvailable(ObjectMapper::new)); + + return new HypermediaWebFluxConfigurer(customizer); } @Bean @@ -75,10 +66,10 @@ class WebFluxHateoasConfiguration { */ static class HypermediaWebFluxConfigurer implements WebFluxConfigurer { - private final WebFluxCodecs codecs; + private final WebfluxCodecCustomizer customizer; - public HypermediaWebFluxConfigurer(WebFluxCodecs codecs) { - this.codecs = codecs; + public HypermediaWebFluxConfigurer(WebfluxCodecCustomizer customizer) { + this.customizer = customizer; } /** @@ -90,42 +81,7 @@ class WebFluxHateoasConfiguration { */ @Override public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { - codecs.registerCodecs(configurer.customCodecs()); - } - } - - private static class WebFluxCodecs { - - private final List> decoders; - private final List> encoders; - - private WebFluxCodecs(ObjectMapper mapper, List mappingInformation) { - - this.decoders = new ArrayList<>(); - this.encoders = new ArrayList<>(); - - for (HypermediaMappingInformation information : mappingInformation) { - - ObjectMapper objectMapper = information.configureObjectMapper(mapper.copy()); - List mediaTypes = information.getMediaTypes(); - - this.decoders.add(getDecoder(objectMapper, mediaTypes)); - this.encoders.add(getEncoder(objectMapper, mediaTypes)); - } - } - - public void registerCodecs(CustomCodecs codecs) { - - decoders.forEach(codecs::registerWithDefaultConfig); - encoders.forEach(codecs::registerWithDefaultConfig); - } - - private static Decoder getDecoder(ObjectMapper mapper, List mediaTypes) { - return new Jackson2JsonDecoder(mapper, mediaTypes.toArray(new MimeType[0])); - } - - private static Encoder getEncoder(ObjectMapper mapper, List mediaTypes) { - return new Jackson2JsonEncoder(mapper, mediaTypes.toArray(new MimeType[0])); + configurer.defaultCodecs().configureDefaultCodec(customizer); } } } diff --git a/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java index 0af5cd06..44c04472 100644 --- a/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java @@ -91,7 +91,7 @@ class WebMvcHateoasConfiguration { */ @Override public void extendMessageConverters(List> converters) { - hypermediaConverters.augment(converters); + hypermediaConverters.augmentServer(converters); } } diff --git a/src/main/java/org/springframework/hateoas/config/WebfluxCodecCustomizer.java b/src/main/java/org/springframework/hateoas/config/WebfluxCodecCustomizer.java new file mode 100644 index 00000000..e1a1025c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/WebfluxCodecCustomizer.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 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.config; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.Jackson2CodecSupport; +import org.springframework.lang.Nullable; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Oliver Drotbohm + */ +class WebfluxCodecCustomizer implements Consumer { + + private static final MediaType ANY_JSON = MediaType.parseMediaType("application/*+json"); + + private final List mappingInformations; + private final ObjectMapper mapper; + private final boolean withGenericJsonTypes; + + /** + * @param mappingInformations + * @param mapper + */ + public WebfluxCodecCustomizer(List mappingInformations, ObjectMapper mapper) { + this(mappingInformations, mapper, false); + } + + private WebfluxCodecCustomizer(List mappingInformations, ObjectMapper mapper, + boolean withGenericJsonTypes) { + + this.mappingInformations = mappingInformations; + this.mapper = mapper; + this.withGenericJsonTypes = withGenericJsonTypes; + } + + WebfluxCodecCustomizer withGenericJsonTypes() { + return new WebfluxCodecCustomizer(mappingInformations, mapper, true); + } + + /* + * (non-Javadoc) + * @see java.util.function.Consumer#accept(java.lang.Object) + */ + @Override + public void accept(@Nullable Object it) { + + if (it == null || !Jackson2CodecSupport.class.isInstance(it)) { + return; + } + + Jackson2CodecSupport codec = (Jackson2CodecSupport) it; + ObjectMapper firstMapper = null; + + for (HypermediaMappingInformation information : mappingInformations) { + + ObjectMapper objectMapper = information.configureObjectMapper(mapper.copy()); + + if (firstMapper == null) { + firstMapper = objectMapper; + } + + for (MediaType mediaType : information.getMediaTypes()) { + codec.registerObjectMappersForType(information.getRootType(), map -> { + map.put(mediaType, objectMapper); + }); + } + } + + if (!withGenericJsonTypes) { + return; + } + + Class type = mappingInformations.get(0).getRootType(); + ObjectMapper mapper = firstMapper; + + codec.registerObjectMappersForType(type, map -> { + Stream.of(MediaType.APPLICATION_JSON, ANY_JSON).forEach(mediaType -> map.put(mediaType, mapper)); + }); + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonMediaTypeConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonMediaTypeConfiguration.java index cbbea52e..43d185fd 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonMediaTypeConfiguration.java +++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonMediaTypeConfiguration.java @@ -33,7 +33,7 @@ import com.fasterxml.jackson.databind.Module; * @author Greg Turnquist * @author Oliver Drotbohm */ -@Configuration +@Configuration(proxyBeanMethods = false) class CollectionJsonMediaTypeConfiguration implements HypermediaMappingInformation { @Bean diff --git a/src/main/java/org/springframework/hateoas/mediatype/uber/UberMediaTypeConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/uber/UberMediaTypeConfiguration.java index 3e948730..c544ffc7 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/uber/UberMediaTypeConfiguration.java +++ b/src/main/java/org/springframework/hateoas/mediatype/uber/UberMediaTypeConfiguration.java @@ -33,7 +33,7 @@ import com.fasterxml.jackson.databind.Module; * @author Greg Turnquist * @author Oliver Drotbohm */ -@Configuration +@Configuration(proxyBeanMethods = false) class UberMediaTypeConfiguration implements HypermediaMappingInformation { @Bean diff --git a/src/test/java/org/springframework/hateoas/config/CustomHypermediaWebFluxTest.java b/src/test/java/org/springframework/hateoas/config/CustomHypermediaWebFluxTest.java index 00e4d095..5617fd08 100644 --- a/src/test/java/org/springframework/hateoas/config/CustomHypermediaWebFluxTest.java +++ b/src/test/java/org/springframework/hateoas/config/CustomHypermediaWebFluxTest.java @@ -56,7 +56,8 @@ class CustomHypermediaWebFluxTest { this.testClient = WebTestClient.bindToApplicationContext(ctx).build() // .mutate() // - .exchangeStrategies(it -> it.codecs(webClientConfigurer.configurer)) // + .exchangeStrategies( + it -> it.codecs(inner -> inner.defaultCodecs().configureDefaultCodec(webClientConfigurer.customizer))) // .build(); } diff --git a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java index d9807d9b..c14182a6 100755 --- a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java @@ -53,11 +53,12 @@ import org.springframework.hateoas.server.EntityLinks; import org.springframework.hateoas.server.LinkRelationProvider; import org.springframework.hateoas.server.core.DelegatingEntityLinks; import org.springframework.hateoas.server.core.DelegatingLinkRelationProvider; -import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.mock.http.MockHttpOutputMessage; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ReflectionUtils; import org.springframework.web.client.RestTemplate; @@ -69,8 +70,6 @@ import org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConv import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; /** * Integration tests for {@link EnableHypermediaSupport}. @@ -195,335 +194,110 @@ class EnableHypermediaSupportIntegrationTest { assertUberSetupForConfigClass(ExtendedUberConfig.class); } - /** - * @see #134, #219 - */ @Test - @SuppressWarnings("unchecked") - void halSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - - withServletContext(HalConfig.class, context -> { - - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); - - assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()) // - .contains(MediaTypes.HAL_JSON); - - boolean found = false; - - for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { - - if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { - - found = true; - - AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; - List> converters = (List>) ReflectionTestUtils - .getField(processor, "messageConverters"); - - assertThat(converters.get(0)).isInstanceOfSatisfying(TypeConstrainedMappingJackson2HttpMessageConverter.class, - it -> assertThat(it.getSupportedMediaTypes()).contains(MediaTypes.HAL_JSON)); - } - } - - assertThat(found).isTrue(); - }); - } - - @Test - @SuppressWarnings("unchecked") - void halFormsSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - - withServletContext(HalFormsConfig.class, context -> { - - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); - - assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()).hasSize(1) - .contains(MediaTypes.HAL_FORMS_JSON); - - boolean found = false; - - for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { - - if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { - - found = true; - - AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; - List> converters = (List>) ReflectionTestUtils - .getField(processor, "messageConverters"); - - assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class); - assertThat(converters.get(0).getSupportedMediaTypes()).hasSize(1).contains(MediaTypes.HAL_FORMS_JSON); - } - } - - assertThat(found).isTrue(); - }); - } - - @Test - @SuppressWarnings("unchecked") void collectionJsonSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { withServletContext(CollectionJsonConfig.class, context -> { + assertMediaTypeSupported(context, MediaTypes.COLLECTION_JSON, RepresentationModel.class); + }); + } - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); + private static Object assertMediaTypeSupported(ApplicationContext context, MediaType mediaType, Class type) { + return assertMediaTypeSupported(context, mediaType, type, null); + } - assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()).hasSize(1) - .contains(MediaTypes.COLLECTION_JSON); + @Nullable + private static String assertMediaTypeSupported(ApplicationContext context, MediaType mediaType, Class type, + @Nullable Object source) { - boolean found = false; + context.getBeanProvider(RestTemplate.class).ifAvailable(it -> { + assertMediaTypeSupported(it.getMessageConverters(), mediaType, type, source); + }); - for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { + RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); - if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { + boolean found = false; - found = true; + for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { - AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; - List> converters = (List>) ReflectionTestUtils - .getField(processor, "messageConverters"); + if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { - assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class); - assertThat(converters.get(0).getSupportedMediaTypes()).hasSize(1).contains(MediaTypes.COLLECTION_JSON); - } + found = true; + + AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; + List> converters = (List>) ReflectionTestUtils + .getField(processor, "messageConverters"); + + assertMediaTypeSupported(converters, MediaTypes.HAL_FORMS_JSON, RepresentationModel.class); } - - assertThat(found).isTrue(); - }); - } - - @Test - @SuppressWarnings("unchecked") - void uberSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - - withServletContext(UberConfig.class, context -> { - - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); - - assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()) // - .hasSize(1) // - .contains(MediaTypes.UBER_JSON); - - boolean found = false; - - for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { - - if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { - - found = true; - - AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; - List> converters = (List>) ReflectionTestUtils - .getField(processor, "messageConverters"); - - assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class); - assertThat(converters.get(0).getSupportedMediaTypes()) // - .hasSize(1) // - .contains(MediaTypes.UBER_JSON); - } - } - - assertThat(found).isTrue(); - }); - } - - /** - * @see #293 - */ - @Test - void registersHalHttpMessageConvertersForRestTemplate() { - - withServletContext(HalConfig.class, context -> { - - RestTemplate template = context.getBean(RestTemplate.class); - - assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()) // - .contains(MediaTypes.HAL_JSON); - }); - } - - @Test - void registersHalFormsHttpMessageConvertersForRestTemplate() { - - withServletContext( // - HalFormsConfig.class, // - context -> foo( // - context, // - RestTemplate.class, // - it -> it.getMessageConverters().get(0), // - converter -> assertThat(converter.getSupportedMediaTypes()) // // - .hasSize(1) // - .contains(MediaTypes.HAL_FORMS_JSON) // - ) // - ); - } - - private static void foo(ApplicationContext context, Class beanType, Function extractor, - ThrowingConsumer consumer) { - - T bean = context.getBean(beanType); - S result = extractor.apply(bean); - - try { - consumer.accept(result); - } catch (Throwable o_O) { - throw new RuntimeException(o_O); } + + assertThat(found).isTrue(); + + return assertMediaTypeSupported(adapter.getMessageConverters(), mediaType, type, source); } - private static void assertObjectMapper(ApplicationContext context, MediaType mediaType, - ThrowingConsumer consumer) { - - Function mapper = adapter -> { - - Optional result = adapter.getMessageConverters().stream()// - .filter(it -> it.getSupportedMediaTypes().contains(mediaType)).findFirst() // - .map(AbstractJackson2HttpMessageConverter.class::cast) // - .map(AbstractJackson2HttpMessageConverter::getObjectMapper); - - if (!result.isPresent()) { - fail("Couldn't find ObjectMapper from HttpMessageConverter supporting " + mediaType); - } - - return result.orElseThrow(IllegalStateException::new); - }; - - foo(context, RequestMappingHandlerAdapter.class, mapper, consumer); + @Nullable + private static Object assertMediaTypeSupported(List> converters, MediaType mediaType, + Class type) { + return assertMediaTypeSupported(converters, mediaType, type, null); } - interface ThrowingConsumer { - void accept(T source) throws Throwable; - } + @Nullable + private static String assertMediaTypeSupported(List> converters, MediaType mediaType, + Class type, @Nullable Object source) { - @Test - void registersCollectionJsonHttpMessageConvertersForRestTemplate() { + Optional result = converters.stream()// + .filter(AbstractJackson2HttpMessageConverter.class::isInstance) // + .findFirst() // + .map(AbstractJackson2HttpMessageConverter.class::cast); - withServletContext(CollectionJsonConfig.class, context -> { - RestTemplate template = context.getBean(RestTemplate.class); - - assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()).hasSize(1) - .contains(MediaTypes.COLLECTION_JSON); + assertThat(result).hasValueSatisfying(it -> { + assertThat(it.getSupportedMediaTypes(type)); }); - } - @Test - void registersUberHttpMessageConvertersForRestTemplate() { + if (source == null) { + return null; + } - withServletContext(UberConfig.class, context -> { - RestTemplate template = context.getBean(RestTemplate.class); + HttpMessageConverter converter = result.get(); + MockHttpOutputMessage message = new MockHttpOutputMessage(); - assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()) // - .hasSize(1) // - .contains(MediaTypes.UBER_JSON); - }); - } + assertThatCode(() -> converter.write(source, mediaType, message)).doesNotThrowAnyException(); - /** - * @see #341 - */ - @Test - void configuresDefaultObjectMapperForHalToIgnoreUnknownProperties() { - - withServletContext( // - HalConfig.class, // - context -> assertObjectMapper( // - context, // - MediaTypes.HAL_JSON, // - mapper -> assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) // - .isFalse() // - ) // - ); - } - - /** - * @see #341 - */ - @Test - void configuresDefaultObjectMapperForHalFormsToIgnoreUnknownProperties() { - - withServletContext( // - HalFormsConfig.class, // - context -> assertObjectMapper( // - context, // - MediaTypes.HAL_FORMS_JSON, // - mapper -> assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) // - .isFalse() // - ) // - ); - } - - @Test - void configuresDefaultObjectMapperForCollectionJsonToIgnoreUnknownProperties() { - - withServletContext( // - CollectionJsonConfig.class, // - context -> assertObjectMapper( // - context, // - MediaTypes.COLLECTION_JSON, // - mapper -> assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) // - .isFalse() // - ) // - ); - } - - @Test - void configuresDefaultObjectMapperForUberToIgnoreUnknownProperties() { - - withServletContext( // - UberConfig.class, // - context -> assertObjectMapper( // - context, // - MediaTypes.UBER_JSON, // - mapper -> assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) // - .isFalse() // - ) // - ); + return message.getBodyAsString(); } @Test void verifyDefaultHalConfigurationRendersSingleItemAsSingleItem() throws JsonProcessingException { + RepresentationModel resourceSupport = new RepresentationModel<>(); + resourceSupport.add(Link.of("localhost").withSelfRel()); + withServletContext(HalConfig.class, context -> { - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); + assertMediaTypeSupported(context.getBean(RestTemplate.class).getMessageConverters(), MediaTypes.HAL_FORMS_JSON, + RepresentationModel.class); - Optional mapper = adapter.getMessageConverters().stream() // - .filter(it -> it.getSupportedMediaTypes().contains(MediaType.parseMediaType("application/hal+json"))) // - .findFirst() // - .map(AbstractJackson2HttpMessageConverter.class::cast) // - .map(AbstractJackson2HttpMessageConverter::getObjectMapper); + String result = assertMediaTypeSupported(context, MediaTypes.HAL_JSON, RepresentationModel.class, + resourceSupport); - assertThat(mapper).hasValueSatisfying(it -> { - - RepresentationModel resourceSupport = new RepresentationModel<>(); - resourceSupport.add(Link.of("localhost").withSelfRel()); - - assertThatCode(() -> { - assertThat(it.writeValueAsString(resourceSupport)) // - .isEqualTo("{\"_links\":{\"self\":{\"href\":\"localhost\"}}}"); - }).doesNotThrowAnyException(); - }); + assertThat(result).isEqualTo("{\"_links\":{\"self\":{\"href\":\"localhost\"}}}"); }); } @Test void verifyRenderSingleLinkAsArrayViaOverridingBean() { - withServletContext( // - RenderLinkAsSingleLinksConfig.class, // - context -> assertObjectMapper( // - context, // - MediaTypes.HAL_JSON, // - mapper -> { // - RepresentationModel resourceSupport = new RepresentationModel<>(); // - resourceSupport.add(Link.of("localhost").withSelfRel()); // - assertThat(mapper.writeValueAsString(resourceSupport)) // - .isEqualTo("{\"_links\":{\"self\":[{\"href\":\"localhost\"}]}}"); // - } // - ) // - ); + RepresentationModel model = new RepresentationModel<>(); // + model.add(Link.of("localhost").withSelfRel()); // + + withServletContext(RenderLinkAsSingleLinksConfig.class, context -> { + + String result = assertMediaTypeSupported(context, MediaTypes.HAL_JSON, RepresentationModel.class, model); + + assertThat(result).isEqualTo("{\"_links\":{\"self\":[{\"href\":\"localhost\"}]}}"); // + }); } @Test // #1019 @@ -571,6 +345,31 @@ class EnableHypermediaSupportIntegrationTest { }); } + /* + * HAL FORMS, UBER + * RepresentationModel -> hal-forms, application/json, application/*+json + * RepresentationModel -> uber, application/json, application/*+json + * + * hal-forms, uber, application/json, application/*+json + * + * + * + * + * + */ + + @Test + void ordersMediaTypeIntegrationBasedOnConfiguration() { + + withServletContext(MediaTypeOrdering.class, context -> { + + WebConverters converters = context.getBean(WebConverters.class); + + assertThat(converters.getSupportedMediaTypes()) // + .containsExactly(MediaTypes.UBER_JSON, MediaTypes.HAL_FORMS_JSON); + }); + } + private static void assertEntityLinksSetUp(ApplicationContext context) { assertThat(context.getBeansOfType(EntityLinks.class).values()) // @@ -602,10 +401,7 @@ class EnableHypermediaSupportIntegrationTest { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(HalFormsLinkDiscoverer.class); - - RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class); - assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class); - + assertMediaTypeSupported(context, MediaTypes.HAL_FORMS_JSON, RepresentationModel.class); }); } @@ -615,9 +411,7 @@ class EnableHypermediaSupportIntegrationTest { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(CollectionJsonLinkDiscoverer.class); - - RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class); - assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class); + assertMediaTypeSupported(context, MediaTypes.COLLECTION_JSON, RepresentationModel.class); }); } @@ -627,9 +421,7 @@ class EnableHypermediaSupportIntegrationTest { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(UberLinkDiscoverer.class); - - RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class); - assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class); + assertMediaTypeSupported(context, MediaTypes.UBER_JSON, RepresentationModel.class); }); } @@ -790,4 +582,10 @@ class EnableHypermediaSupportIntegrationTest { static class HalAndHalFormsConfig { } + + @Configuration + @EnableHypermediaSupport(type = { HypermediaType.UBER, HypermediaType.HAL_FORMS }) + static class MediaTypeOrdering { + + } } diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateBeanPostProcessorTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateBeanPostProcessorTest.java index 8da41f41..dba786d8 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateBeanPostProcessorTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateBeanPostProcessorTest.java @@ -16,22 +16,22 @@ package org.springframework.hateoas.config; import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.springframework.hateoas.mediatype.MediaTypeTestUtils.*; import static org.springframework.hateoas.support.ContextTester.*; -import java.util.Collection; import java.util.List; -import java.util.stream.Collectors; +import java.util.function.Function; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.config.RestTemplateHateoasConfiguration.HypermediaRestTemplateBeanPostProcessor; import org.springframework.hateoas.support.CustomHypermediaType; import org.springframework.http.MediaType; -import org.springframework.http.converter.AbstractHttpMessageConverter; -import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.client.RestTemplate; /** @@ -41,6 +41,9 @@ import org.springframework.web.client.RestTemplate; */ class HypermediaRestTemplateBeanPostProcessorTest { + static final Function>> REST_TEMPLATE_EXTRACTOR = it -> it + .getBean(RestTemplate.class).getMessageConverters(); + /** * @see #728 */ @@ -49,7 +52,7 @@ class HypermediaRestTemplateBeanPostProcessorTest { withContext(HalConfig.class, context -> { - assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) // + assertThat(getSupportedHypermediaTypes(context, REST_TEMPLATE_EXTRACTOR)) // .containsExactlyInAnyOrder( // MediaTypes.HAL_JSON, // MediaType.APPLICATION_JSON, // @@ -65,7 +68,7 @@ class HypermediaRestTemplateBeanPostProcessorTest { withContext(HalAndCollectionJsonConfig.class, context -> { - assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) // + assertThat(getSupportedHypermediaTypes(context, REST_TEMPLATE_EXTRACTOR)) // .containsExactlyInAnyOrder( // MediaTypes.HAL_JSON, // MediaTypes.COLLECTION_JSON, // @@ -82,7 +85,7 @@ class HypermediaRestTemplateBeanPostProcessorTest { withContext(AllHypermediaConfig.class, context -> { - assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) // + assertThat(getSupportedHypermediaTypes(context, REST_TEMPLATE_EXTRACTOR)) // .containsExactlyInAnyOrder( // MediaTypes.HAL_JSON, // MediaTypes.HAL_FORMS_JSON, // @@ -98,7 +101,7 @@ class HypermediaRestTemplateBeanPostProcessorTest { withContext(CustomHypermediaConfig.class, context -> { - assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) // + assertThat(getSupportedHypermediaTypes(context, REST_TEMPLATE_EXTRACTOR)) // .containsExactlyInAnyOrder( // MediaTypes.HAL_JSON, // MediaType.parseMediaType("application/frodo+json"), // @@ -108,13 +111,6 @@ class HypermediaRestTemplateBeanPostProcessorTest { }); } - private List lookupSupportedHypermediaTypes(RestTemplate restTemplate) { - - return restTemplate.getMessageConverters().stream().filter(MappingJackson2HttpMessageConverter.class::isInstance) - .map(AbstractJackson2HttpMessageConverter.class::cast).map(AbstractHttpMessageConverter::getSupportedMediaTypes) - .flatMap(Collection::stream).collect(Collectors.toList()); - } - static class BaseConfig { @Bean diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurerTest.java index d427939b..70a2f3b9 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaRestTemplateConfigurerTest.java @@ -1,7 +1,23 @@ +/* + * Copyright 2021 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.config; import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; +import static org.springframework.hateoas.mediatype.MediaTypeTestUtils.*; import static org.springframework.hateoas.support.ContextTester.*; import java.util.Collections; @@ -10,7 +26,6 @@ import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Bean; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.client.RestTemplate; public class HypermediaRestTemplateConfigurerTest { @@ -23,10 +38,9 @@ public class HypermediaRestTemplateConfigurerTest { withContext(HalConfig.class, context -> { HypermediaRestTemplateConfigurer configurer = context.getBean(HypermediaRestTemplateConfigurer.class); - RestTemplate restTemplate = configurer.registerHypermediaTypes(new RestTemplate()); - assertThat(restTemplate.getMessageConverters()).flatExtracting(HttpMessageConverter::getSupportedMediaTypes) + assertThat(getSupportedHypermediaTypes(restTemplate.getMessageConverters())) // .contains(MediaTypes.HAL_JSON) // .doesNotContain(MediaTypes.HAL_FORMS_JSON, MediaTypes.COLLECTION_JSON, MediaTypes.UBER_JSON); }); @@ -38,10 +52,9 @@ public class HypermediaRestTemplateConfigurerTest { withContext(AllHypermediaConfig.class, context -> { HypermediaRestTemplateConfigurer configurer = context.getBean(HypermediaRestTemplateConfigurer.class); - RestTemplate restTemplate = configurer.registerHypermediaTypes(new RestTemplate()); - assertThat(restTemplate.getMessageConverters()).flatExtracting(HttpMessageConverter::getSupportedMediaTypes) + assertThat(getSupportedHypermediaTypes(restTemplate.getMessageConverters())) // .contains(MediaTypes.HAL_JSON, MediaTypes.HAL_FORMS_JSON, MediaTypes.COLLECTION_JSON, MediaTypes.UBER_JSON); }); } @@ -52,11 +65,10 @@ public class HypermediaRestTemplateConfigurerTest { withContext(CustomHypermediaConfig.class, context -> { HypermediaRestTemplateConfigurer configurer = context.getBean(HypermediaRestTemplateConfigurer.class); - RestTemplate restTemplate = configurer.registerHypermediaTypes(new RestTemplate()); - assertThat(restTemplate.getMessageConverters()).flatExtracting(HttpMessageConverter::getSupportedMediaTypes) - .contains(MediaTypes.HAL_JSON, FRODO_JSON) + assertThat(getSupportedHypermediaTypes(restTemplate.getMessageConverters())) // + .contains(MediaTypes.HAL_JSON, FRODO_JSON) // .doesNotContain(MediaTypes.HAL_FORMS_JSON, MediaTypes.COLLECTION_JSON, MediaTypes.UBER_JSON); }); } diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaWebClientConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaWebClientConfigurerTest.java index b5d8b8a9..f84c23f2 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaWebClientConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaWebClientConfigurerTest.java @@ -1,7 +1,23 @@ +/* + * Copyright 2021 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.config; import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.MediaTypes.*; +import static org.springframework.hateoas.mediatype.MediaTypeTestUtils.*; import static org.springframework.hateoas.support.ContextTester.*; import java.util.Collections; @@ -9,11 +25,8 @@ import java.util.Collections; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Bean; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.mediatype.MediaTypeTestUtils; import org.springframework.http.MediaType; -import org.springframework.http.codec.HttpMessageReader; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; public class HypermediaWebClientConfigurerTest { @@ -26,11 +39,9 @@ public class HypermediaWebClientConfigurerTest { withContext(HalConfig.class, context -> { HypermediaWebClientConfigurer configurer = context.getBean(HypermediaWebClientConfigurer.class); - WebClient webClient = configurer.registerHypermediaTypes(WebClient.builder()).build(); - assertThat(exchangeStrategies(webClient).messageReaders()) - .flatExtracting(HttpMessageReader::getReadableMediaTypes) // + assertThat(getSupportedHypermediaTypes(webClient)) // .contains(HAL_JSON) // .doesNotContain(HAL_FORMS_JSON, COLLECTION_JSON, UBER_JSON); }); @@ -42,11 +53,9 @@ public class HypermediaWebClientConfigurerTest { withContext(AllHypermediaConfig.class, context -> { HypermediaWebClientConfigurer configurer = context.getBean(HypermediaWebClientConfigurer.class); - WebClient webClient = configurer.registerHypermediaTypes(WebClient.builder()).build(); - assertThat(exchangeStrategies(webClient).messageReaders()) - .flatExtracting(HttpMessageReader::getReadableMediaTypes) // + assertThat(getSupportedHypermediaTypes(webClient)) // .contains(HAL_JSON, HAL_FORMS_JSON, COLLECTION_JSON, UBER_JSON); }); } @@ -57,29 +66,14 @@ public class HypermediaWebClientConfigurerTest { withContext(CustomHypermediaConfig.class, context -> { HypermediaWebClientConfigurer configurer = context.getBean(HypermediaWebClientConfigurer.class); - WebClient webClient = configurer.registerHypermediaTypes(WebClient.builder()).build(); - assertThat(exchangeStrategies(webClient).messageReaders()) - .flatExtracting(HttpMessageReader::getReadableMediaTypes) // + assertThat(MediaTypeTestUtils.getSupportedHypermediaTypes(webClient)) // .contains(HAL_JSON, FRODO_JSON) // .doesNotContain(HAL_FORMS_JSON, COLLECTION_JSON, UBER_JSON); }); } - /** - * Extract the {@link ExchangeStrategies} from a {@link WebTestClient} to assert it has the proper message readers and - * writers. - * - * @param webClient - * @return - */ - private static ExchangeStrategies exchangeStrategies(WebClient webClient) { - - return (ExchangeStrategies) ReflectionTestUtils - .getField(ReflectionTestUtils.getField(webClient, "exchangeFunction"), "strategies"); - } - @EnableHypermediaSupport(type = HypermediaType.HAL) static class HalConfig { diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java index 078d6d2d..bfb60666 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java @@ -17,6 +17,7 @@ package org.springframework.hateoas.config; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.hamcrest.CoreMatchers.*; import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.*; @@ -261,7 +262,7 @@ class HypermediaWebFluxConfigurerTest { } /** - * When requesting an unregistered media type, fallback to Spring Framework's default JSON handler. + * When requesting an unregistered media type, expect a 406 Not Acceptable. * * @see #728 */ @@ -272,10 +273,9 @@ class HypermediaWebFluxConfigurerTest { this.testClient.get().uri("/").accept(MediaTypes.UBER_JSON) // .exchange() // - .expectStatus().isOk() // + .expectStatus().value(is(406)) .returnResult(String.class).getResponseBody() // .as(StepVerifier::create) // - .expectNext("{\"links\":[{\"rel\":\"self\",\"href\":\"/\"},{\"rel\":\"employees\",\"href\":\"/employees\"}]}") .verifyComplete(); } @@ -316,10 +316,14 @@ class HypermediaWebFluxConfigurerTest { return true; }).verifyComplete(); - this.testClient.get().uri("/reactive/employees/1").accept(MediaTypes.HAL_JSON).exchange() // - .expectStatus().isOk().expectHeader().contentType(MediaTypes.HAL_JSON) // + this.testClient.get() // + .uri("/reactive/employees/1") // + .accept(MediaTypes.HAL_JSON).exchange() // + .expectStatus().isOk() // + .expectHeader().contentType(MediaTypes.HAL_JSON) // .returnResult(this.resourceEmployeeType).getResponseBody() // - .as(StepVerifier::create).expectNextMatches(employee -> { + .as(StepVerifier::create) // + .expectNextMatches(employee -> { assertThat(employee.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); assertThat(employee.getLinks()) // @@ -336,8 +340,8 @@ class HypermediaWebFluxConfigurerTest { this.testClient.get().uri("/sample/4711").exchange() // .expectStatus().isEqualTo(HttpStatus.I_AM_A_TEAPOT) // - .returnResult(String.class).getResponseBody() - .as(StepVerifier::create) + .returnResult(String.class).getResponseBody() // + .as(StepVerifier::create) // .expectNextMatches(it -> { assertThat(it).isEqualTo("/sample/sample"); @@ -356,7 +360,8 @@ class HypermediaWebFluxConfigurerTest { this.testClient.get().uri("/").accept(requestType).exchange() // .expectStatus().isOk() // .expectHeader().contentType(responseType) // - .returnResult(RepresentationModel.class).getResponseBody().as(StepVerifier::create) + .returnResult(RepresentationModel.class) // + .getResponseBody().as(StepVerifier::create) // .expectNextMatches(resourceSupport -> { assertThat(resourceSupport.getLinks()) // diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java index 4d295bad..4debeb2b 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java @@ -245,22 +245,13 @@ class HypermediaWebMvcConfigurerTest { verifyCreatingNewEntityWorks(MediaTypes.UBER_JSON); } - /** - * When requesting an unregistered media type, fallback to Spring Framework's default JSON handler. - */ @Test void callingForUnregisteredMediaTypeShouldFallBackToDefaultHandler() throws Exception { setUp(HalWebMvcConfig.class); - String unformattedJson = this.mockMvc.perform(get("/").accept(MediaTypes.UBER_JSON)) // - .andExpect(status().isOk()) // - .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.UBER_JSON.toString())) // - .andReturn() // - .getResponse().getContentAsString(); // - - assertThat(unformattedJson) - .isEqualTo("{\"links\":[{\"rel\":\"self\",\"href\":\"/\"},{\"rel\":\"employees\",\"href\":\"/employees\"}]}"); + this.mockMvc.perform(get("/").accept(MediaTypes.UBER_JSON)) + .andExpect(status().isNotAcceptable()); } @Test // #118 diff --git a/src/test/java/org/springframework/hateoas/mediatype/MediaTypeTestUtils.java b/src/test/java/org/springframework/hateoas/mediatype/MediaTypeTestUtils.java index 8b667bf4..7cd519b7 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/MediaTypeTestUtils.java +++ b/src/test/java/org/springframework/hateoas/mediatype/MediaTypeTestUtils.java @@ -17,11 +17,20 @@ package org.springframework.hateoas.mediatype; import java.util.Collections; import java.util.List; +import java.util.function.Function; import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; import org.springframework.hateoas.RepresentationModel; import org.springframework.http.MediaType; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; /** @@ -52,13 +61,61 @@ public class MediaTypeTestUtils { */ public static List getSupportedHypermediaTypes(ApplicationContext context, Class type) { - RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); + return getSupportedHypermediaTypes(context, + it -> it.getBean(RequestMappingHandlerAdapter.class).getMessageConverters(), type); + } - return adapter.getMessageConverters().stream() // + public static List getSupportedHypermediaTypes(ApplicationContext context, + Function>> extractor) { + return getSupportedHypermediaTypes(context, extractor, RepresentationModel.class); + } + + public static List getSupportedHypermediaTypes(ApplicationContext context, + Function>> extractor, Class type) { + return getSupportedHypermediaTypes(extractor.apply(context), type); + } + + public static List getSupportedHypermediaTypes(List> converters) { + return getSupportedHypermediaTypes(converters, RepresentationModel.class); // + } + + public static List getSupportedHypermediaTypes(List> converters, Class type) { + + return converters.stream() // .filter(MappingJackson2HttpMessageConverter.class::isInstance) // .map(MappingJackson2HttpMessageConverter.class::cast) // .findFirst() // .map(it -> it.getSupportedMediaTypes(type)) // .orElseGet(() -> Collections.emptyList()); // } + + public static List getSupportedHypermediaTypes(WebClient client) { + return getSupportedHypermediaTypes(client, RepresentationModel.class); // + } + + @SuppressWarnings("unchecked") + public static List getSupportedHypermediaTypes(WebClient client, Class type) { + + return exchangeStrategies(client).messageReaders().stream() // + .filter(DecoderHttpMessageReader.class::isInstance) // + .map(DecoderHttpMessageReader.class::cast) // + .filter(it -> Jackson2JsonDecoder.class.isInstance(it.getDecoder())) + .findFirst() // + .map(it -> it.getReadableMediaTypes(ResolvableType.forClass(type))) // + .orElseGet(() -> Collections.emptyList()); + } + + /** + * Extract the {@link ExchangeStrategies} from a {@link WebTestClient} to assert it has the proper message readers and + * writers. + * + * @param webClient + * @return + */ + @SuppressWarnings("null") + private static ExchangeStrategies exchangeStrategies(WebClient webClient) { + + return (ExchangeStrategies) ReflectionTestUtils + .getField(ReflectionTestUtils.getField(webClient, "exchangeFunction"), "strategies"); + } }