diff --git a/src/main/java/org/springframework/hateoas/core/WebHandler.java b/src/main/java/org/springframework/hateoas/WebHandler.java similarity index 52% rename from src/main/java/org/springframework/hateoas/core/WebHandler.java rename to src/main/java/org/springframework/hateoas/WebHandler.java index 9dfaad92..11d4a526 100644 --- a/src/main/java/org/springframework/hateoas/core/WebHandler.java +++ b/src/main/java/org/springframework/hateoas/WebHandler.java @@ -13,13 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.hateoas.core; +package org.springframework.hateoas; 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 lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -30,13 +35,21 @@ 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.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.AnnotationMappingDiscoverer; +import org.springframework.hateoas.core.LastInvocationAware; +import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.core.MethodInvocation; +import org.springframework.hateoas.core.MethodParameters; import org.springframework.hateoas.mvc.ControllerLinkBuilder; import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -47,52 +60,45 @@ 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)); + new AnnotationAttribute(PathVariable.class)); private static final AnnotatedParametersParameterAccessor REQUEST_PARAM_ACCESSOR = new RequestParamParameterAccessor(); public static ControllerLinkBuilder linkTo(Object invocationValue, - Function mappingToUriComponentsBuilder, - BiFunction additionalUriHandler) { + 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); - } - + UriTemplate template = new UriTemplate(mapping == null ? "/" : 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)) { + for (AnnotatedParametersParameterAccessor.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)) { + for (AnnotatedParametersParameterAccessor.BoundMethodParameter parameter : REQUEST_PARAM_ACCESSOR.getBoundParameters(invocation)) { bindRequestParameters(builder, parameter); @@ -112,12 +118,9 @@ public class WebHandler { } } - UriComponents components; - if (additionalUriHandler == null) { - components = builder.buildAndExpand(values); - } else { - components = additionalUriHandler.apply(builder, invocation).buildAndExpand(values); - } + UriComponents components = additionalUriHandler == null // + ? builder.buildAndExpand(values) + : additionalUriHandler.apply(builder, invocation).buildAndExpand(values); TemplateVariables variables = NONE; @@ -125,22 +128,22 @@ public class WebHandler { boolean previousRequestParameter = components.getQueryParams().isEmpty() && variables.equals(NONE); TemplateVariable variable = new TemplateVariable(parameter, - previousRequestParameter ? REQUEST_PARAM : REQUEST_PARAM_CONTINUED); + 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}. + * {@link AnnotatedParametersParameterAccessor.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) { + private static void bindRequestParameters(UriComponentsBuilder builder, AnnotatedParametersParameterAccessor.BoundMethodParameter parameter) { Object value = parameter.getValue(); String key = parameter.getVariableName(); @@ -198,7 +201,7 @@ public class WebHandler { */ @Override protected BoundMethodParameter createParameter(final MethodParameter parameter, Object value, - AnnotationAttribute attribute) { + AnnotationAttribute attribute) { return new BoundMethodParameter(parameter, value, attribute) { @@ -243,4 +246,162 @@ public class WebHandler { return annotation.defaultValue().equals(ValueConstants.DEFAULT_NONE) ? SKIP_VALUE : null; } } + + /** + * Value object to allow accessing {@link MethodInvocation} parameters with the configured {@link AnnotationAttribute}. + * + * @author Oliver Gierke + */ + @RequiredArgsConstructor + private static class AnnotatedParametersParameterAccessor { + + private static final Map METHOD_PARAMETERS_CACHE = new ConcurrentReferenceHashMap<>( + 16, ConcurrentReferenceHashMap.ReferenceType.WEAK); + + private final @NonNull AnnotationAttribute attribute; + + /** + * Returns {@link BoundMethodParameter}s contained in the given {@link MethodInvocation}. + * + * @param invocation must not be {@literal null}. + * @return + */ + public List getBoundParameters(MethodInvocation invocation) { + + Assert.notNull(invocation, "MethodInvocation must not be null!"); + + MethodParameters parameters = getOrCreateMethodParametersFor(invocation.getMethod()); + Object[] arguments = invocation.getArguments(); + List result = new ArrayList<>(); + + for (MethodParameter parameter : parameters.getParametersWith(attribute.getAnnotationType())) { + + Object value = arguments[parameter.getParameterIndex()]; + Object verifiedValue = verifyParameterValue(parameter, value); + + if (verifiedValue != null) { + result.add(createParameter(parameter, verifiedValue, attribute)); + } + } + + return result; + } + + /** + * Create the {@link BoundMethodParameter} for the given {@link MethodParameter}, parameter value and + * {@link AnnotationAttribute}. + * + * @param parameter must not be {@literal null}. + * @param value can be {@literal null}. + * @param attribute must not be {@literal null}. + * @return + */ + protected BoundMethodParameter createParameter(MethodParameter parameter, Object value, + AnnotationAttribute attribute) { + return new BoundMethodParameter(parameter, value, attribute); + } + + /** + * Callback to verify the parameter values given for a dummy invocation. Default implementation rejects + * {@literal null} values as they indicate an invalid dummy call. + * + * @param parameter will never be {@literal null}. + * @param value could be {@literal null}. + * @return the verified value. + */ + protected Object verifyParameterValue(MethodParameter parameter, Object value) { + return value; + } + + /** + * Returns the {@link MethodParameters} for the given {@link Method}. + * + * @param method + * @return + */ + private static MethodParameters getOrCreateMethodParametersFor(Method method) { + return METHOD_PARAMETERS_CACHE.computeIfAbsent(method, MethodParameters::new); + } + + /** + * Represents a {@link MethodParameter} alongside the value it has been bound to. + * + * @author Oliver Gierke + */ + static class BoundMethodParameter { + + private static final ConversionService CONVERSION_SERVICE = new DefaultFormattingConversionService(); + private static final TypeDescriptor STRING_DESCRIPTOR = TypeDescriptor.valueOf(String.class); + + private final MethodParameter parameter; + private final Object value; + private final AnnotationAttribute attribute; + private final TypeDescriptor parameterTypeDescriptor; + + /** + * Creates a new {@link BoundMethodParameter} + * + * @param parameter must not be {@literal null}. + * @param value can be {@literal null}. + * @param attribute can be {@literal null}. + */ + public BoundMethodParameter(MethodParameter parameter, Object value, AnnotationAttribute attribute) { + + Assert.notNull(parameter, "MethodParameter must not be null!"); + + this.parameter = parameter; + this.value = value; + this.attribute = attribute; + this.parameterTypeDescriptor = TypeDescriptor.nested(parameter, parameter.isOptional() ? 1 : 0); + } + + /** + * Returns the name of the {@link UriTemplate} variable to be bound. The name will be derived from the configured + * {@link AnnotationAttribute} or the {@link MethodParameter} name as fallback. + * + * @return + */ + public String getVariableName() { + + if (attribute == null) { + return parameter.getParameterName(); + } + + Annotation annotation = parameter.getParameterAnnotation(attribute.getAnnotationType()); + String annotationAttributeValue = attribute.getValueFrom(annotation); + + return StringUtils.hasText(annotationAttributeValue) ? annotationAttributeValue : parameter.getParameterName(); + } + + /** + * Returns the raw value bound to the {@link MethodParameter}. + * + * @return + */ + public Object getValue() { + return value; + } + + /** + * Returns the bound value converted into a {@link String} based on default conversion service setup. + * + * @return + */ + public String asString() { + + return value == null // + ? null // + : (String) CONVERSION_SERVICE.convert(value, parameterTypeDescriptor, STRING_DESCRIPTOR); + } + + /** + * Returns whether the given parameter is a required one. Defaults to {@literal true}. + * + * @return + */ + public boolean isRequired() { + return true; + } + } + } } diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index 465e1022..58e2867d 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -95,15 +95,5 @@ public @interface EnableHypermediaSupport { UBER; private static Set HAL_BASED_MEDIATYPES = EnumSet.of(HAL, HAL_FORMS); - - /** - * Is this {@literal HAL} or one of its derivatives? - * - * @param hypermediaType - * @return - */ - public static boolean isHalBasedMediaType(HypermediaType hypermediaType) { - return HAL_BASED_MEDIATYPES.contains(hypermediaType); - } } } diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java b/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java index f72c07b3..793ee525 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaObjectMapperCreator.java @@ -18,7 +18,6 @@ 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; @@ -27,14 +26,13 @@ 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 */ @@ -42,7 +40,7 @@ public final class HypermediaObjectMapperCreator { /** * Create a {@link org.springframework.hateoas.MediaTypes#HAL_JSON}-based {@link ObjectMapper}. - * + * * @param objectMapper * @param curieProvider * @param relProvider @@ -50,18 +48,15 @@ public final class HypermediaObjectMapperCreator { * @param halConfiguration * @return */ - public static ObjectMapper createHalObjectMapper(ObjectMapper objectMapper, - CurieProvider curieProvider, - RelProvider relProvider, - MessageSourceAccessor linkRelationMessageSource, - HalConfiguration halConfiguration) { + 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)); + mapper.setHandlerInstantiator( + new HalHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource, halConfiguration)); return mapper; } @@ -76,19 +71,16 @@ public final class HypermediaObjectMapperCreator { * @param halFormsConfiguration * @return properly configured objectMapper */ - public static ObjectMapper createHalFormsObjectMapper(ObjectMapper objectMapper, - CurieProvider curieProvider, - RelProvider relProvider, - MessageSourceAccessor linkRelationMessageSource, - HalFormsConfiguration halFormsConfiguration) { + 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)); + mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource, + true, halFormsConfiguration)); return mapper; } @@ -100,14 +92,12 @@ public final class HypermediaObjectMapperCreator { * @param linkRelationMessageSource * @return properly configured objectMapper */ - public static ObjectMapper createCollectionJsonObjectMapper(ObjectMapper objectMapper, - MessageSourceAccessor linkRelationMessageSource) { + public static ObjectMapper createCollectionJsonObjectMapper(ObjectMapper objectMapper) { ObjectMapper mapper = objectMapper.copy(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.registerModule(new Jackson2CollectionJsonModule()); - mapper.setHandlerInstantiator(new CollectionJsonHandlerInstantiator(linkRelationMessageSource)); return mapper; } @@ -124,7 +114,6 @@ public final class HypermediaObjectMapperCreator { 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/mvc/HypermediaWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java index 384f53c7..b677642d 100644 --- a/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/mvc/HypermediaWebMvcConfigurer.java @@ -79,11 +79,11 @@ public class HypermediaWebMvcConfigurer implements WebMvcConfigurer, BeanFactory @Override public void extendMessageConverters(List> converters) { - if (converters.stream() - .filter(MappingJackson2HttpMessageConverter.class::isInstance) - .map(AbstractJackson2HttpMessageConverter.class::cast) - .map(AbstractJackson2HttpMessageConverter::getObjectMapper) - .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { + if (converters.stream().filter(MappingJackson2HttpMessageConverter.class::isInstance) + .filter(AbstractJackson2HttpMessageConverter.class::isInstance) + .map(AbstractJackson2HttpMessageConverter.class::cast) + .map(AbstractJackson2HttpMessageConverter::getObjectMapper) + .anyMatch(Jackson2HalModule::isAlreadyRegisteredIn)) { return; } @@ -91,36 +91,32 @@ public class HypermediaWebMvcConfigurer implements WebMvcConfigurer, BeanFactory MessageSourceAccessor linkRelationMessageSource = this.beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSourceAccessor.class); - if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { - - if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + 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))); - } + 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, + Collections.singletonList(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))); + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, + Arrays.asList(COLLECTION_JSON), createCollectionJsonObjectMapper(this.mapper))); } if (this.hypermediaTypes.contains(HypermediaType.UBER)) { - converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter( - ResourceSupport.class, Arrays.asList(UBER_JSON), createUberObjectMapper(this.mapper))); + converters.add(0, new TypeConstrainedMappingJackson2HttpMessageConverter(ResourceSupport.class, + Collections.singletonList(UBER_JSON), createUberObjectMapper(this.mapper))); } } } diff --git a/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java index 0faab5df..213f36e4 100644 --- a/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurer.java @@ -26,6 +26,7 @@ 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.CharSequenceEncoder; import org.springframework.core.codec.StringDecoder; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; @@ -44,7 +45,7 @@ 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 */ @@ -62,7 +63,7 @@ public class HypermediaWebFluxConfigurer implements WebFluxConfigurer, BeanFacto private final Collection hypermediaTypes; private BeanFactory beanFactory; - + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; @@ -70,8 +71,8 @@ public class HypermediaWebFluxConfigurer implements WebFluxConfigurer, BeanFacto /** * 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. + *

+ * The configured readers and writers will be used for both annotated controllers and functional endpoints. * * @param configurer the configurer to use */ @@ -79,67 +80,59 @@ public class HypermediaWebFluxConfigurer implements WebFluxConfigurer, BeanFacto 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)) { + .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); + MessageSourceAccessor.class); CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs(); - if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { + if (this.hypermediaTypes.contains(HypermediaType.HAL)) { - if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + ObjectMapper halObjectMapper = createHalObjectMapper(this.mapper, this.curieProvider, this.relProvider, + linkRelationMessageSource, this.halConfiguration); - 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)); + } - 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)) { - if (this.hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + ObjectMapper halFormsObjectMapper = createHalFormsObjectMapper(this.mapper, this.curieProvider, + this.relProvider, linkRelationMessageSource, this.halFormsConfiguration); - 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)); - } + 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); + ObjectMapper collectionJsonObjectMapper = createCollectionJsonObjectMapper(this.mapper); - customCodecs.encoder( - new Jackson2JsonEncoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); - customCodecs.decoder( - new Jackson2JsonDecoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); + 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.encoder(new Jackson2JsonEncoder(uberObjectMapper, MediaTypes.UBER_JSON)); + customCodecs.decoder(new Jackson2JsonDecoder(uberObjectMapper, MediaTypes.UBER_JSON)); } + customCodecs.encoder(CharSequenceEncoder.allMimeTypes()); 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 index 65ea44b2..8d07a57e 100644 --- a/src/main/java/org/springframework/hateoas/config/reactive/WebClientConfigurer.java +++ b/src/main/java/org/springframework/hateoas/config/reactive/WebClientConfigurer.java @@ -28,6 +28,7 @@ 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.CharSequenceEncoder; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.core.codec.StringDecoder; @@ -46,7 +47,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; /** * Assembles {@link ExchangeStrategies} needed to wire a {@link WebClient} with hypermedia support. - * + * * @author Greg Turnquist * @since 1.0 */ @@ -64,7 +65,7 @@ public class WebClientConfigurer implements BeanFactoryAware { private final Collection hypermediaTypes; private BeanFactory beanFactory; - + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; @@ -72,40 +73,38 @@ public class WebClientConfigurer implements BeanFactoryAware { /** * 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); + MessageSourceAccessor.class); List> encoders = new ArrayList<>(); List> decoders = new ArrayList<>(); - if (this.hypermediaTypes.stream().anyMatch(HypermediaType::isHalBasedMediaType)) { + if (this.hypermediaTypes.contains(HypermediaType.HAL)) { - if (this.hypermediaTypes.contains(HypermediaType.HAL)) { + ObjectMapper halObjectMapper = createHalObjectMapper(this.mapper, this.curieProvider, this.relProvider, + linkRelationMessageSource, this.halConfiguration); - 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)); + } - 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)) { - if (this.hypermediaTypes.contains(HypermediaType.HAL_FORMS)) { + ObjectMapper halFormsObjectMapper = createHalFormsObjectMapper(this.mapper, this.curieProvider, + this.relProvider, linkRelationMessageSource, this.halFormsConfiguration); - 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)); - } + 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); + ObjectMapper collectionJsonObjectMapper = createCollectionJsonObjectMapper(this.mapper); encoders.add(new Jackson2JsonEncoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); decoders.add(new Jackson2JsonDecoder(collectionJsonObjectMapper, MediaTypes.COLLECTION_JSON)); @@ -119,18 +118,16 @@ public class WebClientConfigurer implements BeanFactoryAware { decoders.add(new Jackson2JsonDecoder(uberObjectMapper, MediaTypes.UBER_JSON)); } - // Include ability to decode into a plain string + encoders.add(CharSequenceEncoder.allMimeTypes()); decoders.add(StringDecoder.allMimeTypes()); - return ExchangeStrategies.builder() - .codecs(clientCodecConfigurer -> { + return ExchangeStrategies.builder().codecs(clientCodecConfigurer -> { - encoders.forEach(encoder -> clientCodecConfigurer.customCodecs().encoder(encoder)); - decoders.forEach(decoder -> clientCodecConfigurer.customCodecs().decoder(decoder)); + encoders.forEach(encoder -> clientCodecConfigurer.customCodecs().encoder(encoder)); + decoders.forEach(decoder -> clientCodecConfigurer.customCodecs().decoder(decoder)); - clientCodecConfigurer.registerDefaults(false); - }) - .build(); + clientCodecConfigurer.registerDefaults(false); + }).build(); } /** @@ -141,9 +138,6 @@ public class WebClientConfigurer implements BeanFactoryAware { */ public WebClient registerHypermediaTypes(WebClient webClient) { - return webClient.mutate() - .exchangeStrategies(hypermediaExchangeStrategies()) - .build(); + return webClient.mutate().exchangeStrategies(hypermediaExchangeStrategies()).build(); } } - diff --git a/src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java b/src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java deleted file mode 100644 index aaacae01..00000000 --- a/src/main/java/org/springframework/hateoas/core/AnnotatedParametersParameterAccessor.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * 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. - * 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 lombok.NonNull; -import lombok.RequiredArgsConstructor; - -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -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.util.Assert; -import org.springframework.util.ConcurrentReferenceHashMap; -import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; -import org.springframework.util.StringUtils; -import org.springframework.web.util.UriTemplate; - -/** - * Value object to allow accessing {@link MethodInvocation} parameters with the configured {@link AnnotationAttribute}. - * - * @author Oliver Gierke - */ -@RequiredArgsConstructor -class AnnotatedParametersParameterAccessor { - - private static final Map METHOD_PARAMETERS_CACHE = new ConcurrentReferenceHashMap<>( - 16, ReferenceType.WEAK); - - private final @NonNull AnnotationAttribute attribute; - - /** - * Returns {@link BoundMethodParameter}s contained in the given {@link MethodInvocation}. - * - * @param invocation must not be {@literal null}. - * @return - */ - public List getBoundParameters(MethodInvocation invocation) { - - Assert.notNull(invocation, "MethodInvocation must not be null!"); - - MethodParameters parameters = getOrCreateMethodParametersFor(invocation.getMethod()); - Object[] arguments = invocation.getArguments(); - List result = new ArrayList<>(); - - for (MethodParameter parameter : parameters.getParametersWith(attribute.getAnnotationType())) { - - Object value = arguments[parameter.getParameterIndex()]; - Object verifiedValue = verifyParameterValue(parameter, value); - - if (verifiedValue != null) { - result.add(createParameter(parameter, verifiedValue, attribute)); - } - } - - return result; - } - - /** - * Create the {@link BoundMethodParameter} for the given {@link MethodParameter}, parameter value and - * {@link AnnotationAttribute}. - * - * @param parameter must not be {@literal null}. - * @param value can be {@literal null}. - * @param attribute must not be {@literal null}. - * @return - */ - protected BoundMethodParameter createParameter(MethodParameter parameter, Object value, - AnnotationAttribute attribute) { - return new BoundMethodParameter(parameter, value, attribute); - } - - /** - * Callback to verify the parameter values given for a dummy invocation. Default implementation rejects - * {@literal null} values as they indicate an invalid dummy call. - * - * @param parameter will never be {@literal null}. - * @param value could be {@literal null}. - * @return the verified value. - */ - protected Object verifyParameterValue(MethodParameter parameter, Object value) { - return value; - } - - /** - * Returns the {@link MethodParameters} for the given {@link Method}. - * - * @param method - * @return - */ - private static MethodParameters getOrCreateMethodParametersFor(Method method) { - return METHOD_PARAMETERS_CACHE.computeIfAbsent(method, MethodParameters::new); - } - - /** - * Represents a {@link MethodParameter} alongside the value it has been bound to. - * - * @author Oliver Gierke - */ - static class BoundMethodParameter { - - private static final ConversionService CONVERSION_SERVICE = new DefaultFormattingConversionService(); - private static final TypeDescriptor STRING_DESCRIPTOR = TypeDescriptor.valueOf(String.class); - - private final MethodParameter parameter; - private final Object value; - private final AnnotationAttribute attribute; - private final TypeDescriptor parameterTypeDescriptor; - - /** - * Creates a new {@link BoundMethodParameter} - * - * @param parameter must not be {@literal null}. - * @param value can be {@literal null}. - * @param attribute can be {@literal null}. - */ - public BoundMethodParameter(MethodParameter parameter, Object value, AnnotationAttribute attribute) { - - Assert.notNull(parameter, "MethodParameter must not be null!"); - - this.parameter = parameter; - this.value = value; - this.attribute = attribute; - this.parameterTypeDescriptor = TypeDescriptor.nested(parameter, parameter.isOptional() ? 1 : 0); - } - - /** - * Returns the name of the {@link UriTemplate} variable to be bound. The name will be derived from the configured - * {@link AnnotationAttribute} or the {@link MethodParameter} name as fallback. - * - * @return - */ - public String getVariableName() { - - if (attribute == null) { - return parameter.getParameterName(); - } - - Annotation annotation = parameter.getParameterAnnotation(attribute.getAnnotationType()); - String annotationAttributeValue = attribute.getValueFrom(annotation); - - return StringUtils.hasText(annotationAttributeValue) ? annotationAttributeValue : parameter.getParameterName(); - } - - /** - * Returns the raw value bound to the {@link MethodParameter}. - * - * @return - */ - public Object getValue() { - return value; - } - - /** - * Returns the bound value converted into a {@link String} based on default conversion service setup. - * - * @return - */ - public String asString() { - - return value == null // - ? null // - : (String) CONVERSION_SERVICE.convert(value, parameterTypeDescriptor, STRING_DESCRIPTOR); - } - - /** - * Returns whether the given parameter is a required one. Defaults to {@literal true}. - * - * @return - */ - public boolean isRequired() { - return true; - } - } -} diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java index 6c0c6d09..259b2205 100644 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java @@ -28,7 +28,7 @@ import org.springframework.hateoas.Link; import org.springframework.hateoas.MethodLinkBuilderFactory; import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.hateoas.core.MethodParameters; -import org.springframework.hateoas.core.WebHandler; +import org.springframework.hateoas.WebHandler; /** * Factory for {@link LinkBuilderSupport} instances based on the request mapping annotated on the given controller. diff --git a/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java b/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java index 097ec208..2b481e70 100644 --- a/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java @@ -18,9 +18,10 @@ 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.WebHandler; import org.springframework.hateoas.mvc.ControllerLinkBuilder; -import org.springframework.hateoas.core.WebHandler; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.UriComponentsBuilder; @@ -37,19 +38,18 @@ public class ReactiveLinkBuilder { 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. + * 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))); + .map(context -> linkTo(invocationValue, context.getOrDefault(SERVER_WEB_EXCHANGE, null))); } /** @@ -61,19 +61,9 @@ public class ReactiveLinkBuilder { */ public static ReactiveLinkBuilder linkTo(Object invocationValue, ServerWebExchange exchange) { - ControllerLinkBuilder controllerLinkBuilder = WebHandler.linkTo(invocationValue, path -> { + ControllerLinkBuilder controllerLinkBuilder = WebHandler // + .linkTo(invocationValue, path -> getBuilder(exchange).replacePath(path == null ? "/" : path), null); - UriComponentsBuilder builder = getBuilder(exchange); - - if (path == null) { - builder.replacePath("/"); - } else { - builder.replacePath(path); - } - - return builder; - }, null); - return new ReactiveLinkBuilder(controllerLinkBuilder); } @@ -100,10 +90,8 @@ public class ReactiveLinkBuilder { */ private static UriComponentsBuilder getBuilder(ServerWebExchange exchange) { - if (exchange == null) { - return UriComponentsBuilder.fromPath("/"); - } - - return UriComponentsBuilder.fromHttpRequest(exchange.getRequest()); + return exchange == null // + ? UriComponentsBuilder.fromPath("/") // + : UriComponentsBuilder.fromHttpRequest(exchange.getRequest()); } } diff --git a/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java b/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java index cfbec7a5..97078ecc 100644 --- a/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java +++ b/src/main/java/org/springframework/hateoas/reactive/SimpleReactiveResourceAssembler.java @@ -17,6 +17,7 @@ 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; @@ -25,7 +26,7 @@ import org.springframework.web.server.ServerWebExchange; /** * Reactive variant of {@link ResourceAssembler} combined with {@link SimpleResourceAssembler}. - * + * * @author Greg Turnquist * @since 1.0 */ @@ -60,14 +61,14 @@ public interface SimpleReactiveResourceAssembler extends ReactiveResourceAsse */ 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; - }); + return entities // + .flatMap(entity -> toResource(entity, exchange)) // + .collectList() // + .map(listOfResources -> { + Resources> resources = new Resources<>(listOfResources); + addLinks(resources, exchange); + return resources; + }); } /** @@ -76,5 +77,4 @@ public interface SimpleReactiveResourceAssembler extends ReactiveResourceAsse * @param resources */ void addLinks(Resources> resources, ServerWebExchange exchange); - } diff --git a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java index 2337c8c6..a00074a5 100644 --- a/src/main/java/org/springframework/hateoas/support/PropertyUtils.java +++ b/src/main/java/org/springframework/hateoas/support/PropertyUtils.java @@ -30,6 +30,8 @@ import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import org.springframework.beans.BeanUtils; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotationUtils; @@ -73,6 +75,13 @@ public class PropertyUtils { public static List findPropertyNames(ResolvableType resolvableType) { + if (WebStack.WEBFLUX.isAvailable()) { + if (Mono.class.equals(resolvableType.getRawClass()) || Flux.class.equals(resolvableType.getRawClass())) { + ResolvableType generic = resolvableType.getGeneric(0); + return findPropertyNames(generic); + } + } + if (resolvableType.getRawClass().equals(Resource.class)) { return findPropertyNames(resolvableType.resolveGeneric(0)); } else { diff --git a/src/test/java/org/springframework/hateoas/ArchitectureTest.java b/src/test/java/org/springframework/hateoas/ArchitectureTest.java index 6390b6f2..63ca90f1 100644 --- a/src/test/java/org/springframework/hateoas/ArchitectureTest.java +++ b/src/test/java/org/springframework/hateoas/ArchitectureTest.java @@ -39,7 +39,7 @@ public class ArchitectureTest { public void assertNoCyclicPackageDependencies() { SliceRule rule = SlicesRuleDefinition.slices() // - .matching("org.springframework.(**)..") // + .matching("org.springframework.hateoas.(**)..") // .should().beFreeOfCycles(); rule.check(classes); diff --git a/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonSpecTest.java b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonSpecTest.java index 3a8b22f8..dc630f70 100644 --- a/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonSpecTest.java +++ b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonSpecTest.java @@ -25,14 +25,18 @@ import java.util.List; import org.junit.Before; import org.junit.Test; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.core.io.ClassPathResource; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; +import org.springframework.hateoas.support.Employee; import org.springframework.hateoas.support.MappingUtils; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -51,6 +55,7 @@ public class CollectionJsonSpecTest { public void setUp() { mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); mapper.registerModule(new Jackson2CollectionJsonModule()); mapper.configure(SerializationFeature.INDENT_OUTPUT, true); } @@ -203,9 +208,13 @@ public class CollectionJsonSpecTest { @Test public void specPart7() throws IOException { - String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part7.json", getClass())); + String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part7-adjusted.json", getClass())); - // TODO: Come up with a way to verify this JSON spec can be used to create a new resource. + Resource resource = mapper.readValue(specBasedJson, + mapper.getTypeFactory().constructParametricType(Resource.class, Employee.class)); + + assertThat(resource.getContent()).isEqualTo(new Employee("W. Chandry", "developer")); + assertThat(resource.getLinks()).isEmpty(); } @Data diff --git a/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebFluxIntegrationTest.java b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebFluxIntegrationTest.java new file mode 100644 index 00000000..52ee8343 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebFluxIntegrationTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.collectionjson; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.collection.IsCollectionWithSize.*; +import static org.springframework.hateoas.support.JsonPathUtils.*; +import static org.springframework.hateoas.support.MappingUtils.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.config.reactive.WebClientConfigurer; +import org.springframework.hateoas.support.WebFluxEmployeeController; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class CollectionJsonWebFluxIntegrationTest { + + @Autowired WebTestClient testClient; + + @Before + public void setUp() { + WebFluxEmployeeController.reset(); + } + + /** + * @see #728 + */ + @Test + public void singleEmployee() { + + this.testClient.get().uri("http://localhost/employees/0") + .accept(MediaTypes.COLLECTION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.COLLECTION_JSON) + .expectBody(String.class) + .value(jsonPath("$.collection.version", is("1.0"))) + .value(jsonPath("$.collection.href", is("http://localhost/employees/0"))) + .value(jsonPath("$.collection.links.*", hasSize(1))) + .value(jsonPath("$.collection.links[0].rel", is("employees"))) + .value(jsonPath("$.collection.links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.items.*", hasSize(1))) + .value(jsonPath("$.collection.items[0].data[1].name", is("name"))) + .value(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins"))) + .value(jsonPath("$.collection.items[0].data[0].name", is("role"))) + .value(jsonPath("$.collection.items[0].data[0].value", is("ring bearer"))) + + .value(jsonPath("$.collection.items[0].links.*", hasSize(1))) + .value(jsonPath("$.collection.items[0].links[0].rel", is("employees"))) + .value(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.template.*", hasSize(1))) + .value(jsonPath("$.collection.template.data[0].name", is("name"))) + .value(jsonPath("$.collection.template.data[0].value", is(""))) + .value(jsonPath("$.collection.template.data[1].name", is("role"))) + .value(jsonPath("$.collection.template.data[1].value", is(""))); + } + + /** + * @see #728 + */ + @Test + public void collectionOfEmployees() { + + this.testClient.get().uri("http://localhost/employees") + .accept(MediaTypes.COLLECTION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.COLLECTION_JSON) + .expectBody(String.class) + + .value(jsonPath("$.collection.version", is("1.0"))) + .value(jsonPath("$.collection.href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.items.*", hasSize(2))) + .value(jsonPath("$.collection.items[0].data[1].name", is("name"))) + .value(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins"))) + .value(jsonPath("$.collection.items[0].data[0].name", is("role"))) + .value(jsonPath("$.collection.items[0].data[0].value", is("ring bearer"))) + + .value(jsonPath("$.collection.items[0].links.*", hasSize(1))) + .value(jsonPath("$.collection.items[0].links[0].rel", is("employees"))) + .value(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.items[1].data[1].name", is("name"))) + .value(jsonPath("$.collection.items[1].data[1].value", is("Bilbo Baggins"))) + .value(jsonPath("$.collection.items[1].data[0].name", is("role"))) + .value(jsonPath("$.collection.items[1].data[0].value", is("burglar"))) + + .value(jsonPath("$.collection.items[1].links.*", hasSize(1))) + .value(jsonPath("$.collection.items[1].links[0].rel", is("employees"))) + .value(jsonPath("$.collection.items[1].links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.template.*", hasSize(1))) + .value(jsonPath("$.collection.template.data[0].name", is("name"))) + .value(jsonPath("$.collection.template.data[0].value", is(""))) + .value(jsonPath("$.collection.template.data[1].name", is("role"))) + .value(jsonPath("$.collection.template.data[1].value", is(""))); + } + + /** + * @see #728 + */ + @Test + public void createNewEmployee() throws Exception { + + String specBasedJson = read(new ClassPathResource("spec-part7-adjusted.json", getClass())); + + this.testClient.post().uri("http://localhost/employees") + .contentType(MediaTypes.COLLECTION_JSON) + .syncBody(specBasedJson) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2"); + + this.testClient.get().uri("http://localhost/employees/2") + .accept(MediaTypes.COLLECTION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.COLLECTION_JSON) + .expectBody(String.class) + + .value(jsonPath("$.collection.version", is("1.0"))) + .value(jsonPath("$.collection.href", is("http://localhost/employees/2"))) + + .value(jsonPath("$.collection.links.*", hasSize(1))) + .value(jsonPath("$.collection.links[0].rel", is("employees"))) + .value(jsonPath("$.collection.links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.items.*", hasSize(1))) + .value(jsonPath("$.collection.items[0].data[1].name", is("name"))) + .value(jsonPath("$.collection.items[0].data[1].value", is("W. Chandry"))) + .value(jsonPath("$.collection.items[0].data[0].name", is("role"))) + .value(jsonPath("$.collection.items[0].data[0].value", is("developer"))) + + .value(jsonPath("$.collection.items[0].links.*", hasSize(1))) + .value(jsonPath("$.collection.items[0].links[0].rel", is("employees"))) + .value(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees"))) + + .value(jsonPath("$.collection.template.*", hasSize(1))) + .value(jsonPath("$.collection.template.data[0].name", is("name"))) + .value(jsonPath("$.collection.template.data[0].value", is(""))) + .value(jsonPath("$.collection.template.data[1].name", is("role"))) + .value(jsonPath("$.collection.template.data[1].value", is(""))); + } + + @Configuration + @EnableWebFlux + @EnableHypermediaSupport(type = { HypermediaType.COLLECTION_JSON }) + static class TestConfig { + + @Bean + WebFluxEmployeeController employeeController() { + return new WebFluxEmployeeController(); + } + + @Bean + WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) { + + return WebTestClient.bindToApplicationContext(ctx).build() + .mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) + .build(); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebMvcIntegrationTest.java index 6f14e232..e7901c21 100644 --- a/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/collectionjson/CollectionJsonWebMvcIntegrationTest.java @@ -25,10 +25,10 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.TreeMap; import org.junit.Before; import org.junit.Test; @@ -46,6 +46,7 @@ import org.springframework.hateoas.config.EnableHypermediaSupport; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; import org.springframework.hateoas.support.Employee; import org.springframework.hateoas.support.MappingUtils; +import org.springframework.hateoas.support.WebMvcEmployeeController; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; @@ -75,17 +76,11 @@ public class CollectionJsonWebMvcIntegrationTest { MockMvc mockMvc; - private static Map EMPLOYEES; - @Before public void setUp() { this.mockMvc = webAppContextSetup(this.context).build(); - - EMPLOYEES = new TreeMap<>(); - - EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); - EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + WebMvcEmployeeController.reset(); } @Test @@ -196,6 +191,8 @@ public class CollectionJsonWebMvcIntegrationTest { @RestController static class EmployeeController { + private static Map EMPLOYEES = new HashMap<>(); + @GetMapping("/employees") public Resources> all() { @@ -334,8 +331,8 @@ public class CollectionJsonWebMvcIntegrationTest { static class TestConfig { @Bean - EmployeeController employeeController() { - return new EmployeeController(); + WebMvcEmployeeController employeeController() { + return new WebMvcEmployeeController(); } } } diff --git a/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java index 1d65f3cb..ac1d7954 100644 --- a/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java +++ b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebClientBeanPostProcessorTest.java @@ -18,13 +18,14 @@ package org.springframework.hateoas.config.reactive; import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.support.ContextTester.*; +import reactor.test.StepVerifier; + 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; @@ -52,7 +53,7 @@ public class HypermediaWebClientBeanPostProcessorTest { public void setUp() { this.server = new Server(); - + Resource actor = new Resource<>(new Actor("Keanu Reaves")); String actorUri = this.server.mockResourceFor(actor); @@ -67,7 +68,7 @@ public class HypermediaWebClientBeanPostProcessorTest { } @After - public void tearDown() throws IOException { + public void tearDown() { if (this.server != null) { this.server.close(); @@ -81,18 +82,17 @@ public class HypermediaWebClientBeanPostProcessorTest { 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; + 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(); + }).verifyComplete(); }); } @@ -105,23 +105,23 @@ public class HypermediaWebClientBeanPostProcessorTest { 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(); + webClient // + .get().uri(this.baseUri) // + .retrieve() // + .bodyToMono(ResourceSupport.class) // + .map(resourceSupport -> resourceSupport.getRequiredLink("actors")) // + .flatMap(link -> webClient // + .get().uri(link.expand().getHref()) // + .retrieve() // + .bodyToMono(ResourceSupport.class)) // + .map(resourceSupport -> resourceSupport.getLinks().toList().get(0)) // + .flatMap(link -> webClient // + .get().uri(link.expand().getHref()) // + .retrieve() // + .bodyToMono(typeReference)) // + .as(StepVerifier::create) // + .expectNext(new Resource<>(new Actor("Keanu Reaves"))) // + .verifyComplete(); }); } @@ -134,4 +134,4 @@ public class HypermediaWebClientBeanPostProcessorTest { 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 index 82f59442..189b84d8 100644 --- a/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/reactive/HypermediaWebFluxConfigurerTest.java @@ -15,22 +15,24 @@ */ package org.springframework.hateoas.config.reactive; -import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 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 java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.Test; 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.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.Resource; @@ -69,12 +71,13 @@ public class HypermediaWebFluxConfigurerTest { WebClientConfigurer webClientConfigurer = ctx.getBean(WebClientConfigurer.class); - this.testClient = WebTestClient.bindToApplicationContext(ctx).build() - .mutate() - .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) - .build(); + this.testClient = WebTestClient.bindToApplicationContext(ctx).build().mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()).build(); } + /** + * @see #728 + */ @Test public void registeringHalShouldServeHal() { @@ -83,8 +86,14 @@ public class HypermediaWebFluxConfigurerTest { verifyRootUriServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); } + /** + * @see #728 + */ @Test public void registeringHalFormsShouldServeHalForms() { @@ -93,8 +102,14 @@ public class HypermediaWebFluxConfigurerTest { verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_FORMS_JSON); } + /** + * @see #728 + */ @Test public void registeringCollectionJsonShouldServerCollectionJson() { @@ -103,8 +118,14 @@ public class HypermediaWebFluxConfigurerTest { verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.COLLECTION_JSON); } + /** + * @see #728 + */ @Test public void registeringUberShouldServerUber() { @@ -113,8 +134,14 @@ public class HypermediaWebFluxConfigurerTest { verifyRootUriServesHypermedia(MediaTypes.UBER_JSON); verifyAggregateRootServesHypermedia(MediaTypes.UBER_JSON); verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.UBER_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.UBER_JSON); } + /** + * @see #728 + */ @Test public void registeringHalAndHalFormsShouldServerHalAndHalForms() { @@ -129,6 +156,24 @@ public class HypermediaWebFluxConfigurerTest { verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); } + /** + * @see #728 + */ + @Test + public void registeringHalAndHalFormsShouldAllowCreatingViaHalAndHalForms() { + + setUp(AllHalWebFluxConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_FORMS_JSON); + } + + /** + * @see #728 + */ @Test public void registeringHalAndCollectionJsonShouldServerHalAndCollectionJson() { @@ -147,6 +192,27 @@ public class HypermediaWebFluxConfigurerTest { verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); } + /** + * @see #728 + */ + @Test + public void registeringHalAndCollectionJsonShouldAllowCreatingViaHalAndCollectionJson() { + + setUp(HalAndCollectionJsonWebFluxConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_FORMS_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.COLLECTION_JSON); + } + + /** + * @see #728 + */ @Test public void registeringAllHypermediaTypesShouldServerThemAll() { @@ -169,90 +235,87 @@ public class HypermediaWebFluxConfigurerTest { verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); } + /** + * @see #728 + */ + @Test + public void registeringAllHypermediaTypesShouldAllowCreatingThroughAllFormats() { + + setUp(AllHypermediaTypesWebFluxConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_JSON, MediaTypes.HAL_JSON_UTF8); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.HAL_FORMS_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.COLLECTION_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.UBER_JSON); + verifyCreatingNewEntityReactivelyShouldWork(MediaTypes.UBER_JSON); + } + + /** + * @see #728 + */ @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(); + this.testClient.get().uri("/").accept(MediaTypes.UBER_JSON).exchange().expectStatus().is4xxClientError() + .returnResult(String.class).getResponseBody().as(StepVerifier::create).verifyComplete(); } + /** + * @see #728 + */ @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 -> { + 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(); + assertThat(resourceSupport.getLinks()).containsExactlyInAnyOrder(new Link("/", IanaLinkRelations.SELF), + new Link("/employees", "employees")); - 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 -> { + return true; + }).verifyComplete(); - assertThat(resources.getContent()).extracting("content").containsExactlyInAnyOrder( - new Employee("Frodo Baggins", "ring bearer")); + 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("links").containsExactlyInAnyOrder( - Arrays.asList( - new Link("/employees/1", IanaLinkRelation.SELF.value()), - new Link("/employees", "employees"))); + assertThat(resources.getLinks()).containsExactlyInAnyOrder(new Link("/employees", IanaLinkRelations.SELF)); - assertThat(resources.getLinks()).containsExactlyInAnyOrder( - new Link("/employees", IanaLinkRelation.SELF.value())); + Resource content = resources.getContent().iterator().next(); - return true; - }) - .verifyComplete(); + assertThat(content.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(content.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); - 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); + return true; + }).verifyComplete(); - 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(); + 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 -> { + + assertThat(employee.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(employee.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); + return true; + }).verifyComplete(); } private void verifyRootUriServesHypermedia(MediaType mediaType) { @@ -261,23 +324,17 @@ public class HypermediaWebFluxConfigurerTest { 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 -> { + 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")); + assertThat(resourceSupport.getLinks()) // + .containsExactlyInAnyOrder(new Link("/", IanaLinkRelations.SELF), new Link("/employees", "employees")); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } private void verifyAggregateRootServesHypermedia(MediaType mediaType) { @@ -286,35 +343,24 @@ public class HypermediaWebFluxConfigurerTest { 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 -> { + 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.getLinks()).containsExactlyInAnyOrder(new Link("/employees", IanaLinkRelations.SELF)); - assertThat(resources.getContent()).extracting("content").containsExactlyInAnyOrder( - new Employee("Frodo Baggins", "ring bearer") - ); + Collection> content = resources.getContent(); + assertThat(content).hasSize(1); - assertThat(resources.getContent()).extracting("links").containsExactlyInAnyOrder( - Arrays.asList( - new Link("/employees/1", IanaLinkRelation.SELF.value()), - new Link("/employees", "employees")) - ); + Resource resource = content.iterator().next(); - assertThat(resources.getLinks()).containsExactlyInAnyOrder( - new Link("/employees", IanaLinkRelation.SELF.value()) - ); + assertThat(resource.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(resource.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } private void verifySingleItemResourceServesHypermedia(MediaType mediaType) { @@ -323,28 +369,56 @@ public class HypermediaWebFluxConfigurerTest { 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 -> { + 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.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(employeeResource.getLinks()).containsExactlyInAnyOrder( + new Link("/employees/1", IanaLinkRelations.SELF), new Link("/employees", "employees")); - assertThat(employeeResource.getLinks()).containsExactlyInAnyOrder( - new Link("/employees/1", IanaLinkRelation.SELF.value()), - new Link("/employees", "employees")); - - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } - + + private void verifyCreatingNewEntityWorks(MediaType mediaType) { + verifyCreatingNewEntityWorks(mediaType, mediaType); + } + + private void verifyCreatingNewEntityWorks(MediaType contentType, MediaType responseType) { + verifyCreation("/employees", contentType, responseType); + } + + private void verifyCreatingNewEntityReactivelyShouldWork(MediaType contentType) { + verifyCreatingNewEntityReactivelyShouldWork(contentType, contentType); + } + + private void verifyCreatingNewEntityReactivelyShouldWork(MediaType contentType, MediaType responseType) { + verifyCreation("/reactive/employees", contentType, responseType); + } + + private void verifyCreation(String uri, MediaType contentType, MediaType responseType) { + + this.testClient.post().uri(uri) // + .accept(contentType).contentType(contentType) + .body(Mono.just(new Employee("Samwise Gamgee", "gardener")), Employee.class) // + .exchange() // + .expectStatus().isOk() // + .expectHeader().contentType(responseType).returnResult(this.resourceEmployeeType) // + .getResponseBody().as(StepVerifier::create).expectNextMatches(resource -> { + + assertThat(resource.getContent()).isEqualTo(new Employee("Samwise Gamgee", "gardener")); + assertThat(resource.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); + + return true; + }).verifyComplete(); + } + @Configuration @EnableWebFlux static abstract class BaseConfig { @@ -356,32 +430,25 @@ public class HypermediaWebFluxConfigurerTest { } @EnableHypermediaSupport(type = HAL) - static class HalWebFluxConfig extends BaseConfig { - } + static class HalWebFluxConfig extends BaseConfig {} @EnableHypermediaSupport(type = HAL_FORMS) - static class HalFormsWebFluxConfig extends BaseConfig { - } + static class HalFormsWebFluxConfig extends BaseConfig {} @EnableHypermediaSupport(type = COLLECTION_JSON) - static class CollectionJsonWebFluxConfig extends BaseConfig { - } + static class CollectionJsonWebFluxConfig extends BaseConfig {} @EnableHypermediaSupport(type = UBER) - static class UberWebFluxConfig extends BaseConfig { - } + static class UberWebFluxConfig extends BaseConfig {} - @EnableHypermediaSupport(type = {HAL, HAL_FORMS}) - static class AllHalWebFluxConfig 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 }) + static class HalAndCollectionJsonWebFluxConfig extends BaseConfig {} - @EnableHypermediaSupport(type = {HAL, HAL_FORMS, COLLECTION_JSON, UBER}) - static class AllHypermediaTypesWebFluxConfig extends BaseConfig { - } + @EnableHypermediaSupport(type = { HAL, HAL_FORMS, COLLECTION_JSON, UBER }) + static class AllHypermediaTypesWebFluxConfig extends BaseConfig {} @RestController static class TestController { @@ -390,14 +457,14 @@ public class HypermediaWebFluxConfigurerTest { 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()); @@ -418,7 +485,7 @@ public class HypermediaWebFluxConfigurerTest { return this.assembler.toResource(newEmployee); } - + @GetMapping("/employees/{id}") Resource employee(@PathVariable String id) { return this.assembler.toResource(this.employees.get(0)); @@ -440,15 +507,24 @@ public class HypermediaWebFluxConfigurerTest { @GetMapping("/reactive/employees") Mono>> reactiveEmployees() { - return findAll() - .collectList() - .map(employees -> this.assembler.toResources(employees)); + return findAll() // + .collectList() // + .map(assembler::toResources); + } + + @PostMapping("/reactive/employees") + Mono> createReactiveEmployee(@RequestBody Mono newEmployee) { + + return newEmployee.map(employee -> { + employees.add(employee); + return employee; + }).map(assembler::toResource); } @GetMapping("/reactive/employees/{id}") Mono> reactiveEmployee(@PathVariable String id) { - return findById(0) - .map(this.assembler::toResource); + return findById(0) // + .map(assembler::toResource); } Mono findById(int id) { @@ -474,4 +550,5 @@ public class HypermediaWebFluxConfigurerTest { resources.add(new Link("/employees").withSelfRel()); } } -} \ No newline at end of file + +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebFluxIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebFluxIntegrationTest.java new file mode 100644 index 00000000..1fc7df5c --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebFluxIntegrationTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.hal.forms; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.collection.IsCollectionWithSize.*; +import static org.springframework.hateoas.support.JsonPathUtils.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.config.reactive.WebClientConfigurer; +import org.springframework.hateoas.support.MappingUtils; +import org.springframework.hateoas.support.WebFluxEmployeeController; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class HalFormsWebFluxIntegrationTest { + + @Autowired WebTestClient testClient; + + @Before + public void setUp() { + WebFluxEmployeeController.reset(); + } + + /** + * @see #728 + */ + @Test + public void singleEmployee() { + + this.testClient.get().uri("http://localhost/employees/0") + .accept(MediaTypes.HAL_FORMS_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_FORMS_JSON) + .expectBody(String.class) + .value(jsonPath("$.name", is("Frodo Baggins"))) + .value(jsonPath("$.role", is("ring bearer"))) + + .value(jsonPath("$._links.*", hasSize(2))) + .value(jsonPath("$._links['self'].href", is("http://localhost/employees/0"))) + .value(jsonPath("$._links['employees'].href", is("http://localhost/employees"))) + + .value(jsonPath("$._templates.*", hasSize(2))) + .value(jsonPath("$._templates['default'].method", is("put"))) + .value(jsonPath("$._templates['default'].properties[0].name", is("name"))) + .value(jsonPath("$._templates['default'].properties[0].required", is(true))) + .value(jsonPath("$._templates['default'].properties[1].name", is("role"))) + .value(jsonPath("$._templates['default'].properties[1].required", is(true))) + + .value(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch"))) + .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name"))) + .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false))) + .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role"))) + .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false))); + } + + /** + * @see #728 + */ + @Test + public void collectionOfEmployees() { + + this.testClient.get().uri("http://localhost/employees") + .accept(MediaTypes.HAL_FORMS_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.HAL_FORMS_JSON) + .expectBody(String.class) + .value(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins"))) + .value(jsonPath("$._embedded.employees[0].role", is("ring bearer"))) + .value(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0"))) + .value(jsonPath("$._embedded.employees[1].name", is("Bilbo Baggins"))) + .value(jsonPath("$._embedded.employees[1].role", is("burglar"))) + .value(jsonPath("$._embedded.employees[1]._links['self'].href", is("http://localhost/employees/1"))) + + .value(jsonPath("$._links.*", hasSize(1))) + .value(jsonPath("$._links['self'].href", is("http://localhost/employees"))) + + .value(jsonPath("$._templates.*", hasSize(1))) + .value(jsonPath("$._templates['default'].method", is("post"))) + .value(jsonPath("$._templates['default'].properties[0].name", is("name"))) + .value(jsonPath("$._templates['default'].properties[0].required", is(true))) + .value(jsonPath("$._templates['default'].properties[1].name", is("role"))) + .value(jsonPath("$._templates['default'].properties[1].required", is(true))); + } + + /** + * @see #728 + */ + @Test + public void createNewEmployee() throws Exception { + + String specBasedJson = MappingUtils.read(new ClassPathResource("new-employee.json", getClass())); + + this.testClient.post().uri("http://localhost/employees") + .contentType(MediaTypes.HAL_FORMS_JSON) + .syncBody(specBasedJson) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2"); + } + + @Configuration + @EnableWebFlux + @EnableHypermediaSupport(type = { HypermediaType.HAL_FORMS }) + static class TestConfig { + + @Bean + WebFluxEmployeeController employeeController() { + return new WebFluxEmployeeController(); + } + + @Bean + WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) { + + return WebTestClient.bindToApplicationContext(ctx).build() + .mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) + .build(); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcIntegrationTest.java index 75b72d92..01db57fa 100644 --- a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcIntegrationTest.java @@ -17,18 +17,10 @@ package org.springframework.hateoas.hal.forms; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.collection.IsCollectionWithSize.*; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -39,25 +31,15 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; -import org.springframework.hateoas.Resource; -import org.springframework.hateoas.Resources; import org.springframework.hateoas.config.EnableHypermediaSupport; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; -import org.springframework.hateoas.support.Employee; import org.springframework.hateoas.support.MappingUtils; +import org.springframework.hateoas.support.WebMvcEmployeeController; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -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.context.WebApplicationContext; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @@ -75,7 +57,9 @@ public class HalFormsWebMvcIntegrationTest { @Before public void setUp() { + this.mockMvc = webAppContextSetup(this.context).build(); + WebMvcEmployeeController.reset(); } @Test @@ -138,117 +122,14 @@ public class HalFormsWebMvcIntegrationTest { .andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2")); } - @RestController - static class EmployeeController { - - private final static Map EMPLOYEES = new TreeMap<>(); - - static { - EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); - EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); - } - - @GetMapping("/employees") - public Resources> all() { - - // Create a list of Resource's to return - List> employees = new ArrayList<>(); - - // Fetch each Resource using the controller's findOne method. - for (int i = 0; i < EMPLOYEES.size(); i++) { - employees.add(findOne(i)); - } - - // Generate an "Affordance" based on this method (the "self" link) - Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel() // - .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))); - - // Return the collection of employee resources along with the composite affordance - return new Resources<>(employees, selfLink); - } - - @GetMapping("/employees/{id}") - public Resource findOne(@PathVariable Integer id) { - - // Start the affordance with the "self" link, i.e. this method. - Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel(); - - // Define final link as means to find entire collection. - Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees"); - - // Return the affordance + a link back to the entire collection resource. - return new Resource<>(EMPLOYEES.get(id), // - findOneLink.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) // - .andAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))), - employeesLink); - } - - @PostMapping("/employees") - public ResponseEntity newEmployee(@RequestBody Employee employee) { - - int newEmployeeId = EMPLOYEES.size(); - - EMPLOYEES.put(newEmployeeId, employee); - - try { - return ResponseEntity.created(toUri(newEmployeeId)).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PutMapping("/employees/{id}") - public ResponseEntity updateEmployee(@RequestBody Employee employee, @PathVariable Integer id) { - - EMPLOYEES.put(id, employee); - - try { - return ResponseEntity.noContent().location(toUri(id)).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PatchMapping("/employees/{id}") - public ResponseEntity partiallyUpdateEmployee(@RequestBody Employee employee, @PathVariable Integer id) { - - Employee newEmployee = EMPLOYEES.get(id); - - if (employee.getName() != null) { - newEmployee = newEmployee.withName(employee.getName()); - } - - if (employee.getRole() != null) { - newEmployee = newEmployee.withRole(employee.getRole()); - } - - EMPLOYEES.put(id, newEmployee); - - try { - return ResponseEntity.noContent().location(toUri(id)).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - private URI toUri(Integer id) throws URISyntaxException { - - String uri = findOne(id).getLink(IanaLinkRelations.SELF.value()) // - .map(link -> link.expand().getHref()) // - .orElse(""); - - return new URI(uri); - } - } - @Configuration @EnableWebMvc @EnableHypermediaSupport(type = { HypermediaType.HAL_FORMS }) static class TestConfig { @Bean - EmployeeController employeeController() { - return new EmployeeController(); + WebMvcEmployeeController employeeController() { + return new WebMvcEmployeeController(); } } } diff --git a/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java index 9fa75739..0e8fc43b 100644 --- a/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java +++ b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java @@ -20,14 +20,15 @@ import static org.springframework.hateoas.config.EnableHypermediaSupport.Hyperme 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.junit.Before; +import org.junit.Test; 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.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.ResourceSupport; @@ -45,7 +46,7 @@ import org.springframework.web.reactive.config.EnableWebFlux; public class HypermediaWebFilterTest { WebTestClient testClient; - + @Before public void setUp() { @@ -55,32 +56,28 @@ public class HypermediaWebFilterTest { WebClientConfigurer webClientConfigurer = ctx.getBean(WebClientConfigurer.class); - this.testClient = WebTestClient.bindToApplicationContext(ctx).build() - .mutate() - .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) - .build(); + 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 -> { + 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(); + assertThat(resourceSupport.getLinks())// + .containsExactly(new Link("http://example.com/api", IanaLinkRelations.SELF)); + return true; + }).verifyComplete(); } @RestController @@ -90,9 +87,8 @@ public class HypermediaWebFilterTest { @GetMapping Mono root() { - return linkTo(methodOn(TestController.class).root()) - .map(ReactiveLinkBuilder::withSelfRel) - .map(ResourceSupport::new); + return linkTo(methodOn(TestController.class).root()).map(ReactiveLinkBuilder::withSelfRel) + .map(ResourceSupport::new); } } diff --git a/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java b/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java index 894c0829..aadeb777 100644 --- a/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java +++ b/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java @@ -21,6 +21,10 @@ import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.linkTo; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + import java.net.URI; import java.net.URISyntaxException; @@ -28,10 +32,7 @@ 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.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -59,7 +60,7 @@ public class ReactiveLinkBuilderTest { Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); - assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); } @@ -71,18 +72,15 @@ public class ReactiveLinkBuilderTest { 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 -> { + 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(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + + return true; + }).verifyComplete(); } @Test @@ -95,7 +93,7 @@ public class ReactiveLinkBuilderTest { Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); - assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); } @@ -107,31 +105,28 @@ public class ReactiveLinkBuilderTest { 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 -> { + 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"); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); - return true; - }) - .verifyComplete(); + 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.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); } @@ -143,18 +138,15 @@ public class ReactiveLinkBuilderTest { 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 -> { + 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(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); + + return true; + }).verifyComplete(); } @Test @@ -165,34 +157,30 @@ public class ReactiveLinkBuilderTest { 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 -> { + 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/"); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/"); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } @Test public void linkToRouteWithNoExchangeInTheContextShouldFallbackToRelativeUris() throws URISyntaxException { - linkTo(methodOn(TestController2.class).root()) - .map(ReactiveLinkBuilder::withSelfRel) - .as(StepVerifier::create) - .expectNextMatches(link -> { + linkTo(methodOn(TestController2.class).root())// + .map(ReactiveLinkBuilder::withSelfRel) // + .as(StepVerifier::create) // + .expectNextMatches(link -> { - assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); - assertThat(link.getHref()).isEqualTo("/"); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("/"); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } @Test @@ -200,11 +188,10 @@ public class ReactiveLinkBuilderTest { Link link = linkTo(methodOn(TestController2.class).root(), null).withSelfRel(); - assertThat(link.getRel()).isEqualTo(IanaLinkRelation.SELF.value()); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("/"); } - @RestController @RequestMapping("/api") static class TestController { diff --git a/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java b/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java index 3b651248..90354258 100644 --- a/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java +++ b/src/test/java/org/springframework/hateoas/reactive/ReactiveResourceAssemblerUnitTest.java @@ -15,20 +15,21 @@ */ package org.springframework.hateoas.reactive; -import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.EqualsAndHashCode; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; -import java.util.Collections; +import java.util.Collection; 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; @@ -44,7 +45,7 @@ public class ReactiveResourceAssemblerUnitTest { TestAssemblerWithCustomResources assemblerWithCustomResources; Employee employee; - + ServerWebExchange exchange; @Before @@ -59,59 +60,53 @@ public class ReactiveResourceAssemblerUnitTest { @Test public void simpleConversionShouldWork() { - this.assembler.toResource(this.employee, this.exchange) - .as(StepVerifier::create) - .expectNextMatches(employeeResource -> { + 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")); + assertThat(employeeResource.getEmployee()).isEqualTo(new Employee("Frodo Baggins")); + AssertionsForInterfaceTypes.assertThat(employeeResource.getLinks()) + .containsExactlyInAnyOrder(new Link("/employees", "employees")); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } + @Test public void defaultResourcesConversionShouldWork() { - this.assembler.toResources(Flux.just(this.employee), this.exchange) - .as(StepVerifier::create) - .expectNextMatches(employeeResources -> { + this.assembler.toResources(Flux.just(this.employee), this.exchange).as(StepVerifier::create) + .expectNextMatches(employeeResources -> { - assertThat(employeeResources.getContent()).extracting("employee").containsExactly( - new Employee("Frodo Baggins")); + Collection content = employeeResources.getContent(); - assertThat(employeeResources.getContent()).extracting("links").containsExactly( - Collections.singletonList(new Link("/employees", "employees"))); + assertThat(content) // + .extracting("employee") // + .containsExactly(new Employee("Frodo Baggins")); - assertThat(employeeResources.getLinks()).isEmpty(); + assertThat(content.iterator().next().getLinks()).containsExactly(new Link("/employees", "employees")); + assertThat(employeeResources.getLinks()).isEmpty(); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } @Test public void customResourcesShouldWork() { - this.assemblerWithCustomResources.toResources(Flux.just(this.employee), this.exchange) - .as(StepVerifier::create) - .expectNextMatches(employeeResources -> { + 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")); + assertThat(employeeResources.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees").withSelfRel(), new Link("/", "root")); - AssertionsForInterfaceTypes.assertThat(employeeResources.getContent()).extracting("links").containsExactly( - Collections.singletonList(new Link("/employees", "employees"))); + EmployeeResource content = employeeResources.getContent().iterator().next(); - AssertionsForInterfaceTypes.assertThat(employeeResources.getLinks()).containsExactlyInAnyOrder( - new Link("/employees").withSelfRel(), - new Link("/", "root") - ); + assertThat(content.getEmployee()).isEqualTo(new Employee("Frodo Baggins")); + assertThat(content.getLinks()).containsExactly(new Link("/employees", "employees")); - return true; - }) - .verifyComplete(); + return true; + }).verifyComplete(); } class TestAssembler implements ReactiveResourceAssembler { @@ -121,7 +116,7 @@ public class ReactiveResourceAssemblerUnitTest { EmployeeResource employeeResource = new EmployeeResource(entity); employeeResource.add(new Link("/employees", "employees")); - + return Mono.just(employeeResource); } } @@ -129,20 +124,18 @@ public class ReactiveResourceAssemblerUnitTest { class TestAssemblerWithCustomResources extends TestAssembler { @Override - public Mono> toResources(Flux entities, ServerWebExchange exchange) { + public Mono> toResources(Flux entities, + ServerWebExchange exchange) { - return entities - .flatMap(entity -> toResource(entity, exchange)) - .collectList() - .map(listOfResources -> { + 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")); + Resources employeeResources = new Resources<>(listOfResources); - return employeeResources; - }); + employeeResources.add(new Link("/employees").withSelfRel()); + employeeResources.add(new Link("/", "root")); + + return employeeResources; + }); } } @@ -153,6 +146,7 @@ public class ReactiveResourceAssemblerUnitTest { } @Data + @EqualsAndHashCode(callSuper = true) @AllArgsConstructor class EmployeeResource extends ResourceSupport { private Employee employee; diff --git a/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java b/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java new file mode 100644 index 00000000..04a6b0ca --- /dev/null +++ b/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java @@ -0,0 +1,47 @@ +/* + * 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.assertj.core.matcher.AssertionMatcher; +import org.hamcrest.Matcher; +import org.springframework.lang.Nullable; +import org.springframework.test.util.JsonPathExpectationsHelper; + +/** + * @author Greg Turnquist + */ +public class JsonPathUtils { + + public static AssertionMatcher jsonPath(String expression, Matcher matcher) { + + return new AssertionMatcher() { + @Override + public void assertion(String actual) throws AssertionError { + new JsonPathExpectationsHelper(expression).assertValue(actual, matcher); + } + }; + } + + public static AssertionMatcher jsonPath(String expression, @Nullable Object expectedValue) { + + return new AssertionMatcher() { + @Override + public void assertion(String actual) throws AssertionError { + new JsonPathExpectationsHelper(expression).assertValue(actual, expectedValue); + } + }; + } +} diff --git a/src/test/java/org/springframework/hateoas/support/MappingUtils.java b/src/test/java/org/springframework/hateoas/support/MappingUtils.java index 9f042e48..ae9ce963 100644 --- a/src/test/java/org/springframework/hateoas/support/MappingUtils.java +++ b/src/test/java/org/springframework/hateoas/support/MappingUtils.java @@ -50,4 +50,4 @@ public final class MappingUtils { return builder.toString(); } } -} \ No newline at end of file +} diff --git a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java new file mode 100644 index 00000000..e3c707be --- /dev/null +++ b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java @@ -0,0 +1,171 @@ +/* + * 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 static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.linkTo; +import static reactor.function.TupleUtils.*; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.reactive.ReactiveLinkBuilder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Greg Turnquist + */ +@RestController +public class WebFluxEmployeeController { + + private static Map EMPLOYEES; + + public static void reset() { + + EMPLOYEES = new TreeMap<>(); + + EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); + EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + } + + @GetMapping("/employees") + public Mono>> all() { + + return Flux.fromIterable(EMPLOYEES.keySet()).flatMap(id -> findOne(id)).collectList().flatMap( + resources -> linkTo(methodOn(WebFluxEmployeeController.class).all()).map(ReactiveLinkBuilder::withSelfRel) + .map(link -> link.andAffordance(afford(methodOn(WebFluxEmployeeController.class).newEmployee(null))) + .andAffordance(afford(methodOn(WebFluxEmployeeController.class).search(null, null)))) + .map(selfLink -> new Resources<>(resources, selfLink))); + } + + @GetMapping("/employees/search") + public Mono>> search(@RequestParam(value = "name", required = false) String name, + @RequestParam(value = "role", required = false) String role) { + + return Flux.fromIterable(EMPLOYEES.keySet()).flatMap(id -> findOne(id)).filter(resource -> { + + boolean nameMatches = Optional.ofNullable(name).map(s -> resource.getContent().getName().contains(s)) + .orElse(true); + + boolean roleMatches = Optional.ofNullable(role).map(s -> resource.getContent().getRole().contains(s)) + .orElse(true); + + return nameMatches && roleMatches; + }).collectList().flatMap( + resources -> linkTo(methodOn(WebFluxEmployeeController.class).all()).map(ReactiveLinkBuilder::withSelfRel) + .map(link -> link.andAffordance(afford(methodOn(WebFluxEmployeeController.class).newEmployee(null))) + .andAffordance(afford(methodOn(WebFluxEmployeeController.class).search(null, null)))) + .map(selfLink -> new Resources<>(resources, selfLink))); + } + + @GetMapping("/employees/{id}") + public Mono> findOne(@PathVariable Integer id) { + + return linkTo(methodOn(WebFluxEmployeeController.class).findOne(id)) // + .map(ReactiveLinkBuilder::withSelfRel) // + .zipWith(linkTo(methodOn(WebFluxEmployeeController.class).all()) // + .map(reactiveLinkBuilder -> reactiveLinkBuilder.withRel("employees"))) + .map(function((selfLink, employeesLink) -> new Resource<>(EMPLOYEES.get(id), + selfLink.andAffordance(afford(methodOn(WebFluxEmployeeController.class).updateEmployee(null, id))) // + .andAffordance(afford(methodOn(WebFluxEmployeeController.class).partiallyUpdateEmployee(null, id))), + employeesLink))); + } + + @PostMapping("/employees") + public Mono> newEmployee(@RequestBody Mono> employee) { + + return employee.flatMap(resource -> { + + int newEmployeeId = EMPLOYEES.size(); + EMPLOYEES.put(newEmployeeId, resource.getContent()); + return findOne(newEmployeeId); + + }).map(findOne -> { + + return ResponseEntity.created(URI.create(findOne // + .getLink(IanaLinkRelations.SELF) // + .map(link -> link.expand().getHref()) // + .orElse(""))) // + .build(); + }); + } + + @PutMapping("/employees/{id}") + public Mono> updateEmployee(@RequestBody Mono> employee, + @PathVariable Integer id) { + + return employee.flatMap(resource -> { + EMPLOYEES.put(id, resource.getContent()); + return findOne(id); + }).map(findOne -> { + + return ResponseEntity.noContent() // + .location(URI.create(findOne // + .getLink(IanaLinkRelations.SELF) // + .map(link -> link.expand().getHref()) // + .orElse(""))) // + .build(); + }); + } + + @PatchMapping("/employees/{id}") + public Mono> partiallyUpdateEmployee( // + @RequestBody Mono> employee, @PathVariable Integer id) { + + return employee.flatMap(resource -> { + + Employee oldEmployee = EMPLOYEES.get(id); + Employee newEmployee = oldEmployee; + + if (resource.getContent().getName() != null) { + newEmployee = newEmployee.withName(resource.getContent().getName()); + } + + if (resource.getContent().getRole() != null) { + newEmployee = newEmployee.withRole(resource.getContent().getRole()); + } + + EMPLOYEES.put(id, newEmployee); + + return findOne(id); + + }).map(findOne -> { + + return ResponseEntity.noContent() // + .location(URI.create(findOne // + .getLink(IanaLinkRelations.SELF) // + .map(link -> link.expand().getHref()) // + .orElse(""))) // + .build(); + }); + } +} diff --git a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java new file mode 100644 index 00000000..22520f50 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java @@ -0,0 +1,184 @@ +/* + * 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 static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Greg Turnquist + */ +@RestController +public class WebMvcEmployeeController { + + private static Map EMPLOYEES; + + public static void reset() { + + EMPLOYEES = new TreeMap<>(); + + EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); + EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + } + + @GetMapping("/employees") + public Resources> all() { + + // Create a list of Resource's to return + List> employees = new ArrayList<>(); + + // Fetch each Resource using the controller's findOne method. + for (int i = 0; i < EMPLOYEES.size(); i++) { + employees.add(findOne(i)); + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(WebMvcEmployeeController.class).all()).withSelfRel() // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).newEmployee(null))) // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).search(null, null))); + + // Return the collection of employee resources along with the composite affordance + return new Resources<>(employees, selfLink); + } + + @GetMapping("/employees/search") + public Resources> search(@RequestParam(value = "name", required = false) String name, + @RequestParam(value = "role", required = false) String role) { + + // Create a list of Resource's to return + List> employees = new ArrayList<>(); + + // Fetch each Resource using the controller's findOne method. + for (int i = 0; i < EMPLOYEES.size(); i++) { + + Resource employeeResource = findOne(i); + + boolean nameMatches = Optional.ofNullable(name) // + .map(s -> employeeResource.getContent().getName().contains(s)) // + .orElse(true); + + boolean roleMatches = Optional.ofNullable(role) // + .map(s -> employeeResource.getContent().getRole().contains(s)) // + .orElse(true); + + if (nameMatches && roleMatches) { + employees.add(employeeResource); + } + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(WebMvcEmployeeController.class).all()) // + .withSelfRel() // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).newEmployee(null))) // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).search(null, null))); + + // Return the collection of employee resources along with the composite affordance + return new Resources<>(employees, selfLink); + } + + @GetMapping("/employees/{id}") + public Resource findOne(@PathVariable Integer id) { + + // Start the affordance with the "self" link, i.e. this method. + Link findOneLink = linkTo(methodOn(WebMvcEmployeeController.class).findOne(id)).withSelfRel(); + + // Define final link as means to find entire collection. + Link employeesLink = linkTo(methodOn(WebMvcEmployeeController.class).all()).withRel("employees"); + + // Return the affordance + a link back to the entire collection resource. + return new Resource<>(EMPLOYEES.get(id), // + findOneLink // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).updateEmployee(null, id))) // // + .andAffordance(afford(methodOn(WebMvcEmployeeController.class).partiallyUpdateEmployee(null, id))), // + employeesLink); + } + + @PostMapping("/employees") + public ResponseEntity newEmployee(@RequestBody Resource employee) { + + int newEmployeeId = EMPLOYEES.size(); + + EMPLOYEES.put(newEmployeeId, employee.getContent()); + + Link link = linkTo(methodOn(getClass()).findOne(newEmployeeId)).withSelfRel().expand(); + + return ResponseEntity.created(URI.create(link.getHref())).build(); + } + + @PutMapping("/employees/{id}") + public ResponseEntity updateEmployee(@RequestBody Resource employee, @PathVariable Integer id) { + + EMPLOYEES.put(id, employee.getContent()); + + Link link = linkTo(methodOn(getClass()).findOne(id)).withSelfRel().expand(); + + return ResponseEntity.noContent() // + .location(URI.create(link.getHref())) // + .build(); + } + + @PatchMapping("/employees/{id}") + public ResponseEntity partiallyUpdateEmployee(@RequestBody Resource employee, @PathVariable Integer id) { + + Employee oldEmployee = EMPLOYEES.get(id); + Employee newEmployee = oldEmployee; + + if (employee.getContent().getName() != null) { + newEmployee = newEmployee.withName(employee.getContent().getName()); + } + + if (employee.getContent().getRole() != null) { + newEmployee = newEmployee.withRole(employee.getContent().getRole()); + } + + EMPLOYEES.put(id, newEmployee); + + try { + return ResponseEntity // + .noContent() // + .location( // + new URI(findOne(id) // + .getLink(IanaLinkRelations.SELF) // + .map(link -> link.expand().getHref()) // + .orElse("") // + ) // + ).build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/uber/UberWebFluxIntegrationTest.java b/src/test/java/org/springframework/hateoas/uber/UberWebFluxIntegrationTest.java new file mode 100644 index 00000000..53bb9b61 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/uber/UberWebFluxIntegrationTest.java @@ -0,0 +1,261 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.uber; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.collection.IsCollectionWithSize.*; +import static org.springframework.hateoas.support.JsonPathUtils.*; +import static org.springframework.hateoas.support.MappingUtils.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.config.reactive.WebClientConfigurer; +import org.springframework.hateoas.support.WebFluxEmployeeController; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class UberWebFluxIntegrationTest { + + @Autowired WebTestClient testClient; + + @Before + public void setUp() { + WebFluxEmployeeController.reset(); + } + + /** + * @see #728 + */ + @Test + public void singleEmployee() { + + this.testClient.get().uri("http://localhost/employees/0") + .accept(MediaTypes.UBER_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.UBER_JSON) + .expectBody(String.class) + + .value(jsonPath("$.uber.version", is("1.0"))) + + .value(jsonPath("$.uber.data.*", hasSize(5))) // + .value(jsonPath("$.uber.data[0].name", is("self"))) // + .value(jsonPath("$.uber.data[0].rel[0]", is("self"))) // + .value(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) // + .value(jsonPath("$.uber.data[0].url", is("http://localhost/employees/0"))) + + .value(jsonPath("$.uber.data[1].name", is("updateEmployee"))) + .value(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) + .value(jsonPath("$.uber.data[1].url", is("http://localhost/employees/0"))) + .value(jsonPath("$.uber.data[1].action", is("replace"))) + .value(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[2].url", is("http://localhost/employees/0"))) + .value(jsonPath("$.uber.data[2].action", is("partial"))) + .value(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[3].name", is("employees"))) + .value(jsonPath("$.uber.data[3].rel[0]", is("employees"))) + .value(jsonPath("$.uber.data[3].rel[1]", is("all"))) + .value(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .value(jsonPath("$.uber.data[4].name", is("employee"))) + .value(jsonPath("$.uber.data[4].data.*", hasSize(2))) + .value(jsonPath("$.uber.data[4].data[0].name", is("role"))) + .value(jsonPath("$.uber.data[4].data[0].value", is("ring bearer"))) + .value(jsonPath("$.uber.data[4].data[1].name", is("name"))) + .value(jsonPath("$.uber.data[4].data[1].value", is("Frodo Baggins"))); + } + + /** + * @see #728 + */ + @Test + public void collectionOfEmployees() { + + this.testClient.get().uri("http://localhost/employees") + .accept(MediaTypes.UBER_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.UBER_JSON) + .expectBody(String.class) + + .value(jsonPath("$.uber.version", is("1.0"))) + + .value(jsonPath("$.uber.data.*", hasSize(4))) + + .value(jsonPath("$.uber.data[0].name", is("self"))) // + .value(jsonPath("$.uber.data[0].rel[0]", is("self"))) + .value(jsonPath("$.uber.data[0].rel[1]", is("all"))) + .value(jsonPath("$.uber.data[0].url", is("http://localhost/employees"))) + + .value(jsonPath("$.uber.data[1].name", is("newEmployee"))) + .value(jsonPath("$.uber.data[1].rel[0]", is("newEmployee"))) + .value(jsonPath("$.uber.data[1].url", is("http://localhost/employees"))) + .value(jsonPath("$.uber.data[1].action", is("append"))) + .value(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[2].data[0].name", is("self"))) + .value(jsonPath("$.uber.data[2].data[0].rel[0]", is("self"))) + .value(jsonPath("$.uber.data[2].data[0].rel[1]", is("findOne"))) + .value(jsonPath("$.uber.data[2].data[0].url", is("http://localhost/employees/0"))) + + .value(jsonPath("$.uber.data[2].data[1].name", is("updateEmployee"))) + .value(jsonPath("$.uber.data[2].data[1].rel[0]", is("updateEmployee"))) + .value(jsonPath("$.uber.data[2].data[1].url", is("http://localhost/employees/0"))) + .value(jsonPath("$.uber.data[2].data[1].action", is("replace"))) + .value(jsonPath("$.uber.data[2].data[1].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[2].data[2].name", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[2].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[2].data[2].url", is("http://localhost/employees/0"))) + .value(jsonPath("$.uber.data[2].data[2].action", is("partial"))) + .value(jsonPath("$.uber.data[2].data[2].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[2].data[3].rel[0]", is("employees"))) + .value(jsonPath("$.uber.data[2].data[3].rel[1]", is("all"))) + .value(jsonPath("$.uber.data[2].data[3].url", is("http://localhost/employees"))) + + .value(jsonPath("$.uber.data[2].data[4].name", is("employee"))) + .value(jsonPath("$.uber.data[2].data[4].data[0].name", is("role"))) + .value(jsonPath("$.uber.data[2].data[4].data[0].value", is("ring bearer"))) + .value(jsonPath("$.uber.data[2].data[4].data[1].name", is("name"))) + .value(jsonPath("$.uber.data[2].data[4].data[1].value", is("Frodo Baggins"))) + + .value(jsonPath("$.uber.data[3].data[0].name", is("self"))) + .value(jsonPath("$.uber.data[3].data[0].rel[0]", is("self"))) + .value(jsonPath("$.uber.data[3].data[0].rel[1]", is("findOne"))) + .value(jsonPath("$.uber.data[3].data[0].url", is("http://localhost/employees/1"))) + + .value(jsonPath("$.uber.data[3].data[1].name", is("updateEmployee"))) + .value(jsonPath("$.uber.data[3].data[1].rel[0]", is("updateEmployee"))) + .value(jsonPath("$.uber.data[3].data[1].url", is("http://localhost/employees/1"))) + .value(jsonPath("$.uber.data[3].data[1].action", is("replace"))) + .value(jsonPath("$.uber.data[3].data[1].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[3].data[2].name", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[3].data[2].rel[0]", is("partiallyUpdateEmployee"))) + .value(jsonPath("$.uber.data[3].data[2].url", is("http://localhost/employees/1"))) + .value(jsonPath("$.uber.data[3].data[2].action", is("partial"))) + .value(jsonPath("$.uber.data[3].data[2].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[3].data[3].rel[0]", is("employees"))) + .value(jsonPath("$.uber.data[3].data[3].rel[1]", is("all"))) + .value(jsonPath("$.uber.data[3].data[3].url", is("http://localhost/employees"))) + + .value(jsonPath("$.uber.data[3].data[4].name", is("employee"))) + .value(jsonPath("$.uber.data[3].data[4].data[0].name", is("role"))) + .value(jsonPath("$.uber.data[3].data[4].data[0].value", is("burglar"))) + .value(jsonPath("$.uber.data[3].data[4].data[1].name", is("name"))) + .value(jsonPath("$.uber.data[3].data[4].data[1].value", is("Bilbo Baggins"))); + } + + /** + * @see #728 + */ + @Test + public void createNewEmployee() throws Exception { + + String input = read(new ClassPathResource("create-employee.json", getClass())); + + this.testClient.post().uri("http://localhost/employees") + .contentType(MediaTypes.UBER_JSON) + .syncBody(input) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2"); + + this.testClient.get().uri("http://localhost/employees/2") + .accept(MediaTypes.UBER_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaTypes.UBER_JSON) + .expectBody(String.class) + + .value(jsonPath("$.uber.version", is("1.0"))) + + .value(jsonPath("$.uber.data.*", hasSize(5))) // + .value(jsonPath("$.uber.data[0].name", is("self"))) // + .value(jsonPath("$.uber.data[0].rel[0]", is("self"))) // + .value(jsonPath("$.uber.data[0].rel[1]", is("findOne"))) // + .value(jsonPath("$.uber.data[0].url", is("http://localhost/employees/2"))) + + .value(jsonPath("$.uber.data[1].name", is("updateEmployee"))) // + .value(jsonPath("$.uber.data[1].rel[0]", is("updateEmployee"))) // + .value(jsonPath("$.uber.data[1].url", is("http://localhost/employees/2"))) // + .value(jsonPath("$.uber.data[1].action", is("replace"))) // + .value(jsonPath("$.uber.data[1].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[2].name", is("partiallyUpdateEmployee"))) // + .value(jsonPath("$.uber.data[2].rel[0]", is("partiallyUpdateEmployee"))) // + .value(jsonPath("$.uber.data[2].url", is("http://localhost/employees/2"))) // + .value(jsonPath("$.uber.data[2].action", is("partial"))) // + .value(jsonPath("$.uber.data[2].model", is("name={name}&role={role}"))) + + .value(jsonPath("$.uber.data[3].name", is("employees"))) // + .value(jsonPath("$.uber.data[3].rel[0]", is("employees"))) // + .value(jsonPath("$.uber.data[3].rel[1]", is("all"))) // + .value(jsonPath("$.uber.data[3].url", is("http://localhost/employees"))) + + .value(jsonPath("$.uber.data[4].name", is("employee"))) // + .value(jsonPath("$.uber.data[4].data.*", hasSize(2))) // + .value(jsonPath("$.uber.data[4].data[0].name", is("role"))) // + .value(jsonPath("$.uber.data[4].data[0].value", is("gardener"))) // + .value(jsonPath("$.uber.data[4].data[1].name", is("name"))) // + .value(jsonPath("$.uber.data[4].data[1].value", is("Samwise Gamgee"))); + } + + @Configuration + @EnableWebFlux + @EnableHypermediaSupport(type = { HypermediaType.UBER }) + static class TestConfig { + + @Bean + WebFluxEmployeeController employeeController() { + return new WebFluxEmployeeController(); + } + + @Bean + WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) { + + return WebTestClient.bindToApplicationContext(ctx).build() + .mutate() + .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()) + .build(); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java index 15ad4847..b555bb00 100644 --- a/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/uber/UberWebMvcIntegrationTest.java @@ -17,18 +17,12 @@ package org.springframework.hateoas.uber; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.collection.IsCollectionWithSize.*; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; import static org.springframework.hateoas.support.MappingUtils.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.TreeMap; import org.junit.Before; @@ -41,25 +35,15 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; import org.springframework.hateoas.MediaTypes; -import org.springframework.hateoas.Resource; -import org.springframework.hateoas.Resources; import org.springframework.hateoas.config.EnableHypermediaSupport; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; import org.springframework.hateoas.support.Employee; +import org.springframework.hateoas.support.WebMvcEmployeeController; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @@ -75,17 +59,11 @@ public class UberWebMvcIntegrationTest { MockMvc mockMvc; - private static Map EMPLOYEES; - @Before public void setUp() { this.mockMvc = webAppContextSetup(this.context).build(); - - EMPLOYEES = new TreeMap<>(); - - EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); - EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + WebMvcEmployeeController.reset(); } /** @@ -259,162 +237,14 @@ public class UberWebMvcIntegrationTest { .andExpect(jsonPath("$.uber.data[4].data[1].value", is("Samwise Gamgee"))); } - @RestController - static class EmployeeController { - - @GetMapping("/employees") - public Resources> all() { - - // Create a list of Resource's to return - List> employees = new ArrayList<>(); - - // Fetch each Resource using the controller's findOne method. - for (int i = 0; i < EMPLOYEES.size(); i++) { - employees.add(findOne(i)); - } - - // Generate an "Affordance" based on this method (the "self" link) - Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel() // - .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))) // - .andAffordance(afford(methodOn(EmployeeController.class).search(null, null))); - - // Return the collection of employee resources along with the composite affordance - return new Resources<>(employees, selfLink); - } - - @GetMapping("/employees/search") - public Resources> search(@RequestParam(value = "name", required = false) String name, - @RequestParam(value = "role", required = false) String role) { - - // Create a list of Resource's to return - List> employees = new ArrayList<>(); - - // Fetch each Resource using the controller's findOne method. - for (int i = 0; i < EMPLOYEES.size(); i++) { - - Resource employeeResource = findOne(i); - - boolean nameMatches = Optional.ofNullable(name) // - .map(s -> employeeResource.getContent().getName().contains(s)) // - .orElse(true); - - boolean roleMatches = Optional.ofNullable(role) // - .map(s -> employeeResource.getContent().getRole().contains(s)) // - .orElse(true); - - if (nameMatches && roleMatches) { - employees.add(employeeResource); - } - } - - // Generate an "Affordance" based on this method (the "self" link) - Link selfLink = linkTo(methodOn(EmployeeController.class).all()) // - .withSelfRel() // - .andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))) // - .andAffordance(afford(methodOn(EmployeeController.class).search(null, null))); - - // Return the collection of employee resources along with the composite affordance - return new Resources<>(employees, selfLink); - } - - @GetMapping("/employees/{id}") - public Resource findOne(@PathVariable Integer id) { - - // Start the affordance with the "self" link, i.e. this method. - Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel(); - - // Define final link as means to find entire collection. - Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees"); - - // Return the affordance + a link back to the entire collection resource. - return new Resource<>(EMPLOYEES.get(id), // - findOneLink // - .andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) // // - .andAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))), // - employeesLink); - } - - @PostMapping("/employees") - public ResponseEntity newEmployee(@RequestBody Resource employee) { - - int newEmployeeId = EMPLOYEES.size(); - - EMPLOYEES.put(newEmployeeId, employee.getContent()); - - try { - return ResponseEntity.created( // - new URI(findOne(newEmployeeId) // - .getLink(IanaLinkRelations.SELF.value()) // - .map(link -> link.expand().getHref()) // - .orElse("") // - ) // - ).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PutMapping("/employees/{id}") - public ResponseEntity updateEmployee(@RequestBody Resource employee, @PathVariable Integer id) { - - EMPLOYEES.put(id, employee.getContent()); - - try { - return ResponseEntity // - .noContent() // - .location( // - new URI(findOne(id) // - .getLink(IanaLinkRelations.SELF.value()) // - .map(link -> link.expand().getHref()) // - .orElse("") // - ) // - ).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - - @PatchMapping("/employees/{id}") - public ResponseEntity partiallyUpdateEmployee(@RequestBody Resource employee, - @PathVariable Integer id) { - - Employee oldEmployee = EMPLOYEES.get(id); - Employee newEmployee = oldEmployee; - - if (employee.getContent().getName() != null) { - newEmployee = newEmployee.withName(employee.getContent().getName()); - } - - if (employee.getContent().getRole() != null) { - newEmployee = newEmployee.withRole(employee.getContent().getRole()); - } - - EMPLOYEES.put(id, newEmployee); - - try { - return ResponseEntity // - .noContent() // - .location( // - new URI(findOne(id) // - .getLink(IanaLinkRelations.SELF.value()) // - .map(link -> link.expand().getHref()) // - .orElse("") // - ) // - ).build(); - } catch (URISyntaxException e) { - return ResponseEntity.badRequest().body(e.getMessage()); - } - } - } - @Configuration @EnableWebMvc @EnableHypermediaSupport(type = { HypermediaType.UBER }) static class TestConfig { @Bean - EmployeeController employeeController() { - return new EmployeeController(); + WebMvcEmployeeController employeeController() { + return new WebMvcEmployeeController(); } } } diff --git a/src/test/resources/org/springframework/hateoas/collectionjson/single-reactive-employee.json b/src/test/resources/org/springframework/hateoas/collectionjson/single-reactive-employee.json new file mode 100644 index 00000000..5c00b3d0 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/collectionjson/single-reactive-employee.json @@ -0,0 +1 @@ +{"collection":{"version":"1.0","href":"http://localhost/employees/0","links":[{"rel":"employees","href":"http://localhost/employees"}],"items":[{"href":"http://localhost/employees/0","data":[{"name":"role","value":"ring bearer"},{"name":"name","value":"Frodo Baggins"}],"links":[{"rel":"employees","href":"http://localhost/employees"}]}],"template":{"data":[{"name":"name","value":""},{"name":"role","value":""}]}}} \ No newline at end of file