diff --git a/pom.xml b/pom.xml index dbfa49db..87a7a729 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ hyper-text driven REST web services. - 2012-2018 + 2012-2019 Pivotal, Inc. @@ -48,7 +48,7 @@ Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 - Copyright 2011-2018 the original author or authors. + Copyright 2011-2019 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. @@ -77,6 +77,7 @@ spring.hateoas 2.2.0 2.2.1 + Californium-SR4 1.7.25 5.1.5.RELEASE 2.0.0.BUILD-SNAPSHOT @@ -434,6 +435,18 @@ + + + + io.projectreactor + reactor-bom + ${reactor-bom.version} + import + pom + + + + @@ -490,6 +503,14 @@ org.springframework spring-webmvc ${spring.version} + true + + + + org.springframework + spring-webflux + ${spring.version} + true @@ -595,6 +616,24 @@ test + + io.projectreactor.netty + reactor-netty + test + + + + io.projectreactor + reactor-test + test + + + + io.projectreactor.addons + reactor-extra + test + + diff --git a/src/main/java/org/springframework/hateoas/ResourceSupport.java b/src/main/java/org/springframework/hateoas/ResourceSupport.java index 7f5a1631..089061b8 100755 --- a/src/main/java/org/springframework/hateoas/ResourceSupport.java +++ b/src/main/java/org/springframework/hateoas/ResourceSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2018 the original author or authors. + * Copyright 2012-2019 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. @@ -41,6 +41,22 @@ public class ResourceSupport implements Identifiable { this.links = new ArrayList<>(); } + public ResourceSupport(Link initialLink) { + + Assert.notNull(initialLink, "initialLink must not be null!"); + + this.links = new ArrayList<>(); + this.links.add(initialLink); + } + + public ResourceSupport(List initialLinks) { + + Assert.notNull(initialLinks, "initialLinks must not be null!"); + + this.links = new ArrayList<>(); + this.links.addAll(initialLinks); + } + /** * Returns the {@link Link} with a rel of {@link IanaLinkRelations#SELF}. */ diff --git a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java deleted file mode 100644 index efa1b6ce..00000000 --- a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringWebMvcConfigurer.java +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2018 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.hateoas.config; - -import static org.springframework.hateoas.MediaTypes.*; - -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.BeanFactoryAware; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.support.MessageSourceAccessor; -import org.springframework.hateoas.RelProvider; -import org.springframework.hateoas.ResourceSupport; -import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule; -import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; -import org.springframework.hateoas.core.DelegatingRelProvider; -import org.springframework.hateoas.hal.CurieProvider; -import org.springframework.hateoas.hal.HalConfiguration; -import org.springframework.hateoas.hal.Jackson2HalModule; -import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator; -import org.springframework.hateoas.hal.forms.HalFormsConfiguration; -import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule; -import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule.HalFormsHandlerInstantiator; -import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; -import org.springframework.hateoas.uber.Jackson2UberModule; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; - -/** - * @author Oliver Gierke - * @author Greg Turnquist - */ -@Configuration -@RequiredArgsConstructor -class ConverterRegisteringWebMvcConfigurer implements WebMvcConfigurer, BeanFactoryAware { - - private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource"; - - private final ObjectProvider mapper; - private final ObjectProvider relProvider; - private final ObjectProvider curieProvider; - private final ObjectProvider halConfiguration; - private final ObjectProvider halFormsConfiguration; - - private BeanFactory beanFactory; - private Collection hypermediaTypes; - - /* - * (non-Javadoc) - * @see org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org.springframework.beans.factory.BeanFactory) - */ - @Override - public void setBeanFactory(BeanFactory beanFactory) throws BeansException { - this.beanFactory = beanFactory; - } - - /** - * @param hyperMediaTypes the hyperMediaTypes to set - */ - public void setHypermediaTypes(Collection hyperMediaTypes) { - this.hypermediaTypes = hyperMediaTypes; - } - - /* - * (non-Javadoc) - * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurer#extendMessageConverters(java.util.List) - */ - @Override - public void extendMessageConverters(List> converters) { - - if (converters.stream().filter(MappingJackson2HttpMessageConverter.class::isInstance) - .map(AbstractJackson2HttpMessageConverter.class::cast) - .map(AbstractJackson2HttpMessageConverter::getObjectMapper) - .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { - - return; - } - - ObjectMapper objectMapper = mapper.getIfAvailable(ObjectMapper::new); - - MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, - MessageSourceAccessor.class); - - if (hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { - - CurieProvider curieProvider = this.curieProvider.getIfAvailable(); - RelProvider relProvider = this.relProvider.getObject(); - - if (hypermediaTypes.contains(HypermediaType.HAL)) { - converters.add(0, createHalConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); - } - - if (hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { - converters.add(0, createHalFormsConverter(objectMapper, curieProvider, relProvider, linkRelationMessageSource)); - } - } - - if (hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { - converters.add(0, createCollectionJsonConverter(objectMapper, linkRelationMessageSource)); - } - - if (hypermediaTypes.contains(HypermediaType.UBER)) { - converters.add(0, createUberJsonConverter(objectMapper)); - } - } - - /** - * @param objectMapper - * @return - */ - protected MappingJackson2HttpMessageConverter createUberJsonConverter(ObjectMapper objectMapper) { - - ObjectMapper mapper = objectMapper.copy(); - - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.registerModule(new Jackson2UberModule()); - // mapper.setHandlerInstantiator(new UberHandlerInstantiator()); - - return new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, - Collections.singletonList(UBER_JSON), mapper); - } - - /** - * @param objectMapper - * @param linkRelationMessageSource - * @return - */ - protected MappingJackson2HttpMessageConverter createCollectionJsonConverter(ObjectMapper objectMapper, - MessageSourceAccessor linkRelationMessageSource) { - - ObjectMapper mapper = objectMapper.copy(); - - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.registerModule(new Jackson2CollectionJsonModule()); - - return new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, - Collections.singletonList(COLLECTION_JSON), mapper); - } - - /** - * @param objectMapper - * @param curieProvider - * @param relProvider - * @param linkRelationMessageSource - * @return - */ - private MappingJackson2HttpMessageConverter createHalFormsConverter(ObjectMapper objectMapper, - CurieProvider curieProvider, RelProvider relProvider, MessageSourceAccessor linkRelationMessageSource) { - - ObjectMapper mapper = objectMapper.copy(); - - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.registerModule(new Jackson2HalFormsModule()); - mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource, - true, this.halFormsConfiguration.getIfAvailable(HalFormsConfiguration::new))); - - return new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, - Collections.singletonList(HAL_FORMS_JSON), mapper); - } - - /** - * @param objectMapper - * @param curieProvider - * @param relProvider - * @param linkRelationMessageSource - * @return - */ - private MappingJackson2HttpMessageConverter createHalConverter(ObjectMapper objectMapper, CurieProvider curieProvider, - RelProvider relProvider, MessageSourceAccessor linkRelationMessageSource) { - - ObjectMapper mapper = objectMapper.copy(); - - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.registerModule(new Jackson2HalModule()); - mapper.setHandlerInstantiator(new HalHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource, - this.halConfiguration.getIfAvailable(HalConfiguration::new))); - - return new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, - Arrays.asList(HAL_JSON, HAL_JSON_UTF8), mapper); - } -} diff --git a/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java index 9b312289..080195e6 100644 --- a/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/HateoasConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2018 the original author or authors. + * Copyright 2015-2019 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. @@ -16,7 +16,6 @@ package org.springframework.hateoas.config; import org.springframework.beans.factory.BeanCreationException; -import org.springframework.beans.factory.ObjectFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -39,6 +38,7 @@ import org.springframework.util.ClassUtils; * Common HATEOAS specific configuration. * * @author Oliver Gierke + * @author Greg Turnquist * @soundtrack Elephants Crossing - Wait (Live at Stadtfest Dresden) * @since 0.19 */ @@ -66,12 +66,6 @@ class HateoasConfiguration { } } - @Bean - ConverterRegisteringBeanPostProcessor jackson2ModuleRegisteringBeanPostProcessor( - ObjectFactory configurer) { - return new ConverterRegisteringBeanPostProcessor(configurer); - } - // RelProvider @Bean diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java b/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java new file mode 100644 index 00000000..f72c07b3 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java @@ -0,0 +1,131 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config; + +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.hateoas.RelProvider; +import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule; +import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule; +import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule.HalFormsHandlerInstantiator; +import org.springframework.hateoas.uber.Jackson2UberModule; +import org.springframework.hateoas.uber.Jackson2UberModule.UberHandlerInstantiator; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Utility class for create {@link ObjectMapper} instances of all hypermedia types. + * + * @author Greg Turnquist + * @since 1.0 + */ +public final class HypermediaObjectMapperCreator { + + /** + * Create a {@link org.springframework.hateoas.MediaTypes#HAL_JSON}-based {@link ObjectMapper}. + * + * @param objectMapper + * @param curieProvider + * @param relProvider + * @param linkRelationMessageSource + * @param halConfiguration + * @return + */ + public static ObjectMapper createHalObjectMapper(ObjectMapper objectMapper, + CurieProvider curieProvider, + RelProvider relProvider, + MessageSourceAccessor linkRelationMessageSource, + HalConfiguration halConfiguration) { + + ObjectMapper mapper = objectMapper.copy(); + + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2HalModule()); + mapper.setHandlerInstantiator(new HalHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, halConfiguration)); + + return mapper; + } + + /** + * Create a {@link org.springframework.hateoas.MediaTypes#HAL_FORMS_JSON}-based {@link ObjectMapper}. + * + * @param objectMapper + * @param curieProvider + * @param relProvider + * @param linkRelationMessageSource + * @param halFormsConfiguration + * @return properly configured objectMapper + */ + public static ObjectMapper createHalFormsObjectMapper(ObjectMapper objectMapper, + CurieProvider curieProvider, + RelProvider relProvider, + MessageSourceAccessor linkRelationMessageSource, + HalFormsConfiguration halFormsConfiguration) { + + ObjectMapper mapper = objectMapper.copy(); + + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2HalFormsModule()); + mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator( + relProvider, curieProvider, linkRelationMessageSource, true, + halFormsConfiguration)); + + return mapper; + } + + /** + * Create a {@link org.springframework.hateoas.MediaTypes#COLLECTION_JSON}-based {@link ObjectMapper}. + * + * @param objectMapper + * @param linkRelationMessageSource + * @return properly configured objectMapper + */ + public static ObjectMapper createCollectionJsonObjectMapper(ObjectMapper objectMapper, + MessageSourceAccessor linkRelationMessageSource) { + + ObjectMapper mapper = objectMapper.copy(); + + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2CollectionJsonModule()); + mapper.setHandlerInstantiator(new CollectionJsonHandlerInstantiator(linkRelationMessageSource)); + + return mapper; + } + + /** + * Create a {@link org.springframework.hateoas.MediaTypes#UBER_JSON}-based {@link ObjectMapper}. + * + * @param objectMapper + * @return properly configured objectMapper + */ + public static ObjectMapper createUberObjectMapper(ObjectMapper objectMapper) { + + ObjectMapper mapper = objectMapper.copy(); + + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.registerModule(new Jackson2UberModule()); + mapper.setHandlerInstantiator(new UberHandlerInstantiator()); + + return mapper; + } +} diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java index 006e12cd..e75e85fd 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2018 the original author or authors. + * Copyright 2013-2019 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. @@ -35,8 +35,11 @@ import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.LinkDiscoverer; import org.springframework.hateoas.collectionjson.CollectionJsonLinkDiscoverer; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.config.mvc.WebMvcHateoasConfiguration; +import org.springframework.hateoas.config.reactive.WebFluxHateoasConfiguration; import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.hateoas.hal.forms.HalFormsLinkDiscoverer; +import org.springframework.hateoas.support.WebStack; import org.springframework.hateoas.uber.UberLinkDiscoverer; import org.springframework.util.ClassUtils; @@ -62,6 +65,19 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe Map attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName()); Collection types = Arrays.asList((HypermediaType[]) attributes.get("type")); + /* + * Register all the {@link HypermediaType}s as individual beans, so they can be gathered as needed into a + * collection using the application context. + */ + for (HypermediaType type : types) { + + BeanDefinitionBuilder hypermediaTypeBeanDefinition = genericBeanDefinition(HypermediaType.class, () -> type); + registerSourcedBeanDefinition(hypermediaTypeBeanDefinition, metadata, registry); + } + + /* + * Only register JSONPath-based {@link LinkDiscoverer}s based on the registered {@link HypermediaType}s. + */ if (JSONPATH_PRESENT) { for (HypermediaType type : types) { @@ -72,9 +88,24 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe } } - BeanDefinitionBuilder configurerBeanDefinition = rootBeanDefinition(ConverterRegisteringWebMvcConfigurer.class); - configurerBeanDefinition.addPropertyValue("hypermediaTypes", types); - registerSourcedBeanDefinition(configurerBeanDefinition, metadata, registry); + /* + * Register a Spring MVC-specific HATEOAS configuration. + */ + if (WebStack.WEBMVC.isAvailable()) { + + BeanDefinitionBuilder webMvcHateosConfiguration = rootBeanDefinition(WebMvcHateoasConfiguration.class); + registerSourcedBeanDefinition(webMvcHateosConfiguration, metadata, registry); + } + + /* + * Register a Spring WebFlux-specific HATEOAS configuration. + */ + if (WebStack.WEBFLUX.isAvailable()) { + + BeanDefinitionBuilder webFluxHateoasConfiguration = rootBeanDefinition(WebFluxHateoasConfiguration.class); + registerSourcedBeanDefinition(webFluxHateoasConfiguration, metadata, registry); + } + } /** diff --git a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringBeanPostProcessor.java b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessor.java similarity index 60% rename from src/main/java/org/springframework/hateoas/config/ConverterRegisteringBeanPostProcessor.java rename to src/main/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessor.java index f8e4aa28..e552d34a 100644 --- a/src/main/java/org/springframework/hateoas/config/ConverterRegisteringBeanPostProcessor.java +++ b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 the original author or authors. + * Copyright 2018-2019 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. @@ -13,29 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.hateoas.config; +package org.springframework.hateoas.config.mvc; import lombok.RequiredArgsConstructor; import org.springframework.beans.BeansException; -import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.config.BeanPostProcessor; -import org.springframework.context.ApplicationContext; -import org.springframework.hateoas.hal.Jackson2HalModule; import org.springframework.web.client.RestTemplate; -import com.fasterxml.jackson.databind.ObjectMapper; - /** - * {@link BeanPostProcessor} to register {@link Jackson2HalModule} with {@link ObjectMapper} instances registered in the - * {@link ApplicationContext}. + * {@link BeanPostProcessor} to register hypermedia support with {@link RestTemplate} instances found in the + * application context. * * @author Oliver Gierke + * @author Greg Turnquist */ @RequiredArgsConstructor -class ConverterRegisteringBeanPostProcessor implements BeanPostProcessor { +public class HypermediaRestTemplateBeanPostProcessor implements BeanPostProcessor { - private final ObjectFactory configurer; + private final HypermediaWebMvcConfigurer configurer; /* * (non-Javadoc) @@ -45,9 +41,7 @@ class ConverterRegisteringBeanPostProcessor implements BeanPostProcessor { public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RestTemplate) { - - ConverterRegisteringWebMvcConfigurer object = configurer.getObject(); - object.extendMessageConverters(((RestTemplate) bean).getMessageConverters()); + this.configurer.extendMessageConverters(((RestTemplate) bean).getMessageConverters()); } return bean; diff --git a/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java new file mode 100644 index 00000000..384f53c7 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.mvc; + +import static org.springframework.hateoas.MediaTypes.*; +import static org.springframework.hateoas.config.HypermediaObjectMapperCreator.*; + +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.core.DelegatingRelProvider; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Oliver Gierke + * @author Greg Turnquist + */ +@Configuration +@RequiredArgsConstructor +public class HypermediaWebMvcConfigurer implements WebMvcConfigurer, BeanFactoryAware { + + private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource"; + + private final ObjectMapper mapper; + private final DelegatingRelProvider relProvider; + private final CurieProvider curieProvider; + private final HalConfiguration halConfiguration; + private final HalFormsConfiguration halFormsConfiguration; + private final Collection hypermediaTypes; + + private BeanFactory beanFactory; + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanFactoryAware#setBeanFactory(org.springframework.beans.factory.BeanFactory) + */ + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurer#extendMessageConverters(java.util.List) + */ + @Override + public void extendMessageConverters(List> converters) { + + if (converters.stream() + .filter(MappingJackson2HttpMessageConverter.class::isInstance) + .map(AbstractJackson2HttpMessageConverter.class::cast) + .map(AbstractJackson2HttpMessageConverter::getObjectMapper) + .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { + + return; + } + + MessageSourceAccessor linkRelationMessageSource = this.beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, + MessageSourceAccessor.class); + + if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { + + if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(HAL_JSON, HAL_JSON_UTF8), + createHalObjectMapper(this.mapper, this.curieProvider, this.relProvider, linkRelationMessageSource, + this.halConfiguration))); + } + + if (this.hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(HAL_FORMS_JSON), + createHalFormsObjectMapper(this.mapper, this.curieProvider, this.relProvider, linkRelationMessageSource, + this.halFormsConfiguration))); + } + } + + if (this.hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { + + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(COLLECTION_JSON), + createCollectionJsonObjectMapper(this.mapper, linkRelationMessageSource))); + } + + if (this.hypermediaTypes.contains(HypermediaType.UBER)) { + + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class, Arrays.asList(UBER_JSON), createUberObjectMapper(this.mapper))); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/config/mvc/WebMvcHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/mvc/WebMvcHateoasConfiguration.java new file mode 100644 index 00000000..c3afa0db --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/mvc/WebMvcHateoasConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.mvc; + +import java.util.Collection; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.core.DelegatingRelProvider; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Spring MVC HATEOAS Configuration + * + * @author Greg Turnquist + */ +@Configuration +public class WebMvcHateoasConfiguration { + + @Bean + HypermediaWebMvcConfigurer hypermediaWebMvcConfigurer(ObjectProvider mapper, + DelegatingRelProvider relProvider, + ObjectProvider curieProvider, + ObjectProvider halConfiguration, + ObjectProvider halFormsConfiguration, + Collection hypermediaTypes) { + + return new HypermediaWebMvcConfigurer( + mapper.getIfAvailable(ObjectMapper::new), + relProvider, + curieProvider.getIfAvailable(), + halConfiguration.getIfAvailable(HalConfiguration::new), + halFormsConfiguration.getIfAvailable(HalFormsConfiguration::new), + hypermediaTypes); + } + + @Bean + HypermediaRestTemplateBeanPostProcessor restTemplateBeanPostProcessor(HypermediaWebMvcConfigurer configurer) { + return new HypermediaRestTemplateBeanPostProcessor(configurer); + } +} diff --git a/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessor.java b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessor.java new file mode 100644 index 00000000..1a453cba --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessor.java @@ -0,0 +1,45 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * {@link BeanPostProcessor} to register the proper handlers in {@link WebClient} instances found in the + * application context. + * + * @author Greg Turnquist + * @since 1.0 + */ +@RequiredArgsConstructor +public class HypermediaWebClientBeanPostProcessor implements BeanPostProcessor { + + private final WebClientConfigurer configurer; + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + + if (bean instanceof WebClient) { + return this.configurer.registerHypermediaTypes((WebClient) bean); + } + + return bean; + } +} diff --git a/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java new file mode 100644 index 00000000..0faab5df --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java @@ -0,0 +1,147 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import static org.springframework.hateoas.config.HypermediaObjectMapperCreator.*; + +import lombok.RequiredArgsConstructor; + +import java.util.Collection; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.codec.StringDecoder; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.core.DelegatingRelProvider; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.web.reactive.config.WebFluxConfigurer; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * {@link WebFluxConfigurer} to register hypermedia-aware {@link org.springframework.core.codec.Encoder}s and + * {@link org.springframework.core.codec.Decoder}s that will render hypermedia for WebFlux controllers. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Configuration +@RequiredArgsConstructor +public class HypermediaWebFluxConfigurer implements WebFluxConfigurer, BeanFactoryAware { + + private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource"; + + private final ObjectMapper mapper; + private final DelegatingRelProvider relProvider; + private final CurieProvider curieProvider; + private final HalConfiguration halConfiguration; + private final HalFormsConfiguration halFormsConfiguration; + private final Collection hypermediaTypes; + + private BeanFactory beanFactory; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + /** + * Configure custom HTTP message readers and writers or override built-in ones. + *

The configured readers and writers will be used for both annotated + * controllers and functional endpoints. + * + * @param configurer the configurer to use + */ + @Override + public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { + + if (configurer.getReaders().stream() + .flatMap(httpMessageReader -> httpMessageReader.getReadableMediaTypes().stream()) + .anyMatch(mediaType -> mediaType == MediaTypes.HAL_JSON + || mediaType == MediaTypes.HAL_JSON_UTF8 + || mediaType == MediaTypes.HAL_FORMS_JSON + || mediaType == MediaTypes.COLLECTION_JSON + || mediaType == MediaTypes.UBER_JSON)) { + + // Already configured for hypermedia! + return; + } + + MessageSourceAccessor linkRelationMessageSource = this.beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, + MessageSourceAccessor.class); + + CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs(); + + if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { + + if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + + ObjectMapper halObjectMapper = createHalObjectMapper(this.mapper, this.curieProvider, this.relProvider, + linkRelationMessageSource, this.halConfiguration); + + customCodecs.encoder( + new Jackson2JsonEncoder(halObjectMapper, MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8)); + customCodecs.decoder( + new Jackson2JsonDecoder(halObjectMapper, MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8)); + } + + if (this.hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + + ObjectMapper halFormsObjectMapper = createHalFormsObjectMapper(this.mapper, this.curieProvider, + this.relProvider, linkRelationMessageSource, this.halFormsConfiguration); + + customCodecs.encoder( + new Jackson2JsonEncoder(halFormsObjectMapper, MediaTypes.HAL_FORMS_JSON)); + customCodecs.decoder( + new Jackson2JsonDecoder(halFormsObjectMapper, MediaTypes.HAL_FORMS_JSON)); + } + } + + if (this.hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { + + ObjectMapper collectionJsonObjectMapper = createCollectionJsonObjectMapper(this.mapper, linkRelationMessageSource); + + customCodecs.encoder( + new Jackson2JsonEncoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); + customCodecs.decoder( + new Jackson2JsonDecoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); + } + + if (this.hypermediaTypes.contains(HypermediaType.UBER)) { + + ObjectMapper uberObjectMapper = createUberObjectMapper(this.mapper); + + customCodecs.encoder( + new Jackson2JsonEncoder(uberObjectMapper, MediaTypes.UBER_JSON)); + customCodecs.decoder( + new Jackson2JsonDecoder(uberObjectMapper, MediaTypes.UBER_JSON)); + } + + customCodecs.decoder(StringDecoder.allMimeTypes()); + + configurer.registerDefaults(false); + } +} diff --git a/src/main/java/org/springframework/hateoas/config/reactive/WebClientConfigurer.java b/src/main/java/org/springframework/hateoas/config/reactive/WebClientConfigurer.java new file mode 100644 index 00000000..65ea44b2 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/reactive/WebClientConfigurer.java @@ -0,0 +1,149 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import static org.springframework.hateoas.config.HypermediaObjectMapperCreator.*; + +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.core.DelegatingRelProvider; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Assembles {@link ExchangeStrategies} needed to wire a {@link WebClient} with hypermedia support. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Configuration +@RequiredArgsConstructor +public class WebClientConfigurer implements BeanFactoryAware { + + private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource"; + + private final ObjectMapper mapper; + private final DelegatingRelProvider relProvider; + private final CurieProvider curieProvider; + private final HalConfiguration halConfiguration; + private final HalFormsConfiguration halFormsConfiguration; + private final Collection hypermediaTypes; + + private BeanFactory beanFactory; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + /** + * Return a set of {@link ExchangeStrategies} driven by registered {@link HypermediaType}s. + * @return a collection of {@link Encoder}s and {@link Decoder} assembled into a {@link ExchangeStrategies}. + */ + public ExchangeStrategies hypermediaExchangeStrategies() { + + MessageSourceAccessor linkRelationMessageSource = this.beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, + MessageSourceAccessor.class); + + List> encoders = new ArrayList<>(); + List> decoders = new ArrayList<>(); + + if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { + + if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + + ObjectMapper halObjectMapper = createHalObjectMapper(this.mapper, this.curieProvider, this.relProvider, + linkRelationMessageSource, this.halConfiguration); + + encoders.add(new Jackson2JsonEncoder(halObjectMapper, MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8)); + decoders.add(new Jackson2JsonDecoder(halObjectMapper, MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8)); + } + + if (this.hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + + ObjectMapper halFormsObjectMapper = createHalFormsObjectMapper(this.mapper, this.curieProvider, + this.relProvider, linkRelationMessageSource, this.halFormsConfiguration); + + encoders.add(new Jackson2JsonEncoder(halFormsObjectMapper, MediaTypes.HAL_FORMS_JSON)); + decoders.add(new Jackson2JsonDecoder(halFormsObjectMapper, MediaTypes.HAL_FORMS_JSON)); + } + } + + if (this.hypermediaTypes.contains(HypermediaType.COLLECTION_JSON)) { + + ObjectMapper collectionJsonObjectMapper = createCollectionJsonObjectMapper(this.mapper, linkRelationMessageSource); + + encoders.add(new Jackson2JsonEncoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); + decoders.add(new Jackson2JsonDecoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); + } + + if (this.hypermediaTypes.contains(HypermediaType.UBER)) { + + ObjectMapper uberObjectMapper = createUberObjectMapper(this.mapper); + + encoders.add(new Jackson2JsonEncoder(uberObjectMapper, MediaTypes.UBER_JSON)); + decoders.add(new Jackson2JsonDecoder(uberObjectMapper, MediaTypes.UBER_JSON)); + } + + // Include ability to decode into a plain string + decoders.add(StringDecoder.allMimeTypes()); + + return ExchangeStrategies.builder() + .codecs(clientCodecConfigurer -> { + + encoders.forEach(encoder -> clientCodecConfigurer.customCodecs().encoder(encoder)); + decoders.forEach(decoder -> clientCodecConfigurer.customCodecs().decoder(decoder)); + + clientCodecConfigurer.registerDefaults(false); + }) + .build(); + } + + /** + * Register the proper {@link ExchangeStrategies} for a given {@link WebClient}. + * + * @param webClient + * @return mutated webClient with hypermedia support. + */ + public WebClient registerHypermediaTypes(WebClient webClient) { + + return webClient.mutate() + .exchangeStrategies(hypermediaExchangeStrategies()) + .build(); + } +} + diff --git a/src/main/java/org/springframework/hateoas/config/reactive/WebFluxHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/reactive/WebFluxHateoasConfiguration.java new file mode 100644 index 00000000..8c1e6d0f --- /dev/null +++ b/src/main/java/org/springframework/hateoas/config/reactive/WebFluxHateoasConfiguration.java @@ -0,0 +1,86 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import java.util.Collection; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.core.DelegatingRelProvider; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.HalConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.hateoas.reactive.HypermediaWebFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Spring WebFlux HATEOAS configuration. + * + * @author Greg Turnquist + * @since 1.0 + */ +@Configuration +public class WebFluxHateoasConfiguration { + + @Bean + WebClientConfigurer webClientConfigurer(ObjectProvider mapper, + DelegatingRelProvider relProvider, + ObjectProvider curieProvider, + ObjectProvider halConfiguration, + ObjectProvider halFormsConfiguration, + Collection hypermediaTypes) { + + return new WebClientConfigurer( + mapper.getIfAvailable(ObjectMapper::new), + relProvider, + curieProvider.getIfAvailable(), + halConfiguration.getIfAvailable(HalConfiguration::new), + halFormsConfiguration.getIfAvailable(HalFormsConfiguration::new), + hypermediaTypes); + } + + @Bean + HypermediaWebClientBeanPostProcessor webClientBeanPostProcessor(WebClientConfigurer configurer) { + return new HypermediaWebClientBeanPostProcessor(configurer); + } + + @Bean + HypermediaWebFluxConfigurer hypermediaWebFluxConfigurer(ObjectProvider mapper, + DelegatingRelProvider relProvider, + ObjectProvider curieProvider, + ObjectProvider halConfiguration, + ObjectProvider halFormsConfiguration, + Collection hypermediaTypes) { + + return new HypermediaWebFluxConfigurer( + mapper.getIfAvailable(ObjectMapper::new), + relProvider, + curieProvider.getIfAvailable(), + halConfiguration.getIfAvailable(HalConfiguration::new), + halFormsConfiguration.getIfAvailable(HalFormsConfiguration::new), hypermediaTypes); + } + + /** + * TODO: Replace with Spring Framework filter when https://github.com/spring-projects/spring-framework/issues/21746 is completed. + */ + @Bean + HypermediaWebFilter hypermediaWebFilter() { + return new HypermediaWebFilter(); + } +} diff --git a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java b/src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java similarity index 94% rename from src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java rename to src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java index d4f7aeb3..aaacae01 100644 --- a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java +++ b/src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2017 the original author or authors. + * Copyright 2012-2019 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.hateoas.mvc; +package org.springframework.hateoas.core; import lombok.NonNull; import lombok.RequiredArgsConstructor; @@ -28,9 +28,6 @@ import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; import org.springframework.format.support.DefaultFormattingConversionService; -import org.springframework.hateoas.core.AnnotationAttribute; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; -import org.springframework.hateoas.core.MethodParameters; import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; @@ -87,7 +84,7 @@ class AnnotatedParametersParameterAccessor { * @return */ protected BoundMethodParameter createParameter(MethodParameter parameter, Object value, - AnnotationAttribute attribute) { + AnnotationAttribute attribute) { return new BoundMethodParameter(parameter, value, attribute); } diff --git a/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java b/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java index 49d4a145..7057a37d 100644 --- a/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java +++ b/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java @@ -47,13 +47,6 @@ public class DummyInvocationUtils { private static final Map, Class> CLASS_CACHE = new ConcurrentReferenceHashMap<>(16, ReferenceType.WEAK); - public interface LastInvocationAware { - - Iterator getObjectParameters(); - - MethodInvocation getLastInvocation(); - } - /** * Method interceptor that records the last method invocation and creates a proxy for the return value that exposes * the method invocation. @@ -179,15 +172,6 @@ public class DummyInvocationUtils { return (T) factory; } - public interface MethodInvocation { - - Object[] getArguments(); - - Method getMethod(); - - Class getTargetType(); - } - /** * Returns the already created proxy class for the given source type or creates a new one. * @@ -213,7 +197,7 @@ public class DummyInvocationUtils { } @Value - static class SimpleMethodInvocation implements MethodInvocation { + private static class SimpleMethodInvocation implements MethodInvocation { @NonNull Class targetType; @NonNull Method method; diff --git a/src/main/java/org/springframework/hateoas/core/LastInvocationAware.java b/src/main/java/org/springframework/hateoas/core/LastInvocationAware.java new file mode 100644 index 00000000..62925008 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/core/LastInvocationAware.java @@ -0,0 +1,29 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.core; + +import java.util.Iterator; + +/** + * @author Oliver Drotbohm + * @author Greg Turnquist + */ +public interface LastInvocationAware { + + Iterator getObjectParameters(); + + MethodInvocation getLastInvocation(); +} diff --git a/src/main/java/org/springframework/hateoas/core/MethodInvocation.java b/src/main/java/org/springframework/hateoas/core/MethodInvocation.java new file mode 100644 index 00000000..f7a713a5 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/core/MethodInvocation.java @@ -0,0 +1,31 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.core; + +import java.lang.reflect.Method; + +/** + * @author Oliver Drotbohm + * @author Greg Turnquist + */ +public interface MethodInvocation { + + Object[] getArguments(); + + Method getMethod(); + + Class getTargetType(); +} diff --git a/src/main/java/org/springframework/hateoas/core/WebHandler.java b/src/main/java/org/springframework/hateoas/core/WebHandler.java new file mode 100644 index 00000000..9dfaad92 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/core/WebHandler.java @@ -0,0 +1,246 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.core; + +import static org.springframework.hateoas.TemplateVariable.VariableType.*; +import static org.springframework.hateoas.TemplateVariables.*; +import static org.springframework.hateoas.core.EncodingUtils.*; +import static org.springframework.web.util.UriComponents.UriTemplateVariables.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.TemplateVariable; +import org.springframework.hateoas.TemplateVariables; +import org.springframework.hateoas.core.AnnotatedParametersParameterAccessor.BoundMethodParameter; +import org.springframework.hateoas.mvc.ControllerLinkBuilder; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriTemplate; + +/** + * Utility for taking a a method invocation and extracting a {@link ControllerLinkBuilder}. + * + * @author Greg Turnquist + */ +public class WebHandler { + + private static final MappingDiscoverer DISCOVERER = new AnnotationMappingDiscoverer(RequestMapping.class); + private static final AnnotatedParametersParameterAccessor PATH_VARIABLE_ACCESSOR = new AnnotatedParametersParameterAccessor( + new AnnotationAttribute(PathVariable.class)); + private static final AnnotatedParametersParameterAccessor REQUEST_PARAM_ACCESSOR = new RequestParamParameterAccessor(); + + public static ControllerLinkBuilder linkTo(Object invocationValue, + Function mappingToUriComponentsBuilder, + BiFunction additionalUriHandler) { + + Assert.isInstanceOf(LastInvocationAware.class, invocationValue); + + LastInvocationAware invocations = (LastInvocationAware) invocationValue; + MethodInvocation invocation = invocations.getLastInvocation(); + + String mapping = DISCOVERER.getMapping(invocation.getTargetType(), invocation.getMethod()); + + UriComponentsBuilder builder = mappingToUriComponentsBuilder.apply(mapping); + + UriTemplate template; + if (mapping == null) { + template = new UriTemplate("/"); + } else { + template = new UriTemplate(mapping); + } + + Map values = new HashMap<>(); + + Iterator names = template.getVariableNames().iterator(); + Iterator classMappingParameters = invocations.getObjectParameters(); + + while (classMappingParameters.hasNext()) { + values.put(names.next(), encodePath(classMappingParameters.next())); + } + + for (BoundMethodParameter parameter : PATH_VARIABLE_ACCESSOR.getBoundParameters(invocation)) { + values.put(parameter.getVariableName(), encodePath(parameter.asString())); + } + + List optionalEmptyParameters = new ArrayList<>(); + + for (BoundMethodParameter parameter : REQUEST_PARAM_ACCESSOR.getBoundParameters(invocation)) { + + bindRequestParameters(builder, parameter); + + if (SKIP_VALUE.equals(parameter.getValue())) { + + values.put(parameter.getVariableName(), SKIP_VALUE); + + if (!parameter.isRequired()) { + optionalEmptyParameters.add(parameter.getVariableName()); + } + } + } + + for (String variable : template.getVariableNames()) { + if (!values.containsKey(variable)) { + values.put(variable, SKIP_VALUE); + } + } + + UriComponents components; + if (additionalUriHandler == null) { + components = builder.buildAndExpand(values); + } else { + components = additionalUriHandler.apply(builder, invocation).buildAndExpand(values); + } + + TemplateVariables variables = NONE; + + for (String parameter : optionalEmptyParameters) { + + boolean previousRequestParameter = components.getQueryParams().isEmpty() && variables.equals(NONE); + TemplateVariable variable = new TemplateVariable(parameter, + previousRequestParameter ? REQUEST_PARAM : REQUEST_PARAM_CONTINUED); + variables = variables.concat(variable); + } + + return new ControllerLinkBuilder(components, variables, invocation); + } + + /** + * Populates the given {@link UriComponentsBuilder} with request parameters found in the given + * {@link BoundMethodParameter}. + * + * @param builder must not be {@literal null}. + * @param parameter must not be {@literal null}. + */ + @SuppressWarnings("unchecked") + private static void bindRequestParameters(UriComponentsBuilder builder, BoundMethodParameter parameter) { + + Object value = parameter.getValue(); + String key = parameter.getVariableName(); + + if (value instanceof MultiValueMap) { + + MultiValueMap requestParams = (MultiValueMap) value; + + for (Map.Entry> multiValueEntry : requestParams.entrySet()) { + for (String singleEntryValue : multiValueEntry.getValue()) { + builder.queryParam(multiValueEntry.getKey(), encodeParameter(singleEntryValue)); + } + } + + } else if (value instanceof Map) { + + Map requestParams = (Map) value; + + for (Map.Entry requestParamEntry : requestParams.entrySet()) { + builder.queryParam(requestParamEntry.getKey(), encodeParameter(requestParamEntry.getValue())); + } + + } else if (value instanceof Collection) { + + for (Object element : (Collection) value) { + builder.queryParam(key, encodeParameter(element)); + } + + } else if (SKIP_VALUE.equals(value)) { + + if (parameter.isRequired()) { + builder.queryParam(key, String.format("{%s}", parameter.getVariableName())); + } + + } else { + builder.queryParam(key, encodeParameter(parameter.asString())); + } + } + + /** + * Custom extension of {@link AnnotatedParametersParameterAccessor} for {@link RequestParam} to allow {@literal null} + * values handed in for optional request parameters. + * + * @author Oliver Gierke + */ + private static class RequestParamParameterAccessor extends AnnotatedParametersParameterAccessor { + + public RequestParamParameterAccessor() { + super(new AnnotationAttribute(RequestParam.class)); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor#createParameter(org.springframework.core.MethodParameter, java.lang.Object, org.springframework.hateoas.core.AnnotationAttribute) + */ + @Override + protected BoundMethodParameter createParameter(final MethodParameter parameter, Object value, + AnnotationAttribute attribute) { + + return new BoundMethodParameter(parameter, value, attribute) { + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor.BoundMethodParameter#isRequired() + */ + @Override + public boolean isRequired() { + + RequestParam annotation = parameter.getParameterAnnotation(RequestParam.class); + + if (parameter.isOptional()) { + return false; + } + + return annotation.required() // + && annotation.defaultValue().equals(ValueConstants.DEFAULT_NONE); + } + }; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor#verifyParameterValue(org.springframework.core.MethodParameter, java.lang.Object) + */ + @Override + protected Object verifyParameterValue(MethodParameter parameter, Object value) { + + RequestParam annotation = parameter.getParameterAnnotation(RequestParam.class); + + value = ObjectUtils.unwrapOptional(value); + + if (value != null) { + return value; + } + + if (!annotation.required() || parameter.isOptional()) { + return SKIP_VALUE; + } + + return annotation.defaultValue().equals(ValueConstants.DEFAULT_NONE) ? SKIP_VALUE : null; + } + } +} diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java index f4e49c1f..21224490 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java @@ -26,9 +26,9 @@ import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.core.AnnotationMappingDiscoverer; import org.springframework.hateoas.core.CachingMappingDiscoverer; import org.springframework.hateoas.core.DummyInvocationUtils; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.core.MethodInvocation; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.request.RequestContextHolder; @@ -78,7 +78,7 @@ public class ControllerLinkBuilder extends LinkBuilderSupport ControllerLinkBuilder.getBuilder().path(mapping), + (builder, invocation) -> { + + MethodParameters parameters = new MethodParameters(invocation.getMethod()); + Iterator parameterValues = Arrays.asList(invocation.getArguments()).iterator(); - MethodInvocation invocation = invocations.getLastInvocation(); - Iterator classMappingParameters = invocations.getObjectParameters(); - Method method = invocation.getMethod(); - - String mapping = DISCOVERER.getMapping(invocation.getTargetType(), method); - - UriComponentsBuilder builder = ControllerLinkBuilder.getBuilder().path(mapping); - - UriTemplate template = UriTemplateFactory.templateFor(mapping); - Map values = new HashMap<>(); - Iterator names = template.getVariableNames().iterator(); - - while (classMappingParameters.hasNext()) { - values.put(names.next(), encodePath(classMappingParameters.next())); - } - - for (BoundMethodParameter parameter : PATH_VARIABLE_ACCESSOR.getBoundParameters(invocation)) { - values.put(parameter.getVariableName(), encodePath(parameter.asString())); - } - - List optionalEmptyParameters = new ArrayList<>(); - - for (BoundMethodParameter parameter : REQUEST_PARAM_ACCESSOR.getBoundParameters(invocation)) { - - bindRequestParameters(builder, parameter); - - if (SKIP_VALUE.equals(parameter.getValue())) { - - values.put(parameter.getVariableName(), SKIP_VALUE); - - if (!parameter.isRequired()) { - optionalEmptyParameters.add(parameter.getVariableName()); + for (MethodParameter parameter : parameters.getParameters()) { + Object parameterValue = parameterValues.next(); + + for (UriComponentsContributor contributor : this.uriComponentsContributors) { + + if (contributor.supportsParameter(parameter)) { + contributor.enhance(builder, parameter, parameterValue); + } + } } - } - } - for (String variable : template.getVariableNames()) { - if (!values.containsKey(variable)) { - values.put(variable, SKIP_VALUE); - } - } - - UriComponents components = applyUriComponentsContributer(builder, invocation).buildAndExpand(values); - TemplateVariables variables = NONE; - - for (String parameter : optionalEmptyParameters) { - - boolean previousRequestParameter = components.getQueryParams().isEmpty() && variables.equals(NONE); - TemplateVariable variable = new TemplateVariable(parameter, - previousRequestParameter ? REQUEST_PARAM : REQUEST_PARAM_CONTINUED); - variables = variables.concat(variable); - } - - return new ControllerLinkBuilder(components, variables, invocation); + return builder; + }); } /* @@ -199,141 +137,4 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory parameterValues = Arrays.asList(invocation.getArguments()).iterator(); - - for (MethodParameter parameter : parameters.getParameters()) { - Object parameterValue = parameterValues.next(); - for (UriComponentsContributor contributor : uriComponentsContributors) { - if (contributor.supportsParameter(parameter)) { - contributor.enhance(builder, parameter, parameterValue); - } - } - } - - return builder; - } - - /** - * Populates the given {@link UriComponentsBuilder} with request parameters found in the given - * {@link BoundMethodParameter}. - * - * @param builder must not be {@literal null}. - * @param parameter must not be {@literal null}. - */ - @SuppressWarnings("unchecked") - private static void bindRequestParameters(UriComponentsBuilder builder, BoundMethodParameter parameter) { - - Object value = parameter.getValue(); - String key = parameter.getVariableName(); - - if (value instanceof MultiValueMap) { - - MultiValueMap requestParams = (MultiValueMap) value; - - for (Map.Entry> multiValueEntry : requestParams.entrySet()) { - for (String singleEntryValue : multiValueEntry.getValue()) { - builder.queryParam(multiValueEntry.getKey(), encodeParameter(singleEntryValue)); - } - } - - } else if (value instanceof Map) { - - Map requestParams = (Map) value; - - for (Map.Entry requestParamEntry : requestParams.entrySet()) { - builder.queryParam(requestParamEntry.getKey(), encodeParameter(requestParamEntry.getValue())); - } - - } else if (value instanceof Collection) { - - for (Object element : (Collection) value) { - builder.queryParam(key, encodeParameter(element)); - } - - } else if (SKIP_VALUE.equals(value)) { - - if (parameter.isRequired()) { - builder.queryParam(key, String.format("{%s}", parameter.getVariableName())); - } - - } else { - builder.queryParam(key, encodeParameter(parameter.asString())); - } - } - - /** - * Custom extension of {@link AnnotatedParametersParameterAccessor} for {@link RequestParam} to allow {@literal null} - * values handed in for optional request parameters. - * - * @author Oliver Gierke - */ - private static class RequestParamParameterAccessor extends AnnotatedParametersParameterAccessor { - - public RequestParamParameterAccessor() { - super(new AnnotationAttribute(RequestParam.class)); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor#createParameter(org.springframework.core.MethodParameter, java.lang.Object, org.springframework.hateoas.core.AnnotationAttribute) - */ - @Override - protected BoundMethodParameter createParameter(final MethodParameter parameter, Object value, - AnnotationAttribute attribute) { - - return new BoundMethodParameter(parameter, value, attribute) { - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor.BoundMethodParameter#isRequired() - */ - @Override - public boolean isRequired() { - - RequestParam annotation = parameter.getParameterAnnotation(RequestParam.class); - - if (parameter.isOptional()) { - return false; - } - - return annotation.required() // - && annotation.defaultValue().equals(ValueConstants.DEFAULT_NONE); - } - }; - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.mvc.AnnotatedParametersParameterAccessor#verifyParameterValue(org.springframework.core.MethodParameter, java.lang.Object) - */ - @Override - protected Object verifyParameterValue(MethodParameter parameter, Object value) { - - RequestParam annotation = parameter.getParameterAnnotation(RequestParam.class); - - value = ObjectUtils.unwrapOptional(value); - - if (value != null) { - return value; - } - - if (!annotation.required() || parameter.isOptional()) { - return SKIP_VALUE; - } - - return annotation.defaultValue().equals(ValueConstants.DEFAULT_NONE) ? SKIP_VALUE : null; - } - } } diff --git a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java index 63ea8647..00947875 100644 --- a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2019 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. @@ -26,8 +26,8 @@ import org.springframework.hateoas.AffordanceModelFactory; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkRelation; import org.springframework.hateoas.QueryParameter; -import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.core.MethodInvocation; import org.springframework.hateoas.core.MethodParameters; import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.RequestBody; @@ -58,7 +58,10 @@ class SpringMvcAffordanceBuilder { for (HttpMethod requestMethod : discoverer.getRequestMethod(invocation.getTargetType(), invocation.getMethod())) { String methodName = invocation.getMethod().getName(); - Link affordanceLink = new Link(components.toUriString()).withRel(LinkRelation.of(methodName)); + + String href = components.toUriString().equals("") ? "/" : components.toUriString(); + Link affordanceLink = new Link(href).withRel(LinkRelation.of(methodName)); + MethodParameters invocationMethodParameters = new MethodParameters(invocation.getMethod()); ResolvableType inputType = invocationMethodParameters.getParametersWith(RequestBody.class).stream() // diff --git a/src/main/java/org/springframework/hateoas/reactive/HypermediaWebFilter.java b/src/main/java/org/springframework/hateoas/reactive/HypermediaWebFilter.java new file mode 100644 index 00000000..9f38c0ba --- /dev/null +++ b/src/main/java/org/springframework/hateoas/reactive/HypermediaWebFilter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +/** + * {@link WebFilter} that ensures a copy of the {@link ServerWebExchange} is added to the Reactor {@link Context}. + * + * @author Greg Turnquist + * @since 1.0 + */ +public class HypermediaWebFilter implements WebFilter { + + public static final String SERVER_WEB_EXCHANGE = "serverWebExchange"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + + return chain.filter(exchange) + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, exchange)); + } +} diff --git a/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java b/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java new file mode 100644 index 00000000..097ec208 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java @@ -0,0 +1,109 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; + +import reactor.core.publisher.Mono; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.mvc.ControllerLinkBuilder; +import org.springframework.hateoas.core.WebHandler; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Utility for building reactive {@link Link}s. + * + * @author Greg Turnquist + * @since 1.0 + */ +public class ReactiveLinkBuilder { + + private final ControllerLinkBuilder controllerLinkBuilder; + + private ReactiveLinkBuilder(ControllerLinkBuilder controllerLinkBuilder) { + this.controllerLinkBuilder = controllerLinkBuilder; + } + + /** + * Create a {@link ReactiveLinkBuilder} by checking if the Reactor Context contains a {@link ServerWebExchange} + * and using that combined with the Spring Web annotations to build a full URI. + * + * If there is no exchange, then fall back to relative URIs. + * + * @param invocationValue + */ + public static Mono linkTo(Object invocationValue) { + + return Mono.subscriberContext() + .map(context -> linkTo(invocationValue, context.getOrDefault(SERVER_WEB_EXCHANGE, null))); + } + + /** + * Create a {@link ReactiveLinkBuilder} using an explicitly defined {@link ServerWebExchange}. This is possible if + * your WebFlux method includes the exchange and you want to pass it straight in. + * + * @param invocationValue + * @param exchange + */ + public static ReactiveLinkBuilder linkTo(Object invocationValue, ServerWebExchange exchange) { + + ControllerLinkBuilder controllerLinkBuilder = WebHandler.linkTo(invocationValue, path -> { + + UriComponentsBuilder builder = getBuilder(exchange); + + if (path == null) { + builder.replacePath("/"); + } else { + builder.replacePath(path); + } + + return builder; + }, null); + + return new ReactiveLinkBuilder(controllerLinkBuilder); + } + + /** + * Utility method to transform {@link ReactiveLinkBuilder} into a {@link Link}. + */ + public Link withSelfRel() { + return this.controllerLinkBuilder.withSelfRel(); + } + + /** + * Utility method to transform {@link ReactiveLinkBuilder} into a {@link Link}. + * + * @param rel + */ + public Link withRel(String rel) { + return this.controllerLinkBuilder.withRel(rel); + } + + /** + * Returns a {@link UriComponentsBuilder} obtained from the {@link ServerWebExchange}. + * + * @param exchange + */ + private static UriComponentsBuilder getBuilder(ServerWebExchange exchange) { + + if (exchange == null) { + return UriComponentsBuilder.fromPath("/"); + } + + return UriComponentsBuilder.fromHttpRequest(exchange.getRequest()); + } +} diff --git a/src/main/java/org/springframework/hateoas/reactive/ReactiveResourceAssembler.java b/src/main/java/org/springframework/hateoas/reactive/ReactiveResourceAssembler.java new file mode 100644 index 00000000..10b73136 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/reactive/ReactiveResourceAssembler.java @@ -0,0 +1,57 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import org.springframework.hateoas.ResourceAssembler; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.SimpleResourceAssembler; +import org.springframework.web.server.ServerWebExchange; + +/** + * Reactive variant of {@link ResourceAssembler} combined with {@link SimpleResourceAssembler}. + * + * @author Greg Turnquist + * @since 1.0 + */ +public interface ReactiveResourceAssembler { + + /** + * Converts the given entity into a {@code D}, which extends {@link ResourceSupport}. + * + * @param entity + * @return + */ + Mono toResource(T entity, ServerWebExchange exchange); + + /** + * Converts an {@link Iterable} or {@code T}s into an {@link Iterable} of {@link ResourceSupport} and wraps + * them in a {@link Resources} instance. + * + * @param entities must not be {@literal null}. + * @return {@link Resources} containing {@code D}. + */ + default Mono> toResources(Flux entities, ServerWebExchange exchange) { + + return entities + .flatMap(entity -> toResource(entity, exchange)) + .collectList() + .map(listOfResources -> new Resources<>(listOfResources)); + } + +} diff --git a/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java b/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java new file mode 100644 index 00000000..cfbec7a5 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceAssembler; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.SimpleResourceAssembler; +import org.springframework.web.server.ServerWebExchange; + +/** + * Reactive variant of {@link ResourceAssembler} combined with {@link SimpleResourceAssembler}. + * + * @author Greg Turnquist + * @since 1.0 + */ +public interface SimpleReactiveResourceAssembler extends ReactiveResourceAssembler> { + + /** + * Converts the given entity into a {@link Resource} wrapped in a {@link Mono}. + * + * @param entity + * @return + */ + default Mono> toResource(T entity, ServerWebExchange exchange) { + + Resource resource = new Resource<>(entity); + addLinks(resource, exchange); + return Mono.just(resource); + } + + /** + * Define links to add to every individual {@link Resource}. + * + * @param resource + */ + void addLinks(Resource resource, ServerWebExchange exchange); + + /** + * Converts all given entities into resources and wraps the collection as a resource as well. + * + * @see #toResource(Object, ServerWebExchange) + * @param entities must not be {@literal null}. + * @return {@link Resources} containing {@link Resource} of {@code T}. + */ + default Mono>> toResources(Flux entities, ServerWebExchange exchange) { + + return entities + .flatMap(entity -> toResource(entity, exchange)) + .collectList() + .map(listOfResources -> { + Resources> resources = new Resources<>(listOfResources); + addLinks(resources, exchange); + return resources; + }); + } + + /** + * Define links to add to the {@link Resources} collection. + * + * @param resources + */ + void addLinks(Resources> resources, ServerWebExchange exchange); + +} diff --git a/src/main/java/org/springframework/hateoas/support/WebStack.java b/src/main/java/org/springframework/hateoas/support/WebStack.java new file mode 100644 index 00000000..8196408c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/support/WebStack.java @@ -0,0 +1,46 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.support; + +import org.springframework.util.ClassUtils; + +/** + * Utility to glean what web stack is currently available. + * + * @author Greg Turnquist + */ +public enum WebStack { + + WEBMVC("org.springframework.web.servlet.DispatcherServlet"), + + WEBFLUX("org.springframework.web.reactive.DispatcherHandler"); + + private final boolean isAvailable; + + /** + * Initialize the {@link #isAvailable} based upon a defined signature class. + */ + WebStack(String signatureClass) { + this.isAvailable = ClassUtils.isPresent(signatureClass, null); + } + + /** + * Is this web stack on the classpath? + */ + public boolean isAvailable() { + return this.isAvailable; + } +} diff --git a/src/test/java/org/springframework/hateoas/LinkRelationUnitTest.java b/src/test/java/org/springframework/hateoas/LinkRelationUnitTest.java index 7f4fca18..639d989d 100644 --- a/src/test/java/org/springframework/hateoas/LinkRelationUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkRelationUnitTest.java @@ -15,8 +15,7 @@ */ package org.springframework.hateoas; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.assertj.core.api.Assertions.*; import lombok.Value; diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index f4402f39..2ae594bf 100755 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2018 the original author or authors. + * Copyright 2012-2019 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. diff --git a/src/test/java/org/springframework/hateoas/client/Actor.java b/src/test/java/org/springframework/hateoas/client/Actor.java index 107adf5b..4b32fdbb 100644 --- a/src/test/java/org/springframework/hateoas/client/Actor.java +++ b/src/test/java/org/springframework/hateoas/client/Actor.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-2019 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. @@ -15,16 +15,19 @@ */ package org.springframework.hateoas.client; +import lombok.Data; + import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.fasterxml.jackson.annotation.JsonProperty; @JsonAutoDetect(fieldVisibility = Visibility.ANY) +@Data public class Actor { String name; - Actor(@JsonProperty("name") String name) { + public Actor(@JsonProperty("name") String name) { this.name = name; } } diff --git a/src/test/java/org/springframework/hateoas/client/Movie.java b/src/test/java/org/springframework/hateoas/client/Movie.java index f00846f0..15151c2c 100644 --- a/src/test/java/org/springframework/hateoas/client/Movie.java +++ b/src/test/java/org/springframework/hateoas/client/Movie.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2014 the original author or authors. + * Copyright 2013-2019 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. @@ -23,7 +23,7 @@ public class Movie { String title; - Movie(String title) { + public Movie(String title) { this.title = title; } } diff --git a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java index 45b23735..a2aa590c 100755 --- a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2015 the original author or authors. + * Copyright 2013-2019 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. @@ -17,6 +17,7 @@ package org.springframework.hateoas.config; import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.hal.HalConfiguration.RenderSingleLinks.*; +import static org.springframework.hateoas.support.ContextTester.withServletContext; import java.lang.reflect.Method; import java.util.List; @@ -76,8 +77,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; @RunWith(MockitoJUnitRunner.class) public class EnableHypermediaSupportIntegrationTest { - MockMvc mockMvc; - @Test public void bootstrapHalConfiguration() { assertHalSetupForConfigClass(HalConfig.class); @@ -101,7 +100,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersHalLinkDiscoverers() { - withContext(HalConfig.class, context -> { + withServletContext(HalConfig.class, context -> { LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); @@ -117,7 +116,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersHalFormsLinkDiscoverers() { - withContext(HalFormsConfig.class, context -> { + withServletContext(HalFormsConfig.class, context -> { LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); @@ -131,7 +130,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersCollectionJsonLinkDiscoverers() { - withContext(CollectionJsonConfig.class, context -> { + withServletContext(CollectionJsonConfig.class, context -> { LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); @@ -145,7 +144,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersUberLinkDiscoverers() { - withContext(UberConfig.class, context -> { + withServletContext(UberConfig.class, context -> { LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); @@ -183,7 +182,7 @@ public class EnableHypermediaSupportIntegrationTest { @SuppressWarnings("unchecked") public void halSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - withContext(HalConfig.class, context -> { + withServletContext(HalConfig.class, context -> { RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); @@ -215,7 +214,7 @@ public class EnableHypermediaSupportIntegrationTest { @SuppressWarnings("unchecked") public void halFormsSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - withContext(HalFormsConfig.class, context -> { + withServletContext(HalFormsConfig.class, context -> { RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); @@ -247,7 +246,7 @@ public class EnableHypermediaSupportIntegrationTest { @SuppressWarnings("unchecked") public void collectionJsonSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - withContext(CollectionJsonConfig.class, context -> { + withServletContext(CollectionJsonConfig.class, context -> { RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); @@ -279,7 +278,7 @@ public class EnableHypermediaSupportIntegrationTest { @SuppressWarnings("unchecked") public void uberSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { - withContext(UberConfig.class, context -> { + withServletContext(UberConfig.class, context -> { RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); @@ -316,7 +315,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersHalHttpMessageConvertersForRestTemplate() { - withContext(HalConfig.class, context -> { + withServletContext(HalConfig.class, context -> { RestTemplate template = context.getBean(RestTemplate.class); @@ -328,7 +327,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersHalFormsHttpMessageConvertersForRestTemplate() { - withContext( // + withServletContext( // HalFormsConfig.class, // context -> foo( // context, // @@ -381,7 +380,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersCollectionJsonHttpMessageConvertersForRestTemplate() { - withContext(CollectionJsonConfig.class, context -> { + withServletContext(CollectionJsonConfig.class, context -> { RestTemplate template = context.getBean(RestTemplate.class); assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()).hasSize(1) @@ -392,7 +391,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void registersUberHttpMessageConvertersForRestTemplate() { - withContext(UberConfig.class, context -> { + withServletContext(UberConfig.class, context -> { RestTemplate template = context.getBean(RestTemplate.class); assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()) // @@ -407,7 +406,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void configuresDefaultObjectMapperForHalToIgnoreUnknownProperties() { - withContext( // + withServletContext( // HalConfig.class, // context -> assertObjectMapper( // context, // @@ -424,7 +423,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void configuresDefaultObjectMapperForHalFormsToIgnoreUnknownProperties() { - withContext( // + withServletContext( // HalFormsConfig.class, // context -> assertObjectMapper( // context, // @@ -438,7 +437,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void configuresDefaultObjectMapperForCollectionJsonToIgnoreUnknownProperties() { - withContext( // + withServletContext( // CollectionJsonConfig.class, // context -> assertObjectMapper( // context, // @@ -452,7 +451,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void configuresDefaultObjectMapperForUberToIgnoreUnknownProperties() { - withContext( // + withServletContext( // UberConfig.class, // context -> assertObjectMapper( // context, // @@ -466,7 +465,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void verifyDefaultHalConfigurationRendersSingleItemAsSingleItem() throws JsonProcessingException { - withContext(HalConfig.class, context -> { + withServletContext(HalConfig.class, context -> { RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); @@ -492,7 +491,7 @@ public class EnableHypermediaSupportIntegrationTest { @Test public void verifyRenderSingleLinkAsArrayViaOverridingBean() { - withContext( // + withServletContext( // RenderLinkAsSingleLinksConfig.class, // context -> assertObjectMapper( // context, // @@ -507,19 +506,6 @@ public class EnableHypermediaSupportIntegrationTest { ); } - private static void withContext(Class configuration, - ConsumerWithException consumer) throws E { - - try (AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext()) { - - context.register(configuration); - context.setServletContext(new MockServletContext()); - context.refresh(); - - consumer.accept(context); - } - } - private static void assertEntityLinksSetUp(ApplicationContext context) { assertThat(context.getBeansOfType(EntityLinks.class).values()) // @@ -534,7 +520,7 @@ public class EnableHypermediaSupportIntegrationTest { private static void assertHalSetupForConfigClass(Class configClass) { - withContext(configClass, context -> { + withServletContext(configClass, context -> { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(HalLinkDiscoverer.class); @@ -547,7 +533,7 @@ public class EnableHypermediaSupportIntegrationTest { private static void assertHalFormsSetupForConfigClass(Class configClass) { - withContext(configClass, context -> { + withServletContext(configClass, context -> { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(HalFormsLinkDiscoverer.class); @@ -560,7 +546,7 @@ public class EnableHypermediaSupportIntegrationTest { private static void assertCollectionJsonSetupForConfigClass(Class configClass) { - withContext(configClass, context -> { + withServletContext(configClass, context -> { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(CollectionJsonLinkDiscoverer.class); @@ -572,7 +558,7 @@ public class EnableHypermediaSupportIntegrationTest { private static void assertUberSetupForConfigClass(Class configClass) { - withContext(configClass, context -> { + withServletContext(configClass, context -> { assertEntityLinksSetUp(context); assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(UberLinkDiscoverer.class); diff --git a/src/test/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessorTest.java b/src/test/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessorTest.java new file mode 100644 index 00000000..de0b6cc1 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/config/mvc/HypermediaRestTemplateBeanPostProcessorTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.mvc; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.springframework.hateoas.support.ContextTester.*; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +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.web.client.RestTemplate; + +/** + * @author Greg Turnquist + */ +public class HypermediaRestTemplateBeanPostProcessorTest { + + @Test + public void shouldRegisterJustHal() { + + withContext(HalConfig.class, context -> { + + assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) + .containsExactlyInAnyOrder( + MediaTypes.HAL_JSON, + MediaTypes.HAL_JSON_UTF8, + MediaType.APPLICATION_JSON, + MediaType.parseMediaType("application/*+json")); + }); + } + + @Test + public void shouldRegisterHalAndCollectionJsonMessageConverters() { + + withContext(HalAndCollectionJsonConfig.class, context -> { + + assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) + .containsExactlyInAnyOrder( + MediaTypes.HAL_JSON, + MediaTypes.HAL_JSON_UTF8, + MediaTypes.COLLECTION_JSON, + MediaType.APPLICATION_JSON, + MediaType.parseMediaType("application/*+json")); + }); + } + + @Test + public void shouldRegisterHypermediaMessageConverters() { + + withContext(AllHypermediaConfig.class, context -> { + + assertThat(lookupSupportedHypermediaTypes(context.getBean(RestTemplate.class))) + .containsExactlyInAnyOrder( + MediaTypes.HAL_JSON, + MediaTypes.HAL_JSON_UTF8, + MediaTypes.HAL_FORMS_JSON, + MediaTypes.COLLECTION_JSON, + MediaTypes.UBER_JSON, + MediaType.APPLICATION_JSON, + MediaType.parseMediaType("application/*+json")); + }); + } + + 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 + RestTemplate restTemplate() { + return new RestTemplate(); + } + } + + @Configuration + @EnableHypermediaSupport(type = HypermediaType.HAL) + static class HalConfig extends BaseConfig { + } + + @Configuration + @EnableHypermediaSupport(type = {HypermediaType.HAL, HypermediaType.COLLECTION_JSON}) + static class HalAndCollectionJsonConfig extends BaseConfig { + } + + @Configuration + @EnableHypermediaSupport(type = {HypermediaType.HAL, HypermediaType.HAL_FORMS, HypermediaType.COLLECTION_JSON, HypermediaType.UBER}) + static class AllHypermediaConfig extends BaseConfig { + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java new file mode 100644 index 00000000..1d65f3cb --- /dev/null +++ b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.hateoas.support.ContextTester.*; + +import java.io.IOException; +import java.net.URI; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.test.StepVerifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.client.Actor; +import org.springframework.hateoas.client.Movie; +import org.springframework.hateoas.client.Server; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.mvc.TypeReferences.ResourceType; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * @author Greg Turnquist + */ +public class HypermediaWebClientBeanPostProcessorTest { + + private URI baseUri; + private Server server; + + @Before + public void setUp() { + + this.server = new Server(); + + Resource actor = new Resource<>(new Actor("Keanu Reaves")); + String actorUri = this.server.mockResourceFor(actor); + + Movie movie = new Movie("The Matrix"); + Resource resource = new Resource<>(movie); + resource.add(new Link(actorUri, "actor")); + + this.server.mockResourceFor(resource); + this.server.finishMocking(); + + this.baseUri = URI.create(this.server.rootResource()); + } + + @After + public void tearDown() throws IOException { + + if (this.server != null) { + this.server.close(); + } + } + + @Test + public void shouldHandleRootHalDocument() { + + withContext(HalConfig.class, context -> { + + WebClient webClient = context.getBean(WebClient.class); + + webClient + .get().uri(this.baseUri) + .accept(MediaTypes.HAL_JSON) + .retrieve() + .bodyToMono(ResourceSupport.class) + .as(StepVerifier::create) + .expectNextMatches(root -> { + assertThat(root.getLinks()).hasSize(2); + return true; + + }) + .verifyComplete(); + }); + } + + @Test + public void shouldHandleNavigatingToAResourceObject() { + + ParameterizedTypeReference> typeReference = new ResourceType() {}; + + withContext(HalConfig.class, context -> { + + WebClient webClient = context.getBean(WebClient.class); + + webClient + .get().uri(this.baseUri) + .retrieve() + .bodyToMono(ResourceSupport.class) + .map(resourceSupport -> resourceSupport.getLink("actors").orElseThrow(() -> new RuntimeException("Link not found!"))) + .flatMap(link -> webClient + .get().uri(link.expand().getHref()) + .retrieve() + .bodyToMono(ResourceSupport.class)) + .map(resourceSupport -> resourceSupport.getLinks().get(0)) + .flatMap(link -> webClient + .get().uri(link.expand().getHref()) + .retrieve() + .bodyToMono(typeReference)) + .as(StepVerifier::create) + .expectNext(new Resource<>(new Actor("Keanu Reaves"))) + .verifyComplete(); + }); + } + + @Configuration + @EnableHypermediaSupport(type = HypermediaType.HAL) + static class HalConfig { + + @Bean + WebClient webClient() { + return WebClient.create(); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurerTest.java new file mode 100644 index 00000000..82f59442 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurerTest.java @@ -0,0 +1,477 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config.reactive; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.hateoas.IanaLinkRelation; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.SimpleResourceAssembler; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.mvc.TypeReferences.ResourceType; +import org.springframework.hateoas.mvc.TypeReferences.ResourcesType; +import org.springframework.hateoas.support.Employee; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * @author Greg Turnquist + */ +public class HypermediaWebFluxConfigurerTest { + + ParameterizedTypeReference> resourceEmployeeType = new ResourceType() {}; + ParameterizedTypeReference>> resourcesEmployeeType = new ResourcesType>() {}; + + WebTestClient testClient; + + void setUp(Class context) { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.register(context); + ctx.refresh(); + + WebClientConfigurer webClientConfigurer = ctx.getBean(WebClientConfigurer.class); + + this.testClient = WebTestClient.bindToApplicationContext(ctx).build() + .mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) + .build(); + } + + @Test + public void registeringHalShouldServeHal() { + + setUp(HalWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + } + + @Test + public void registeringHalFormsShouldServeHalForms() { + + setUp(HalFormsWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + } + + @Test + public void registeringCollectionJsonShouldServerCollectionJson() { + + setUp(CollectionJsonWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + } + + @Test + public void registeringUberShouldServerUber() { + + setUp(UberWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.UBER_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.UBER_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); + } + + @Test + public void registeringHalAndHalFormsShouldServerHalAndHalForms() { + + setUp(AllHalWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + } + + @Test + public void registeringHalAndCollectionJsonShouldServerHalAndCollectionJson() { + + setUp(HalAndCollectionJsonWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + } + + @Test + public void registeringAllHypermediaTypesShouldServerThemAll() { + + setUp(AllHypermediaTypesWebFluxConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + + verifyRootUriServesHypermedia(MediaTypes.UBER_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.UBER_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); + } + + @Test + public void callingForUnregisteredMediaTypeShouldFail() { + + setUp(HalWebFluxConfig.class); + + this.testClient.get().uri("/") + .accept(MediaTypes.UBER_JSON) + .exchange() + .expectStatus().is4xxClientError() + .returnResult(String.class) + .getResponseBody() + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void reactorTypesShouldWork() { + + setUp(HalWebFluxConfig.class); + + this.testClient.get().uri("/reactive") + .accept(MediaTypes.HAL_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_JSON_UTF8) + .returnResult(ResourceSupport.class) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(resourceSupport -> { + + assertThat(resourceSupport.getLinks()).containsExactlyInAnyOrder( + new Link("/", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees") + ); + + return true; + }) + .verifyComplete(); + + this.testClient.get().uri("/reactive/employees") + .accept(MediaTypes.HAL_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_JSON_UTF8) + .returnResult(this.resourcesEmployeeType) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(resources -> { + + assertThat(resources.getContent()).extracting("content").containsExactlyInAnyOrder( + new Employee("Frodo Baggins", "ring bearer")); + + assertThat(resources.getContent()).extracting("links").containsExactlyInAnyOrder( + Arrays.asList( + new Link("/employees/1", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees"))); + + assertThat(resources.getLinks()).containsExactlyInAnyOrder( + new Link("/employees", IanaLinkRelation.SELF.value())); + + return true; + }) + .verifyComplete(); + + this.testClient.get().uri("/reactive/employees/1") + .accept(MediaTypes.HAL_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_JSON_UTF8) + .returnResult(this.resourceEmployeeType) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(employee -> { + System.out.println(employee); + + assertThat(employee.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + + assertThat(employee.getLinks()).containsExactlyInAnyOrder( + new Link("/employees/1", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees") + ); + return true; + }) + .verifyComplete(); + } + + private void verifyRootUriServesHypermedia(MediaType mediaType) { + verifyRootUriServesHypermedia(mediaType, mediaType); + } + + private void verifyRootUriServesHypermedia(MediaType requestType, MediaType responseType) { + + this.testClient.get().uri("/") + .accept(requestType) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(responseType) + .returnResult(ResourceSupport.class) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(resourceSupport -> { + + assertThat(resourceSupport.getLinks()).containsExactlyInAnyOrder( + new Link("/", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees")); + + return true; + }) + .verifyComplete(); + } + + private void verifyAggregateRootServesHypermedia(MediaType mediaType) { + verifyAggregateRootServesHypermedia(mediaType, mediaType); + } + + private void verifyAggregateRootServesHypermedia(MediaType requestType, MediaType responseType) { + + this.testClient.get().uri("/employees") + .accept(requestType) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(responseType) + .returnResult(this.resourcesEmployeeType) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(resources -> { + + assertThat(resources.getContent()).hasSize(1); + + assertThat(resources.getContent()).extracting("content").containsExactlyInAnyOrder( + new Employee("Frodo Baggins", "ring bearer") + ); + + assertThat(resources.getContent()).extracting("links").containsExactlyInAnyOrder( + Arrays.asList( + new Link("/employees/1", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees")) + ); + + assertThat(resources.getLinks()).containsExactlyInAnyOrder( + new Link("/employees", IanaLinkRelation.SELF.value()) + ); + + return true; + }) + .verifyComplete(); + } + + private void verifySingleItemResourceServesHypermedia(MediaType mediaType) { + verifySingleItemResourceServesHypermedia(mediaType, mediaType); + } + + private void verifySingleItemResourceServesHypermedia(MediaType requestType, MediaType responseType) { + + this.testClient.get().uri("/employees/1") + .accept(requestType) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(responseType) + .returnResult(this.resourceEmployeeType) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(employeeResource -> { + + assertThat(employeeResource.getContent()).isEqualTo( + new Employee("Frodo Baggins", "ring bearer")); + + assertThat(employeeResource.getLinks()).containsExactlyInAnyOrder( + new Link("/employees/1", IanaLinkRelation.SELF.value()), + new Link("/employees", "employees")); + + return true; + }) + .verifyComplete(); + } + + @Configuration + @EnableWebFlux + static abstract class BaseConfig { + + @Bean + TestController testController() { + return new TestController(); + } + } + + @EnableHypermediaSupport(type = HAL) + static class HalWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = HAL_FORMS) + static class HalFormsWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = COLLECTION_JSON) + static class CollectionJsonWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = UBER) + static class UberWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = {HAL, HAL_FORMS}) + static class AllHalWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = {HAL, HAL_FORMS, COLLECTION_JSON}) + static class HalAndCollectionJsonWebFluxConfig extends BaseConfig { + } + + @EnableHypermediaSupport(type = {HAL, HAL_FORMS, COLLECTION_JSON, UBER}) + static class AllHypermediaTypesWebFluxConfig extends BaseConfig { + } + + @RestController + static class TestController { + + private List employees; + private EmployeeResourceAssembler assembler = new EmployeeResourceAssembler(); + + TestController() { + + this.employees = new ArrayList<>(); + this.employees.add(new Employee("Frodo Baggins", "ring bearer")); + } + + @GetMapping + ResourceSupport root() { + + ResourceSupport root = new ResourceSupport(); + + root.add(new Link("/").withSelfRel()); + root.add(new Link("/employees").withRel("employees")); + + return root; + } + + @GetMapping("/employees") + Resources> employees() { + return this.assembler.toResources(this.employees); + } + + @PostMapping("/employees") + Resource newEmployee(@RequestBody Employee newEmployee) { + + this.employees.add(newEmployee); + + return this.assembler.toResource(newEmployee); + } + + @GetMapping("/employees/{id}") + Resource employee(@PathVariable String id) { + return this.assembler.toResource(this.employees.get(0)); + } + + @PutMapping("/employees/{id}") + Resource updateEmployee(@RequestBody Employee newEmployee, @PathVariable String id) { + + this.employees.add(newEmployee); + + return this.assembler.toResource(newEmployee); + } + + @GetMapping("/reactive") + Mono reactiveRoot() { + return Mono.just(root()); + } + + @GetMapping("/reactive/employees") + Mono>> reactiveEmployees() { + + return findAll() + .collectList() + .map(employees -> this.assembler.toResources(employees)); + } + + @GetMapping("/reactive/employees/{id}") + Mono> reactiveEmployee(@PathVariable String id) { + return findById(0) + .map(this.assembler::toResource); + } + + Mono findById(int id) { + return Mono.just(this.employees.get(id)); + } + + Flux findAll() { + return Flux.fromIterable(this.employees); + } + } + + static class EmployeeResourceAssembler implements SimpleResourceAssembler { + + @Override + public void addLinks(Resource resource) { + + resource.add(new Link("/employees/1").withSelfRel()); + resource.add(new Link("/employees").withRel("employees")); + } + + @Override + public void addLinks(Resources> resources) { + resources.add(new Link("/employees").withSelfRel()); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/hateoas/core/ControllerEntityLinksUnitTest.java b/src/test/java/org/springframework/hateoas/core/ControllerEntityLinksUnitTest.java index 673b2c65..232b8f57 100755 --- a/src/test/java/org/springframework/hateoas/core/ControllerEntityLinksUnitTest.java +++ b/src/test/java/org/springframework/hateoas/core/ControllerEntityLinksUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2013 the original author or authors. + * Copyright 2012-2019 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. @@ -16,7 +16,8 @@ package org.springframework.hateoas.core; import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java index 77858835..a7b2b8ff 100755 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2018 the original author or authors. + * Copyright 2012-2019 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. diff --git a/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java index e85a2142..999cf99b 100644 --- a/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 the original author or authors. + * Copyright 2018-2019 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. diff --git a/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java index ae67c34f..c4a9a210 100644 --- a/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2016 the original author or authors. + * Copyright 2012-2019 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. @@ -16,7 +16,9 @@ package org.springframework.hateoas.mvc; import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; import static org.springframework.util.ReflectionUtils.*; @@ -50,7 +52,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.method.support.HandlerMethodReturnValueHandler; /** - * Unit tests for {@link org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler}. + * Unit tests for {@link ResourceProcessorHandlerMethodReturnValueHandler}. * * @author Oliver Gierke * @author Jon Brisbin diff --git a/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java new file mode 100644 index 00000000..9fa75739 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; +import static org.springframework.hateoas.core.DummyInvocationUtils.*; +import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.*; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.IanaLinkRelation; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.reactive.WebClientConfigurer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * @author Greg Turnquist + */ +public class HypermediaWebFilterTest { + + WebTestClient testClient; + + @Before + public void setUp() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); + ctx.register(WebFluxConfig.class); + ctx.refresh(); + + WebClientConfigurer webClientConfigurer = ctx.getBean(WebClientConfigurer.class); + + this.testClient = WebTestClient.bindToApplicationContext(ctx).build() + .mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) + .build(); + } + + @Test + public void webFilterShouldEmbedExchangeIntoContext() { + + this.testClient.get().uri("http://example.com/api") + .accept(MediaTypes.HAL_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_JSON_UTF8) + .returnResult(ResourceSupport.class) + .getResponseBody() + .as(StepVerifier::create) + .expectNextMatches(resourceSupport -> { + + assertThat(resourceSupport.getLinks()).containsExactly( + new Link("http://example.com/api", IanaLinkRelation.SELF.value())); + + return true; + }) + .verifyComplete(); + + } + + @RestController + @RequestMapping("/api") + static class TestController { + + @GetMapping + Mono root() { + + return linkTo(methodOn(TestController.class).root()) + .map(ReactiveLinkBuilder::withSelfRel) + .map(ResourceSupport::new); + } + } + + @Configuration + @EnableWebFlux + @EnableHypermediaSupport(type = HAL) + static class WebFluxConfig { + + @Bean + TestController controller() { + return new TestController(); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java b/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java new file mode 100644 index 00000000..894c0829 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java @@ -0,0 +1,231 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; +import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.linkTo; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.context.Context; +import org.springframework.hateoas.IanaLinkRelation; +import org.springframework.hateoas.Link; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Greg Turnquist + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveLinkBuilderTest { + + @Mock ServerWebExchange exchange; + @Mock ServerHttpRequest request; + + @Test + public void linkAtSameLevelAsExplicitServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + } + + @Test + public void linkAtSameLevelAsContextProvidedServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + linkTo(methodOn(TestController.class).root()) + .map(ReactiveLinkBuilder::withSelfRel) + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) + .as(StepVerifier::create) + .expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + + return true; + }) + .verifyComplete(); + } + + @Test + public void shallowLinkFromDeepExplicitServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api/employees")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + } + + @Test + public void shallowLinkFromDeepContextProvidedServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api/employees")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + linkTo(methodOn(TestController.class).root()) + .map(ReactiveLinkBuilder::withSelfRel) + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) + .as(StepVerifier::create) + .expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + + return true; + }) + .verifyComplete(); + } + + @Test + public void deepLinkFromShallowExplicitServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + Link link = linkTo(methodOn(TestController.class).deep(), this.exchange).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); + } + + @Test + public void deepLinkFromShallowContextProvidedServerExchangeShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + linkTo(methodOn(TestController.class).deep()) + .map(ReactiveLinkBuilder::withSelfRel) + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) + .as(StepVerifier::create) + .expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); + + return true; + }) + .verifyComplete(); + } + + @Test + public void linkToRouteWithNoMappingShouldWork() throws URISyntaxException { + + when(this.exchange.getRequest()).thenReturn(this.request); + + when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/")); + when(this.request.getHeaders()).thenReturn(new HttpHeaders()); + + linkTo(methodOn(TestController2.class).root()) + .map(ReactiveLinkBuilder::withSelfRel) + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) + .as(StepVerifier::create) + .expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/"); + + return true; + }) + .verifyComplete(); + } + + @Test + public void linkToRouteWithNoExchangeInTheContextShouldFallbackToRelativeUris() throws URISyntaxException { + + linkTo(methodOn(TestController2.class).root()) + .map(ReactiveLinkBuilder::withSelfRel) + .as(StepVerifier::create) + .expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("/"); + + return true; + }) + .verifyComplete(); + } + + @Test + public void linkToRouteWithExplictExchangeBeingNullShouldFallbackToRelativeUris() throws URISyntaxException { + + Link link = linkTo(methodOn(TestController2.class).root(), null).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getHref()).isEqualTo("/"); + } + + + @RestController + @RequestMapping("/api") + static class TestController { + + @GetMapping + Mono root() { + return Mono.empty(); + } + + @GetMapping("/employees") + Mono deep() { + return Mono.empty(); + } + } + + @RestController + static class TestController2 { + + @GetMapping + Mono root() { + return Mono.empty(); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java b/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java new file mode 100644 index 00000000..3b651248 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.mockito.Mockito.*; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Collections; + +import org.assertj.core.api.AssertionsForInterfaceTypes; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Greg Turnquist + */ +public class ReactiveResourceAssemblerUnitTest { + + TestAssembler assembler; + + TestAssemblerWithCustomResources assemblerWithCustomResources; + + Employee employee; + + ServerWebExchange exchange; + + @Before + public void setUp() { + + this.assembler = new TestAssembler(); + this.assemblerWithCustomResources = new TestAssemblerWithCustomResources(); + this.employee = new Employee("Frodo Baggins"); + this.exchange = mock(ServerWebExchange.class); + } + + @Test + public void simpleConversionShouldWork() { + + this.assembler.toResource(this.employee, this.exchange) + .as(StepVerifier::create) + .expectNextMatches(employeeResource -> { + + assertThat(employeeResource.getEmployee()).isEqualTo(new Employee("Frodo Baggins")); + AssertionsForInterfaceTypes.assertThat(employeeResource.getLinks()).containsExactlyInAnyOrder( + new Link("/employees", "employees")); + + return true; + }) + .verifyComplete(); + } + @Test + public void defaultResourcesConversionShouldWork() { + + this.assembler.toResources(Flux.just(this.employee), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(employeeResources -> { + + assertThat(employeeResources.getContent()).extracting("employee").containsExactly( + new Employee("Frodo Baggins")); + + assertThat(employeeResources.getContent()).extracting("links").containsExactly( + Collections.singletonList(new Link("/employees", "employees"))); + + assertThat(employeeResources.getLinks()).isEmpty(); + + return true; + }) + .verifyComplete(); + } + + @Test + public void customResourcesShouldWork() { + + this.assemblerWithCustomResources.toResources(Flux.just(this.employee), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(employeeResources -> { + + AssertionsForInterfaceTypes.assertThat(employeeResources.getContent()).extracting("employee").containsExactly( + new Employee("Frodo Baggins")); + + AssertionsForInterfaceTypes.assertThat(employeeResources.getContent()).extracting("links").containsExactly( + Collections.singletonList(new Link("/employees", "employees"))); + + AssertionsForInterfaceTypes.assertThat(employeeResources.getLinks()).containsExactlyInAnyOrder( + new Link("/employees").withSelfRel(), + new Link("/", "root") + ); + + return true; + }) + .verifyComplete(); + } + + class TestAssembler implements ReactiveResourceAssembler { + + @Override + public Mono toResource(Employee entity, ServerWebExchange exchange) { + + EmployeeResource employeeResource = new EmployeeResource(entity); + employeeResource.add(new Link("/employees", "employees")); + + return Mono.just(employeeResource); + } + } + + class TestAssemblerWithCustomResources extends TestAssembler { + + @Override + public Mono> toResources(Flux entities, ServerWebExchange exchange) { + + return entities + .flatMap(entity -> toResource(entity, exchange)) + .collectList() + .map(listOfResources -> { + + Resources employeeResources = new Resources<>(listOfResources); + + employeeResources.add(new Link("/employees").withSelfRel()); + employeeResources.add(new Link("/", "root")); + + return employeeResources; + }); + } + } + + @Data + @AllArgsConstructor + class Employee { + private String name; + } + + @Data + @AllArgsConstructor + class EmployeeResource extends ResourceSupport { + private Employee employee; + } + +} diff --git a/src/test/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssemblerTest.java b/src/test/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssemblerTest.java new file mode 100644 index 00000000..3ca6e330 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssemblerTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.reactive; + +import static org.assertj.core.api.Assertions.*; + +import lombok.Data; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Greg Turnquist + */ +public class SimpleReactiveResourceAssemblerTest { + + TestResourceAssemblerSimple testResourceAssembler; + + ResourceAssemblerWithCustomLinkSimple resourceAssemblerWithCustomLink; + + @Mock ServerWebExchange exchange; + + @Before + public void setUp() { + + this.testResourceAssembler = new TestResourceAssemblerSimple(); + this.resourceAssemblerWithCustomLink = new ResourceAssemblerWithCustomLinkSimple(); + } + + /** + * @see # + */ + @Test + public void convertingToResourceShouldWork() { + + this.testResourceAssembler.toResource(new Employee("Frodo"), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(resource -> { + + assertThat(resource.getContent().getName()).isEqualTo("Frodo"); + assertThat(resource.getLinks()).isEmpty(); + return true; + }) + .verifyComplete(); + } + + /** + * @see # + */ + @Test + public void convertingToResourcesShouldWork() { + + this.testResourceAssembler.toResources(Flux.just(new Employee("Frodo")), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(resources -> { + + assertThat(resources.getContent()).containsExactly(new Resource<>(new Employee("Frodo"))); + assertThat(resources.getLinks()).isEmpty(); + + return true; + }); + } + + /** + * @see # + */ + @Test + public void convertingToResourceWithCustomLinksShouldWork() { + + this.resourceAssemblerWithCustomLink.toResource(new Employee("Frodo"), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(resource -> { + + assertThat(resource.getContent().getName()).isEqualTo("Frodo"); + assertThat(resource.getLinks()).containsExactly(new Link("/employees").withRel("employees")); + + return true; + }) + .verifyComplete(); + } + + /** + * @see # + */ + @Test + public void convertingToResourcesWithCustomLinksShouldWork() { + + this.resourceAssemblerWithCustomLink.toResources(Flux.just(new Employee("Frodo")), this.exchange) + .as(StepVerifier::create) + .expectNextMatches(resources -> { + + assertThat(resources.getContent()).containsExactly( + new Resource<>(new Employee("Frodo"), new Link("/employees").withRel("employees"))); + assertThat(resources.getLinks()).containsExactly(new Link("/", "root")); + + return true; + }) + .verifyComplete(); + } + + class TestResourceAssemblerSimple implements SimpleReactiveResourceAssembler { + + @Override + public void addLinks(Resource resource, ServerWebExchange exchange) { + } + + @Override + public void addLinks(Resources> resources, ServerWebExchange exchange) { + } + } + + class ResourceAssemblerWithCustomLinkSimple implements SimpleReactiveResourceAssembler { + + @Override + public void addLinks(Resource resource, ServerWebExchange exchange) { + resource.add(new Link("/employees").withRel("employees")); + } + + @Override + public void addLinks(Resources> resources, ServerWebExchange exchange) { + resources.add(new Link("/").withRel("root")); + } + } + + @Data + class Employee { + + private final String name; + } +} diff --git a/src/test/java/org/springframework/hateoas/support/ChangelogCreator.java b/src/test/java/org/springframework/hateoas/support/ChangelogCreator.java index bbda100e..e59a5bd8 100644 --- a/src/test/java/org/springframework/hateoas/support/ChangelogCreator.java +++ b/src/test/java/org/springframework/hateoas/support/ChangelogCreator.java @@ -15,10 +15,9 @@ */ package org.springframework.hateoas.support; -import net.minidev.json.JSONArray; - import java.util.Iterator; +import net.minidev.json.JSONArray; import org.springframework.web.client.RestTemplate; import com.jayway.jsonpath.JsonPath; diff --git a/src/test/java/org/springframework/hateoas/support/ContextTester.java b/src/test/java/org/springframework/hateoas/support/ContextTester.java new file mode 100644 index 00000000..4bfe4eb7 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/support/ContextTester.java @@ -0,0 +1,55 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.support; + +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +/** + * @author Greg Turnquist + */ +public class ContextTester { + + public static void withContext(Class configuration, + ConsumerWithException consumer) throws E { + + try (AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext()) { + + context.register(configuration); + context.refresh(); + + consumer.accept(context); + } + } + + public static void withServletContext(Class configuration, + ConsumerWithException consumer) throws E { + + try (AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext()) { + + context.register(configuration); + context.setServletContext(new MockServletContext()); + context.refresh(); + + consumer.accept(context); + } + } + + public interface ConsumerWithException { + + void accept(T element) throws E; + } +} diff --git a/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java b/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java index d399c83b..c17e5e53 100644 --- a/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java +++ b/src/test/java/org/springframework/hateoas/uber/UberLinkDiscovererUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2018 the original author or authors. + * Copyright 2013-2019 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. @@ -15,14 +15,13 @@ */ package org.springframework.hateoas.uber; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.support.MappingUtils.*; import java.io.IOException; import org.junit.Before; import org.junit.Test; - import org.springframework.core.io.ClassPathResource; import org.springframework.hateoas.LinkDiscoverer; import org.springframework.hateoas.core.AbstractLinkDiscovererUnitTest;