diff --git a/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandler.java b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandler.java new file mode 100644 index 00000000..39b80f77 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandler.java @@ -0,0 +1,154 @@ +/* + * Copyright 2012-2016 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.mvc; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Field; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link HandlerMethodReturnValueHandler} to post-process the objects returned from controller methods using the + * configured {@link ResourceProcessor}s. + * + * @author Oliver Gierke + * @since 0.20 + * @soundtrack Doppelkopf - Balance (Von Abseits) + */ +@RequiredArgsConstructor +public class ResourceProcessorHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler { + + static final ResolvableType RESOURCE_TYPE = ResolvableType.forClass(Resource.class); + static final ResolvableType RESOURCES_TYPE = ResolvableType.forClass(Resources.class); + private static final ResolvableType HTTP_ENTITY_TYPE = ResolvableType.forClass(HttpEntity.class); + + static final Field CONTENT_FIELD = ReflectionUtils.findField(Resources.class, "content"); + + static { + ReflectionUtils.makeAccessible(CONTENT_FIELD); + } + + private final @NonNull HandlerMethodReturnValueHandler delegate; + private final @NonNull ResourceProcessorInvoker invoker; + + private boolean rootLinksAsHeaders = false; + + /** + * @param rootLinksAsHeaders the rootLinksAsHeaders to set + */ + public void setRootLinksAsHeaders(boolean rootLinksAsHeaders) { + this.rootLinksAsHeaders = rootLinksAsHeaders; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodReturnValueHandler#supportsReturnType(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return delegate.supportsReturnType(returnType); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodReturnValueHandler#handleReturnValue(java.lang.Object, org.springframework.core.MethodParameter, org.springframework.web.method.support.ModelAndViewContainer, org.springframework.web.context.request.NativeWebRequest) + */ + @Override + public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest) throws Exception { + + Object value = returnValue; + + if (returnValue instanceof HttpEntity) { + value = ((HttpEntity) returnValue).getBody(); + } + + // No post-processable type found - proceed with delegate + if (!ResourceSupport.class.isInstance(value)) { + delegate.handleReturnValue(returnValue, returnType, mavContainer, webRequest); + return; + } + + // We have a Resource or Resources - find suitable processors + ResolvableType targetType = ResolvableType.forMethodReturnType(returnType.getMethod()); + + // Unbox HttpEntity + if (HTTP_ENTITY_TYPE.isAssignableFrom(targetType)) { + targetType = targetType.getGeneric(0); + } + + ResolvableType returnValueType = ResolvableType.forClass(value.getClass()); + + // Returned value is actually of a more specific type, use this type information + if (!getRawType(targetType).equals(getRawType(returnValueType))) { + targetType = returnValueType; + } + + ResourceSupport result = invoker.invokeProcessorsFor((ResourceSupport) value, targetType); + delegate.handleReturnValue(rewrapResult(result, returnValue), returnType, mavContainer, webRequest); + } + + /** + * Re-wraps the result of the post-processing work into an {@link HttpEntity} or {@link ResponseEntity} if the + * original value was one of those two types. Copies headers and status code from the original value but uses the new + * body. + * + * @param newBody the post-processed value. + * @param originalValue the original input value. + * @return + */ + Object rewrapResult(ResourceSupport newBody, Object originalValue) { + + if (!(originalValue instanceof HttpEntity)) { + return newBody; + } + + HttpEntity entity = null; + + if (originalValue instanceof ResponseEntity) { + ResponseEntity source = (ResponseEntity) originalValue; + entity = new ResponseEntity(newBody, source.getHeaders(), source.getStatusCode()); + } else { + HttpEntity source = (HttpEntity) originalValue; + entity = new HttpEntity(newBody, source.getHeaders()); + } + + return addLinksToHeaderWrapper(entity); + } + + private HttpEntity addLinksToHeaderWrapper(HttpEntity entity) { + return rootLinksAsHeaders ? HeaderLinksResponseEntity.wrap(entity) : entity; + } + + private static Class getRawType(ResolvableType type) { + + Class rawType = type.getRawClass(); + return rawType == null ? Object.class : rawType; + } +} diff --git a/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvoker.java b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvoker.java new file mode 100644 index 00000000..020d93af --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvoker.java @@ -0,0 +1,411 @@ +/* + * Copyright 2016 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.mvc; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.core.EmbeddedWrapper; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Component to easily invoke all {@link ResourceProcessor} instances registered for values of type + * {@link ResourceSupport}. + * + * @author Oliver Gierke + * @since 0.20 + * @soundtrack Doppelkopf - Die fabelhaften Vier (Von Abseits) + */ +public class ResourceProcessorInvoker { + + private final List processors; + + /** + * Creates a new {@link ResourceProcessorInvoker} to consider the given {@link ResourceProcessor} to post-process the + * controller methods return value to before invoking the delegate. + * + * @param processors the {@link ResourceProcessor}s to be considered, must not be {@literal null}. + */ + public ResourceProcessorInvoker(Collection> processors) { + + Assert.notNull(processors, "ResourceProcessors must not be null!"); + + this.processors = new ArrayList(); + + for (ResourceProcessor processor : processors) { + + ResolvableType processorType = ResolvableType.forClass(ResourceProcessor.class, processor.getClass()); + Class rawType = processorType.getGeneric(0).resolve(); + + if (Resource.class.isAssignableFrom(rawType)) { + this.processors.add(new ResourceProcessorWrapper(processor)); + } else if (Resources.class.isAssignableFrom(rawType)) { + this.processors.add(new ResourcesProcessorWrapper(processor)); + } else { + this.processors.add(new DefaultProcessorWrapper(processor)); + } + } + + Collections.sort(this.processors, AnnotationAwareOrderComparator.INSTANCE); + } + + /** + * Invokes all {@link ResourceProcessor} instances registered for the type of the given value. + * + * @param value must not be {@literal null}. + * @return + */ + public T invokeProcessorsFor(T value) { + + Assert.notNull(value, "Value must not be null!"); + + return invokeProcessorsFor(value, ResolvableType.forClass(value.getClass())); + } + + /** + * Invokes all {@link ResourceProcessor} instances registered for the type of the given value and reference type. + * + * @param value must not be {@literal null}. + * @param referenceType must not be {@literal null}. + * @return + */ + @SuppressWarnings("unchecked") + public T invokeProcessorsFor(T value, ResolvableType referenceType) { + + Assert.notNull(value, "Value must not be null!"); + Assert.notNull(referenceType, "Reference type must not be null!"); + + // For Resources implementations, process elements first + if (ResourceProcessorHandlerMethodReturnValueHandler.RESOURCES_TYPE.isAssignableFrom(referenceType)) { + + Resources resources = (Resources) value; + ResolvableType elementTargetType = ResolvableType.forClass(Resources.class, referenceType.getRawClass()) + .getGeneric(0); + List result = new ArrayList(resources.getContent().size()); + + for (Object element : resources) { + + ResolvableType elementType = ResolvableType.forClass(element.getClass()); + + if (!getRawType(elementTargetType).equals(elementType.getRawClass())) { + elementTargetType = elementType; + } + + result.add(invokeProcessorsFor(element, elementTargetType)); + } + + ReflectionUtils.setField(ResourceProcessorHandlerMethodReturnValueHandler.CONTENT_FIELD, resources, result); + } + + return (T) invokeProcessorsFor((Object) value, referenceType); + } + + /** + * Invokes all registered {@link ResourceProcessor}s registered for the given {@link ResolvableType}. + * + * @param value the object to process + * @param type + * @return + */ + private Object invokeProcessorsFor(Object value, ResolvableType type) { + + Object currentValue = value; + + // Process actual value + for (ResourceProcessorInvoker.ProcessorWrapper wrapper : this.processors) { + if (wrapper.supports(type, currentValue)) { + currentValue = wrapper.invokeProcessor(currentValue); + } + } + + return currentValue; + } + + private static boolean isRawTypeAssignable(ResolvableType left, Class right) { + return getRawType(left).isAssignableFrom(right); + } + + private static Class getRawType(ResolvableType type) { + + Class rawType = type.getRawClass(); + return rawType == null ? Object.class : rawType; + } + + /** + * Interface to unify interaction with {@link ResourceProcessor}s. The {@link Ordered} rank should be determined by + * the underlying processor. + * + * @author Oliver Gierke + */ + private interface ProcessorWrapper extends Ordered { + + /** + * Returns whether the underlying processor supports the given {@link ResolvableType}. It might also additionally + * inspect the object that would eventually be handed to the processor. + * + * @param type the type of object to be post processed, will never be {@literal null}. + * @param value the object that would be passed into the processor eventually, can be {@literal null}. + * @return + */ + boolean supports(ResolvableType type, Object value); + + /** + * Performs the actual invocation of the processor. Implementations can be sure + * {@link #supports(ResolvableType, Object)} has been called before and returned {@literal true}. + * + * @param object + */ + Object invokeProcessor(Object object); + } + + /** + * Default implementation of {@link ProcessorWrapper} to generically deal with {@link ResourceSupport} types. + * + * @author Oliver Gierke + */ + private static class DefaultProcessorWrapper implements ResourceProcessorInvoker.ProcessorWrapper { + + private final ResourceProcessor processor; + private final ResolvableType targetType; + + /** + * Creates a new {@link DefaultProcessorWrapper} with the given {@link ResourceProcessor}. + * + * @param processor must not be {@literal null}. + */ + public DefaultProcessorWrapper(ResourceProcessor processor) { + + Assert.notNull(processor); + + this.processor = processor; + this.targetType = ResolvableType.forClass(ResourceProcessor.class, processor.getClass()).getGeneric(0); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler.ProcessorWrapper#supports(org.springframework.core.ResolvableType, java.lang.Object) + */ + @Override + public boolean supports(ResolvableType type, Object value) { + return isRawTypeAssignable(targetType, getRawType(type)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler.PostProcessorWrapper#invokeProcessor(java.lang.Object) + */ + @Override + @SuppressWarnings("unchecked") + public Object invokeProcessor(Object object) { + return ((ResourceProcessor) processor).process((ResourceSupport) object); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.Ordered#getOrder() + */ + @Override + public int getOrder() { + return CustomOrderAwareComparator.INSTANCE.getOrder(processor); + } + + /** + * Returns the target type the underlying {@link ResourceProcessor} wants to get invoked for. + * + * @return the targetType + */ + public ResolvableType getTargetType() { + return targetType; + } + } + + /** + * {@link ProcessorWrapper} to deal with {@link ResourceProcessor}s for {@link Resource}s. Will fall back to peeking + * into the {@link Resource}'s content for type resolution. + * + * @author Oliver Gierke + */ + private static class ResourceProcessorWrapper extends ResourceProcessorInvoker.DefaultProcessorWrapper { + + /** + * Creates a new {@link ResourceProcessorWrapper} for the given {@link ResourceProcessor}. + * + * @param processor must not be {@literal null}. + */ + public ResourceProcessorWrapper(ResourceProcessor processor) { + super(processor); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler.DefaultProcessorWrapper#supports(org.springframework.core.ResolvableType, java.lang.Object) + */ + @Override + public boolean supports(ResolvableType type, Object value) { + + if (!ResourceProcessorHandlerMethodReturnValueHandler.RESOURCE_TYPE.isAssignableFrom(type)) { + return false; + } + + return super.supports(type, value) && isValueTypeMatch((Resource) value, getTargetType()); + } + + /** + * Returns whether the given {@link Resource} matches the given target {@link ResolvableType}. We inspect the + * {@link Resource}'s value to determine the match. + * + * @param resource + * @param target must not be {@literal null}. + * @return whether the given {@link Resource} can be assigned to the given target {@link ResolvableType} + */ + private static boolean isValueTypeMatch(Resource resource, ResolvableType target) { + + if (resource == null || !isRawTypeAssignable(target, resource.getClass())) { + return false; + } + + Object content = resource.getContent(); + + if (content == null) { + return false; + } + + ResolvableType type = findGenericType(target, Resource.class); + return type != null && type.getGeneric(0).isAssignableFrom(ResolvableType.forClass(content.getClass())); + } + + private static ResolvableType findGenericType(ResolvableType source, Class type) { + + Class rawType = getRawType(source); + + if (Object.class.equals(rawType)) { + return null; + } + + if (rawType.equals(type)) { + return source; + } + + return findGenericType(source.getSuperType(), type); + } + } + + /** + * {@link ProcessorWrapper} for {@link ResourceProcessor}s targeting {@link Resources}. Will peek into the content of + * the {@link Resources} for type matching decisions if needed. + * + * @author Oliver Gierke + */ + public static class ResourcesProcessorWrapper extends ResourceProcessorInvoker.DefaultProcessorWrapper { + + /** + * Creates a new {@link ResourcesProcessorWrapper} for the given {@link ResourceProcessor}. + * + * @param processor must not be {@literal null}. + */ + public ResourcesProcessorWrapper(ResourceProcessor processor) { + super(processor); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler.DefaultProcessorWrapper#supports(org.springframework.core.ResolvableType, java.lang.Object) + */ + @Override + public boolean supports(ResolvableType type, Object value) { + + if (!ResourceProcessorHandlerMethodReturnValueHandler.RESOURCES_TYPE.isAssignableFrom(type)) { + return false; + } + + return super.supports(type, value) && isValueTypeMatch((Resources) value, getTargetType()); + } + + /** + * Returns whether the given {@link Resources} instance matches the given {@link ResolvableType}. We predict this by + * inspecting the first element of the content of the {@link Resources}. + * + * @param resources the {@link Resources} to inspect. + * @param target that target {@link ResolvableType}. + * @return + */ + static boolean isValueTypeMatch(Resources resources, ResolvableType target) { + + if (resources == null) { + return false; + } + + Collection content = resources.getContent(); + + if (content.isEmpty()) { + return false; + } + + ResolvableType superType = null; + + for (Class resourcesType : Arrays.> asList(resources.getClass(), Resources.class)) { + + superType = ResolvableType.forClass(resourcesType, getRawType(target)); + + if (superType != null) { + break; + } + } + + if (superType == null) { + return false; + } + + Object element = content.iterator().next(); + ResolvableType resourceType = superType.getGeneric(0); + + if (element instanceof Resource) { + return ResourceProcessorWrapper.isValueTypeMatch((Resource) element, resourceType); + } else if (element instanceof EmbeddedWrapper) { + return isRawTypeAssignable(resourceType, ((EmbeddedWrapper) element).getRelTargetType()); + } + + return false; + } + } + + /** + * Helper extension of {@link AnnotationAwareOrderComparator} to make {@link #getOrder(Object)} public to allow it + * being used in a standalone fashion. + * + * @author Oliver Gierke + */ + private static class CustomOrderAwareComparator extends AnnotationAwareOrderComparator { + + public static ResourceProcessorInvoker.CustomOrderAwareComparator INSTANCE = new CustomOrderAwareComparator(); + + @Override + protected int getOrder(Object obj) { + return super.getOrder(obj); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvokingHandlerAdapter.java b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvokingHandlerAdapter.java new file mode 100644 index 00000000..4eb1b2bd --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/ResourceProcessorInvokingHandlerAdapter.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2016 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.mvc; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; + +/** + * Special {@link RequestMappingHandlerAdapter} that tweaks the {@link HandlerMethodReturnValueHandlerComposite} to be + * proxied by a {@link ResourceProcessorHandlerMethodReturnValueHandler} which will invoke the {@link ResourceProcessor} + * s found in the application context and eventually delegate to the originally configured + * {@link HandlerMethodReturnValueHandler}. + *

+ * This is a separate component as it might make sense to deploy it in a standalone SpringMVC application to enable post + * processing. It would actually make most sense in Spring HATEOAS project. + * + * @author Oliver Gierke + * @author Phil Webb + * @since 0.20 + * @soundtrack Dopplekopf - Regen für immer (Von Abseits) + */ +@RequiredArgsConstructor +public class ResourceProcessorInvokingHandlerAdapter extends RequestMappingHandlerAdapter { + + private static final Method RETURN_VALUE_HANDLER_METHOD = ReflectionUtils + .findMethod(ResourceProcessorInvokingHandlerAdapter.class, "getReturnValueHandlers"); + + private @NonNull final ResourceProcessorInvoker invoker; + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() { + + super.afterPropertiesSet(); + + // Retrieve actual handlers to use as delegate + HandlerMethodReturnValueHandlerComposite oldHandlers = getReturnValueHandlersComposite(); + + // Set up ResourceProcessingHandlerMethodResolver to delegate to originally configured ones + List newHandlers = new ArrayList(); + newHandlers.add(new ResourceProcessorHandlerMethodReturnValueHandler(oldHandlers, invoker)); + + // Configure the new handler to be used + this.setReturnValueHandlers(newHandlers); + } + + /** + * Gets a {@link HandlerMethodReturnValueHandlerComposite} for return handlers, dealing with API changes introduced in + * Spring 4.0. + * + * @return a HandlerMethodReturnValueHandlerComposite + */ + @SuppressWarnings("unchecked") + private HandlerMethodReturnValueHandlerComposite getReturnValueHandlersComposite() { + + Object handlers = ReflectionUtils.invokeMethod(RETURN_VALUE_HANDLER_METHOD, this); + + if (handlers instanceof HandlerMethodReturnValueHandlerComposite) { + return (HandlerMethodReturnValueHandlerComposite) handlers; + } + + return new HandlerMethodReturnValueHandlerComposite() + .addHandlers((List) handlers); + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/HttpEntityMatcher.java b/src/test/java/org/springframework/hateoas/mvc/HttpEntityMatcher.java new file mode 100644 index 00000000..316e89e7 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/HttpEntityMatcher.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2016 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.mvc; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; + +/** + * @author Jon Brisbin + * @author Oliver Gierke + */ +@RequiredArgsConstructor(staticName = "httpEntity") +class HttpEntityMatcher extends BaseMatcher> { + + private final @NonNull HttpEntity expected; + + /* + * (non-Javadoc) + * @see org.hamcrest.Matcher#matches(java.lang.Object) + */ + @Override + public boolean matches(Object item) { + + if (!(item instanceof HttpEntity)) { + return false; + } + + if (item instanceof ResponseEntity && expected instanceof ResponseEntity) { + + ResponseEntity left = (ResponseEntity) expected; + ResponseEntity right = (ResponseEntity) item; + + if (!left.getStatusCode().equals(right.getStatusCode())) { + return false; + } + } + + HttpEntity left = expected; + HttpEntity right = (HttpEntity) item; + + return left.getBody().equals(right.getBody()) && left.getHeaders().equals(right.getHeaders()); + } + + /* + * (non-Javadoc) + * @see org.hamcrest.SelfDescribing#describeTo(org.hamcrest.Description) + */ + @Override + public void describeTo(Description description) { + description.appendText(expected.toString()); + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTests.java b/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTests.java new file mode 100644 index 00000000..566a0b99 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTests.java @@ -0,0 +1,417 @@ +/* + * Copyright 2012-2015 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.mvc; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.hateoas.mvc.HttpEntityMatcher.*; +import static org.springframework.util.ReflectionUtils.*; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.hamcrest.Matcher; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.PagedResources.PageMetadata; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.core.EmbeddedWrappers; +import org.springframework.hateoas.mvc.ResourceProcessorInvoker.ResourcesProcessorWrapper; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils.MethodCallback; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * Unit tests for {@link org.springframework.data.rest.webmvc.ResourceProcessorHandlerMethodReturnValueHandler}. + * + * @author Oliver Gierke + * @author Jon Brisbin + */ +@RunWith(MockitoJUnitRunner.class) +public class ResourceProcessorHandlerMethodReturnValueHandlerUnitTests { + + static final Resource FOO = new Resource("foo"); + static final Resources> FOOS = new Resources>(Collections.singletonList(FOO)); + static final PagedResources> FOO_PAGE = new PagedResources>( + Collections.singleton(FOO), new PageMetadata(1, 0, 10)); + static final StringResource FOO_RES = new StringResource("foo"); + static final HttpEntity> FOO_ENTITY = new HttpEntity>(FOO); + static final ResponseEntity> FOO_RESP_ENTITY = new ResponseEntity>(FOO, + HttpStatus.OK); + static final HttpEntity FOO_RES_ENTITY = new HttpEntity(FOO_RES); + static final Resource BAR = new Resource("bar"); + static final Resources> BARS = new Resources>(Collections.singletonList(BAR)); + static final StringResource BAR_RES = new StringResource("bar"); + static final HttpEntity> BAR_ENTITY = new HttpEntity>(BAR); + static final ResponseEntity> BAR_RESP_ENTITY = new ResponseEntity>(BAR, + HttpStatus.OK); + static final HttpEntity BAR_RES_ENTITY = new HttpEntity(BAR_RES); + static final Resource LONG_10 = new Resource(10L); + static final Resource LONG_20 = new Resource(20L); + static final LongResource LONG_10_RES = new LongResource(10L); + static final LongResource LONG_20_RES = new LongResource(20L); + static final HttpEntity> LONG_10_ENTITY = new HttpEntity>(LONG_10); + static final HttpEntity LONG_10_RES_ENTITY = new HttpEntity(LONG_10_RES); + static final HttpEntity> LONG_20_ENTITY = new HttpEntity>(LONG_20); + static final HttpEntity LONG_20_RES_ENTITY = new HttpEntity(LONG_20_RES); + static final Map METHOD_PARAMS = new HashMap(); + + static { + doWithMethods(Controller.class, new MethodCallback() { + @Override + public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException { + METHOD_PARAMS.put(method.getName(), new MethodParameter(method, -1)); + } + }); + } + + @Mock HandlerMethodReturnValueHandler delegate; + List> resourceProcessors; + + @Before + public void setUp() { + resourceProcessors = new ArrayList>(); + } + + @Test + public void supportsIfDelegateSupports() { + assertSupport(true); + } + + @Test + public void doesNotSupportIfDelegateDoesNot() { + assertSupport(false); + } + + @Test + public void postProcessesStringResource() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("stringResourceEntity", is(BAR), FOO); + } + + @Test + public void postProcessesStringResourceInResponseEntity() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("stringResourceEntity", httpEntity(BAR_RESP_ENTITY), FOO_RESP_ENTITY); + } + + @Test + public void postProcessesStringResourceInWildcardResponseEntity() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("resourceEntity", httpEntity(BAR_RESP_ENTITY), FOO_RESP_ENTITY); + } + + @Test + public void postProcessesStringResources() throws Exception { + + resourceProcessors.add(StringResourcesProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("resources", is(BARS), FOOS); + } + + @Test + public void postProcessesSpecializedStringResource() throws Exception { + + resourceProcessors.add(SpecializedStringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("stringResourceEntity", httpEntity(BAR_RES_ENTITY), FOO_RES_ENTITY); + } + + @Test + public void postProcessesSpecializedStringUsingStringResourceProcessor() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("specializedStringResourceEntity", httpEntity(BAR_ENTITY), FOO_RES_ENTITY); + } + + @Test + public void postProcessesLongResource() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("longResource", is(LONG_20), LONG_10); + } + + @Test + public void postProcessesSpecializedLongResource() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(SpecializedLongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("specializedLongResourceEntity", httpEntity(LONG_20_RES_ENTITY), LONG_10_RES_ENTITY); + } + + @Test + public void doesNotPostProcesseLongResourceWithSpecializedLongResourceProcessor() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(SpecializedLongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("numberResourceEntity", httpEntity(LONG_10_ENTITY), LONG_10_ENTITY); + } + + @Test + public void postProcessesSpecializedLongResourceUsingLongResourceProcessor() throws Exception { + + resourceProcessors.add(StringResourceProcessor.INSTANCE); + resourceProcessors.add(LongResourceProcessor.INSTANCE); + + invokeReturnValueHandler("resourceEntity", is(LONG_20), LONG_10_RES); + } + + @Test + public void usesHeaderLinksResponseEntityIfConfigured() throws Exception { + + Resource resource = new Resource("foo", new Link("href", "rel")); + MethodParameter parameter = METHOD_PARAMS.get("resource"); + + ResourceProcessorHandlerMethodReturnValueHandler handler = new ResourceProcessorHandlerMethodReturnValueHandler( + delegate, new ResourceProcessorInvoker(resourceProcessors)); + handler.setRootLinksAsHeaders(true); + handler.handleReturnValue(resource, parameter, null, null); + + verify(delegate, times(1)).handleReturnValue(Mockito.any(HeaderLinksResponseEntity.class), eq(parameter), + Mockito.any(ModelAndViewContainer.class), Mockito.any(NativeWebRequest.class)); + } + + /** + * @see DATAREST-331 + */ + @Test + public void resourcesProcessorMatchesValueSubTypes() { + + ResolvableType type = ResolvableType.forClass(PagedStringResources.class); + + assertThat(ResourcesProcessorWrapper.isValueTypeMatch(FOO_PAGE, type), is(true)); + } + + /** + * @see DATAREST-479 + */ + @Test + public void doesNotInvokeAProcessorForASpecializedType() throws Exception { + + EmbeddedWrappers wrappers = new EmbeddedWrappers(false); + Resources value = new Resources( + Collections. singleton(wrappers.emptyCollectionOf(Object.class))); + ResourcesProcessorWrapper wrapper = new ResourcesProcessorWrapper(new SpecialResourcesProcessor()); + + ResolvableType type = ResolvableType.forMethodReturnType(Controller.class.getMethod("resourcesOfObject")); + + assertThat(wrapper.supports(type, value), is(false)); + } + + /** + * @see DATAREST-702 + */ + @Test + public void registersProcessorForProxyType() { + + ProjectionProcessor processor = new ProjectionProcessor(); + ProxyFactory factory = new ProxyFactory(processor); + + resourceProcessors.add((ResourceProcessor) factory.getProxy()); + + new ResourceProcessorHandlerMethodReturnValueHandler(delegate, new ResourceProcessorInvoker(resourceProcessors)); + } + + // Helpers ---------------------------------------------------------// + private void invokeReturnValueHandler(String method, final Matcher matcher, Object returnValue) throws Exception { + final MethodParameter methodParam = METHOD_PARAMS.get(method); + + if (methodParam == null) { + throw new IllegalArgumentException("Invalid method!"); + } + + HandlerMethodReturnValueHandler handler = new ResourceProcessorHandlerMethodReturnValueHandler(delegate, + new ResourceProcessorInvoker(resourceProcessors)); + handler.handleReturnValue(returnValue, methodParam, null, null); + } + + private void assertSupport(boolean value) { + + final MethodParameter parameter = Mockito.mock(MethodParameter.class); + when(delegate.supportsReturnType(Mockito.any(MethodParameter.class))).thenReturn(value); + + HandlerMethodReturnValueHandler handler = new ResourceProcessorHandlerMethodReturnValueHandler(delegate, + new ResourceProcessorInvoker(resourceProcessors)); + + assertThat(handler.supportsReturnType(parameter), is(value)); + } + + enum StringResourceProcessor implements ResourceProcessor> { + INSTANCE; + + @Override + public Resource process(Resource resource) { + return BAR; + } + } + + enum LongResourceProcessor implements ResourceProcessor> { + INSTANCE; + + @Override + public Resource process(Resource resource) { + return LONG_20; + } + } + + enum StringResourcesProcessor implements ResourceProcessor>> { + INSTANCE; + + @Override + public Resources> process(Resources> resource) { + return BARS; + } + } + + enum SpecializedStringResourceProcessor implements ResourceProcessor { + INSTANCE; + + @Override + public StringResource process(StringResource resource) { + return BAR_RES; + } + } + + enum SpecializedLongResourceProcessor implements ResourceProcessor { + INSTANCE; + + @Override + public LongResource process(LongResource resource) { + return LONG_20_RES; + } + } + + static interface Controller { + + Resources> resources(); + + Resource resource(); + + Resource longResource(); + + StringResource specializedResource(); + + Object object(); + + HttpEntity> resourceEntity(); + + HttpEntity> resourcesEntity(); + + HttpEntity objectEntity(); + + HttpEntity> stringResourceEntity(); + + HttpEntity> numberResourceEntity(); + + HttpEntity specializedStringResourceEntity(); + + HttpEntity specializedLongResourceEntity(); + + ResponseEntity> resourceResponseEntity(); + + ResponseEntity> resourcesResponseEntity(); + + Resources resourcesOfObject(); + } + + static class StringResource extends Resource { + public StringResource(String value) { + super(value); + } + } + + static class LongResource extends Resource { + public LongResource(Long value) { + super(value); + } + } + + static class PagedStringResources extends PagedResources> {}; + + static class Sample { + + } + + static interface SampleProjection { + + } + + static class ProjectionProcessor implements ResourceProcessor> { + + boolean invoked = false; + + @Override + public Resource process(Resource resource) { + this.invoked = true; + return resource; + } + } + + static class SpecialResources extends Resources { + public SpecialResources() { + super(Collections.emptyList()); + } + } + + static class SpecialResourcesProcessor implements ResourceProcessor { + + boolean invoked = false; + + @Override + public SpecialResources process(SpecialResources resource) { + this.invoked = true; + return resource; + } + } +}