#859 - Fix RepresentationModelProcessor to smoothly register with Spring MVC.

Some more polishing on internal of Processor and Invoker machinery regarding resource -> model, etc.
This commit is contained in:
Greg Turnquist
2019-03-07 10:08:00 -06:00
committed by Oliver Drotbohm
parent 0439c5f708
commit 1e621784e7
16 changed files with 583 additions and 141 deletions

View File

@@ -0,0 +1,33 @@
/*
* 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;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author Greg Turnquist
*/
// tag::code[]
@Configuration
public class PaymentProcessingApp {
@Bean
PaymentProcessor paymentProcessor() {
return new PaymentProcessor();
}
}
// end::code[]

View File

@@ -0,0 +1,37 @@
/*
* 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;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.hateoas.support.Order;
/**
* @author Greg Turnquist
*/
// tag::code[]
public class PaymentProcessor implements RepresentationModelProcessor<EntityModel<Order>> { // <1>
@Override
public EntityModel<Order> process(EntityModel<Order> model) {
model.add( // <2>
new Link("/payments/{orderId}").withRel(LinkRelation.of("payments")) //
.expand(model.getContent().getOrderId()));
return model; // <3>
}
}
// end::code[]

View File

@@ -0,0 +1,28 @@
/*
* 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 lombok.RequiredArgsConstructor;
import lombok.Value;
/**
* @author Greg Turnquist
*/
@Value
@RequiredArgsConstructor
public class Customer {
private final long customerId;
}

View File

@@ -0,0 +1,30 @@
/*
* 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 lombok.RequiredArgsConstructor;
import lombok.Value;
/**
* @author Greg Turnquist
*/
@Value
@RequiredArgsConstructor
public class Order {
private final long orderId;
private final String state;
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.hateoas.support;
import lombok.RequiredArgsConstructor;
import lombok.Value;
/**
* @author Greg Turnquist
*/
@Value
@RequiredArgsConstructor
public class Payment {
private final Order order;
private final double amount;
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.hateoas.support;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Greg Turnquist
*/
@RestController
public class PaymentController {
}

View File

@@ -0,0 +1,9 @@
{
"orderId" : "42",
"state" : "AWAITING_PAYMENT",
"_links" : {
"self" : {
"href" : "http://localhost/orders/999"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"orderId" : "42",
"state" : "AWAITING_PAYMENT",
"_links" : {
"self" : {
"href" : "http://localhost/orders/999"
},
"payments" : { // <1>
"href" : "/payments/42" // <2>
}
}
}

View File

@@ -319,7 +319,7 @@ entityLinks.linkToItemResource(order, idExtractor); <2>
==== TypedEntityLinks
As controller implementations are often grouped around entity types, you'll very often find yourself using the same extractor function (see <<server.entity-links.api>> for details) all over the controller class.
We can centralize the identifier extraction logic even more by obtaining a `TypedEntityLinks` instance poviding the extractor once, so that the actually lookups don't have to deal with the extraction anymore at all.
We can centralize the identifier extraction logic even more by obtaining a `TypedEntityLinks` instance providing the extractor once, so that the actually lookups don't have to deal with the extraction anymore at all.
.Using TypedEntityLinks
====
@@ -425,6 +425,66 @@ CollectionModel<PersonModel> model = assembler.toCollectionModel(people);
----
====
[[server.processors]]
== Representation Model Processors
Sometimes you need to tweak and adjust hypermedia representations after they have been <<server.representation-model-assembler,assembled>>.
A perfect example is when you have a controller that deals with order fulfillment, but you need to add links related to making payments.
Imagine having your ordering system producing this type of hypermedia:
====
[source, json, tabsize=2]
----
include::{resource-dir}/docs/order-plain.json[]
----
====
You wish to add a link so the client can make payment, but don't want to mix details about your `PaymentController` into
the `OrderController`.
Instead of polluting the details of your ordering system, you can write a `RepresentationModelProcessor` like this:
====
[source, java, tabsize=2]
----
include::{code-dir}/PaymentProcessor.java[tag=code]
----
<1> This processor will only be applied to `EntityModel<Order>` objects.
<2> Manipulate the existing `EntityModel` object by adding an unconditional link.
<3> Return the `EntityModel` so it can be serialized into the requested media type.
====
Register the processor with your application:
====
[source, java, tabsize=2]
----
include::{code-dir}/PaymentProcessingApp.java[tag=code]
----
====
Now when you issue a hypermedia respresentation of an `Order`, the client receives this:
====
[source, java, tabsize=2]
----
include::{resource-dir}/docs/order-with-payment-link.json[]
----
<1> You see the `LinkRelation.of("payments")` plugged in as this link's relation.
<2> The URI was provided by the processor.
====
This example is quite simple, but you can easily:
* Use `WebMvcLinkBuilder` or `WebFluxLinkBuilder` to construct a dynamic link to your `PaymentController`.
* Inject any services needed to conditionally add other links (e.g. `cancel`, `amend`) that are driven by state.
* Leverage cross cutting services like Spring Security to add, remove, or revise links based upon the current user's context.
Also, in this example, the `PaymentProcessor` alters the provided `EntityModel<Order>`. You also have the power to
_replace_ it with another object. Just be advised the API requires the return type to equal the input type.
[[server.rel-provider]]
== [[spis.rel-provider]] Using the `RelProvider` API

View File

@@ -18,6 +18,7 @@ package org.springframework.hateoas.config;
import lombok.RequiredArgsConstructor;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -27,13 +28,18 @@ import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.hateoas.server.mvc.RepresentationModelProcessorHandlerMethodReturnValueHandler;
import org.springframework.hateoas.server.mvc.RepresentationModelProcessorInvoker;
import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter;
import org.springframework.hateoas.server.mvc.UriComponentsContributor;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilderFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.lang.NonNull;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -52,6 +58,13 @@ class WebMvcHateoasConfiguration {
return new HypermediaWebMvcConfigurer(mapper.getIfAvailable(ObjectMapper::new), hypermediaTypes);
}
@Bean
HypermediaRepresentationModelBeanProcessorPostProcessor hypermediaRepresentionModelProcessorConfigurator(
List<RepresentationModelProcessor<?>> processors) {
return new HypermediaRepresentationModelBeanProcessorPostProcessor(processors);
}
@Bean
static HypermediaRestTemplateBeanPostProcessor restTemplateBeanPostProcessor(
ObjectProvider<HypermediaWebMvcConfigurer> configurer) {
@@ -92,6 +105,34 @@ class WebMvcHateoasConfiguration {
}
}
/**
* @author Greg Turnquist
*/
@RequiredArgsConstructor
static class HypermediaRepresentationModelBeanProcessorPostProcessor implements BeanPostProcessor {
private final List<RepresentationModelProcessor<?>> processors;
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (RequestMappingHandlerAdapter.class.isInstance(bean)) {
RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean;
HandlerMethodReturnValueHandlerComposite delegate = new HandlerMethodReturnValueHandlerComposite();
delegate.addHandlers(adapter.getReturnValueHandlers());
RepresentationModelProcessorHandlerMethodReturnValueHandler handler = new RepresentationModelProcessorHandlerMethodReturnValueHandler(
delegate, new RepresentationModelProcessorInvoker(processors));
adapter.setReturnValueHandlers(Collections.singletonList(handler));
}
return bean;
}
}
/**
* {@link BeanPostProcessor} to register hypermedia support with {@link RestTemplate} instances found in the
* application context.

View File

@@ -32,8 +32,8 @@ public interface RepresentationModelProcessor<T extends RepresentationModel<?>>
/**
* Processes the given representation model, add links, alter the domain data etc.
*
* @param resource
* @return the processed resource
* @param model
* @return the processed model
*/
T process(T resource);
T process(T model);
}

View File

@@ -47,8 +47,8 @@ import org.springframework.web.method.support.ModelAndViewContainer;
@RequiredArgsConstructor
public class RepresentationModelProcessorHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
static final ResolvableType RESOURCE_TYPE = ResolvableType.forRawClass(EntityModel.class);
static final ResolvableType RESOURCES_TYPE = ResolvableType.forRawClass(CollectionModel.class);
static final ResolvableType ENTITY_MODEL_TYPE = ResolvableType.forRawClass(EntityModel.class);
static final ResolvableType COLLECTION_MODEL_TYPE = ResolvableType.forRawClass(CollectionModel.class);
private static final ResolvableType HTTP_ENTITY_TYPE = ResolvableType.forRawClass(HttpEntity.class);
static final Field CONTENT_FIELD = ReflectionUtils.findField(CollectionModel.class, "content");

View File

@@ -37,6 +37,7 @@ import org.springframework.util.ReflectionUtils;
* {@link RepresentationModel}.
*
* @author Oliver Gierke
* @author Greg Turnquist
* @since 0.20
* @soundtrack Doppelkopf - Die fabelhaften Vier (Von Abseits)
*/
@@ -64,9 +65,9 @@ public class RepresentationModelProcessorInvoker {
if (rawType != null) {
if (EntityModel.class.isAssignableFrom(rawType)) {
this.processors.add(new ResourceProcessorWrapper(processor));
this.processors.add(new EntityModelProcessorWrapper(processor));
} else if (CollectionModel.class.isAssignableFrom(rawType)) {
this.processors.add(new ResourcesProcessorWrapper(processor));
this.processors.add(new CollectionModelProcessorWrapper(processor));
} else {
this.processors.add(new DefaultProcessorWrapper(processor));
}
@@ -104,9 +105,10 @@ public class RepresentationModelProcessorInvoker {
Assert.notNull(referenceType, "Reference type must not be null!");
// For Resources implementations, process elements first
if (RepresentationModelProcessorHandlerMethodReturnValueHandler.RESOURCES_TYPE.isAssignableFrom(referenceType)) {
if (RepresentationModelProcessorHandlerMethodReturnValueHandler.COLLECTION_MODEL_TYPE
.isAssignableFrom(referenceType)) {
CollectionModel<?> resources = (CollectionModel<?>) value;
CollectionModel<?> collectionModel = (CollectionModel<?>) value;
Class<?> rawClass = referenceType.getRawClass();
if (rawClass == null) {
@@ -114,9 +116,9 @@ public class RepresentationModelProcessorInvoker {
}
ResolvableType elementTargetType = ResolvableType.forClass(CollectionModel.class, rawClass).getGeneric(0);
List<Object> result = new ArrayList<>(resources.getContent().size());
List<Object> result = new ArrayList<>(collectionModel.getContent().size());
for (Object element : resources) {
for (Object element : collectionModel) {
ResolvableType elementType = ResolvableType.forClass(element.getClass());
@@ -128,8 +130,8 @@ public class RepresentationModelProcessorInvoker {
}
if (RepresentationModelProcessorHandlerMethodReturnValueHandler.CONTENT_FIELD != null) {
ReflectionUtils.setField(RepresentationModelProcessorHandlerMethodReturnValueHandler.CONTENT_FIELD, resources,
result);
ReflectionUtils.setField(RepresentationModelProcessorHandlerMethodReturnValueHandler.CONTENT_FIELD,
collectionModel, result);
}
}
@@ -269,14 +271,14 @@ public class RepresentationModelProcessorInvoker {
*
* @author Oliver Gierke
*/
private static class ResourceProcessorWrapper extends RepresentationModelProcessorInvoker.DefaultProcessorWrapper {
private static class EntityModelProcessorWrapper extends RepresentationModelProcessorInvoker.DefaultProcessorWrapper {
/**
* Creates a new {@link ResourceProcessorWrapper} for the given {@link RepresentationModelProcessor}.
* Creates a new {@link EntityModelProcessorWrapper} for the given {@link RepresentationModelProcessor}.
*
* @param processor must not be {@literal null}.
*/
public ResourceProcessorWrapper(RepresentationModelProcessor<?> processor) {
public EntityModelProcessorWrapper(RepresentationModelProcessor<?> processor) {
super(processor);
}
@@ -287,7 +289,7 @@ public class RepresentationModelProcessorInvoker {
@Override
public boolean supports(ResolvableType type, Object value) {
if (!RepresentationModelProcessorHandlerMethodReturnValueHandler.RESOURCE_TYPE.isAssignableFrom(type)) {
if (!RepresentationModelProcessorHandlerMethodReturnValueHandler.ENTITY_MODEL_TYPE.isAssignableFrom(type)) {
return false;
}
@@ -298,24 +300,26 @@ public class RepresentationModelProcessorInvoker {
* Returns whether the given {@link EntityModel} matches the given target {@link ResolvableType}. We inspect the
* {@link EntityModel}'s value to determine the match.
*
* @param resource can be {@literal null}.
* @param entityModel can be {@literal null}.
* @param target can be {@literal null}.
* @return whether the given {@link EntityModel} can be assigned to the given target {@link ResolvableType}
*/
private static boolean isValueTypeMatch(@Nullable EntityModel<?> resource, @Nullable ResolvableType target) {
private static boolean isValueTypeMatch(@Nullable EntityModel<?> entityModel, @Nullable ResolvableType target) {
if (resource == null || !isRawTypeAssignable(target, resource.getClass())) {
if (entityModel == null || !isRawTypeAssignable(target, entityModel.getClass())) {
return false;
}
Object content = resource.getContent();
Object content = entityModel.getContent();
if (content == null) {
return false;
}
ResolvableType type = findGenericType(target, EntityModel.class);
return type != null && type.getGeneric(0).isAssignableFrom(ResolvableType.forClass(content.getClass()));
return target.isAssignableFrom(content.getClass()) || //
(type != null && type.getGeneric(0).isAssignableFrom(ResolvableType.forClass(content.getClass())));
}
@Nullable
@@ -345,14 +349,15 @@ public class RepresentationModelProcessorInvoker {
*
* @author Oliver Gierke
*/
public static class ResourcesProcessorWrapper extends RepresentationModelProcessorInvoker.DefaultProcessorWrapper {
public static class CollectionModelProcessorWrapper
extends RepresentationModelProcessorInvoker.DefaultProcessorWrapper {
/**
* Creates a new {@link ResourcesProcessorWrapper} for the given {@link RepresentationModelProcessor}.
* Creates a new {@link CollectionModelProcessorWrapper} for the given {@link RepresentationModelProcessor}.
*
* @param processor must not be {@literal null}.
*/
public ResourcesProcessorWrapper(RepresentationModelProcessor<?> processor) {
public CollectionModelProcessorWrapper(RepresentationModelProcessor<?> processor) {
super(processor);
}
@@ -363,28 +368,31 @@ public class RepresentationModelProcessorInvoker {
@Override
public boolean supports(ResolvableType type, Object value) {
if (!RepresentationModelProcessorHandlerMethodReturnValueHandler.RESOURCES_TYPE.isAssignableFrom(type)) {
if (!RepresentationModelProcessorHandlerMethodReturnValueHandler.COLLECTION_MODEL_TYPE.isAssignableFrom(type)) {
return false;
}
return super.supports(type, value) && isValueTypeMatch((CollectionModel<?>) value, getTargetType());
boolean defaultSupports = super.supports(type, value);
boolean valueTypeMatch = isValueTypeMatch((CollectionModel<?>) value, getTargetType());
return defaultSupports && valueTypeMatch;
}
/**
* Returns whether the given {@link CollectionModel} instance matches the given {@link ResolvableType}. We predict
* this by inspecting the first element of the content of the {@link CollectionModel}.
*
* @param resources the {@link CollectionModel} to inspect.
* @param collectionModel the {@link CollectionModel} to inspect.
* @param target that target {@link ResolvableType}.
* @return
*/
static boolean isValueTypeMatch(@Nullable CollectionModel<?> resources, ResolvableType target) {
if (resources == null) {
static boolean isValueTypeMatch(@Nullable CollectionModel<?> collectionModel, ResolvableType target) {
if (collectionModel == null) {
return false;
}
Collection<?> content = resources.getContent();
Collection<?> content = collectionModel.getContent();
if (content.isEmpty()) {
return false;
@@ -392,9 +400,9 @@ public class RepresentationModelProcessorInvoker {
ResolvableType superType = null;
for (Class<?> resourcesType : Arrays.<Class<?>> asList(resources.getClass(), CollectionModel.class)) {
for (Class<?> collectionModelType : Arrays.<Class<?>> asList(collectionModel.getClass(), CollectionModel.class)) {
superType = getSuperType(target, resourcesType);
superType = getSuperType(target, collectionModelType);
if (superType != null) {
break;
@@ -409,7 +417,7 @@ public class RepresentationModelProcessorInvoker {
ResolvableType resourceType = superType.getGeneric(0);
if (element instanceof EntityModel) {
return ResourceProcessorWrapper.isValueTypeMatch((EntityModel<?>) element, resourceType);
return EntityModelProcessorWrapper.isValueTypeMatch((EntityModel<?>) element, resourceType);
} else if (element instanceof EmbeddedWrapper) {
return isRawTypeAssignable(resourceType, ((EmbeddedWrapper) element).getRelTargetType());
}

View File

@@ -1,86 +0,0 @@
/*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.hateoas.server.mvc;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import org.springframework.hateoas.server.RepresentationModelProcessor;
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 RepresentationModelProcessorHandlerMethodReturnValueHandler} which will invoke the
* {@link RepresentationModelProcessor} s found in the application context and eventually delegate to the originally
* configured {@link HandlerMethodReturnValueHandler}.
* <p/>
* 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 RepresentationModelProcessorInvokingHandlerAdapter extends RequestMappingHandlerAdapter {
private @NonNull final RepresentationModelProcessorInvoker 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<HandlerMethodReturnValueHandler> newHandlers = new ArrayList<>();
newHandlers.add(new RepresentationModelProcessorHandlerMethodReturnValueHandler(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 = this.getReturnValueHandlers();
if (handlers instanceof HandlerMethodReturnValueHandlerComposite) {
return (HandlerMethodReturnValueHandlerComposite) handlers;
}
return new HandlerMethodReturnValueHandlerComposite() //
.addHandlers((List<? extends HandlerMethodReturnValueHandler>) handlers);
}
}

View File

@@ -0,0 +1,216 @@
/*
* 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.server.mvc;
import static org.assertj.core.api.AssertionsForInterfaceTypes.*;
import static org.springframework.hateoas.MediaTypes.*;
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 lombok.Getter;
import lombok.Setter;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.client.LinkDiscoverer;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.mediatype.hal.HalLinkDiscoverer;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.hateoas.support.Employee;
import org.springframework.hateoas.support.WebMvcEmployeeController;
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.servlet.MockMvc;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* @author Greg Turnquist
*/
@RunWith(SpringRunner.class)
@WebAppConfiguration
@ContextConfiguration
public class RepresentationModelProcessorIntegrationTest {
private static final LinkRelation COLLECTION_LINK_RELATION = LinkRelation.of("collection-processor");
private static final LinkRelation ENTITY_LINK_RELATION = LinkRelation.of("entity-processor");
private static final LinkRelation WILDCARD_LINK_RELATION = LinkRelation.of("wildcard-processor");
private static final LinkDiscoverer DISCOVERER = new HalLinkDiscoverer();
private @Autowired WebApplicationContext context;
private @Autowired CollectionModelProcessor collectionModelProcessor;
private @Autowired EntityModelProcessor entityModelProcessor;
private @Autowired NonSpecificDomainObjectProcessor wildcardProcessor;
private MockMvc mockMvc;
@Before
public void setUp() {
this.mockMvc = webAppContextSetup(this.context).build();
WebMvcEmployeeController.reset();
collectionModelProcessor.setTriggered(false);
entityModelProcessor.setTriggered(false);
wildcardProcessor.setTriggered(false);
}
@Test
public void collectionModelProcessorShouldWork() throws Exception {
String results = this.mockMvc.perform(get("/employees").accept(HAL_JSON)) //
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, HAL_JSON.toString() + ";charset=UTF-8")) //
.andReturn() //
.getResponse() //
.getContentAsString();
assertThat(DISCOVERER.findRequiredLinkWithRel(COLLECTION_LINK_RELATION, results).getHref())
.isEqualTo("/collection/link");
assertThat(DISCOVERER.findLinkWithRel(ENTITY_LINK_RELATION, results)).isEmpty();
assertThat(collectionModelProcessor.isTriggered()).isTrue();
assertThat(entityModelProcessor.isTriggered()).isTrue();
assertThat(wildcardProcessor.isTriggered()).isTrue();
}
@Test
public void entityModelProcessorShouldWork() throws Exception {
String results = this.mockMvc.perform(get("/employees/1").accept(HAL_JSON)) //
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, HAL_JSON.toString() + ";charset=UTF-8")) //
.andReturn() //
.getResponse() //
.getContentAsString();
assertThat(DISCOVERER.findRequiredLinkWithRel(ENTITY_LINK_RELATION, results).getHref()).isEqualTo("/entity/link");
assertThat(collectionModelProcessor.isTriggered()).isFalse();
assertThat(entityModelProcessor.isTriggered()).isTrue();
assertThat(wildcardProcessor.isTriggered()).isFalse();
}
@Test
public void wildcardProcessorShouldNotWork() throws Exception {
String results = this.mockMvc.perform(get("/employees").accept(HAL_JSON)) //
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, HAL_JSON.toString() + ";charset=UTF-8")) //
.andReturn() //
.getResponse() //
.getContentAsString();
System.out.println(results);
assertThat(DISCOVERER.findRequiredLinkWithRel(WILDCARD_LINK_RELATION, results).getHref())
.isEqualTo("/non-specific-collection/link");
assertThat(collectionModelProcessor.isTriggered()).isTrue();
assertThat(entityModelProcessor.isTriggered()).isTrue();
assertThat(wildcardProcessor.isTriggered()).isTrue();
}
static class EntityModelProcessor implements RepresentationModelProcessor<EntityModel<Employee>> {
private @Getter @Setter boolean triggered = false;
@Override
public EntityModel<Employee> process(EntityModel<Employee> model) {
triggered = true;
model.add(new Link("/entity/link", ENTITY_LINK_RELATION));
return model;
}
}
static class CollectionModelProcessor
implements RepresentationModelProcessor<CollectionModel<EntityModel<Employee>>> {
private @Getter @Setter boolean triggered = false;
@Override
public CollectionModel<EntityModel<Employee>> process(CollectionModel<EntityModel<Employee>> model) {
triggered = true;
model.add(new Link("/collection/link", COLLECTION_LINK_RELATION));
return model;
}
}
static class NonSpecificDomainObjectProcessor implements RepresentationModelProcessor<CollectionModel<?>> {
private @Getter @Setter boolean triggered = false;
@Override
public CollectionModel<?> process(CollectionModel<?> model) {
triggered = true;
model.add(new Link("/non-specific-collection/link", WILDCARD_LINK_RELATION));
return model;
}
}
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type = HypermediaType.HAL)
static class TestConfig {
@Bean
EntityModelProcessor entityModelProcessor() {
return new EntityModelProcessor();
}
@Bean
CollectionModelProcessor collectionModelProcessor() {
return new CollectionModelProcessor();
}
@Bean
NonSpecificDomainObjectProcessor wildcardProcessor() {
return new NonSpecificDomainObjectProcessor();
}
@Bean
WebMvcEmployeeController employeeController() {
return new WebMvcEmployeeController();
}
@Bean
ObjectMapper testMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
return objectMapper;
}
}
}

View File

@@ -46,7 +46,7 @@ import org.springframework.hateoas.PagedModel.PageMetadata;
import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.hateoas.server.core.EmbeddedWrappers;
import org.springframework.hateoas.server.core.HeaderLinksResponseEntity;
import org.springframework.hateoas.server.mvc.RepresentationModelProcessorInvoker.ResourcesProcessorWrapper;
import org.springframework.hateoas.server.mvc.RepresentationModelProcessorInvoker.CollectionModelProcessorWrapper;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -63,19 +63,17 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
static final EntityModel<String> FOO = new EntityModel<>("foo");
static final CollectionModel<EntityModel<String>> FOOS = new CollectionModel<>(Collections.singletonList(FOO));
static final PagedModel<EntityModel<String>> FOO_PAGE = new PagedModel<>(
Collections.singleton(FOO), new PageMetadata(1, 0, 10));
static final PagedModel<EntityModel<String>> FOO_PAGE = new PagedModel<>(Collections.singleton(FOO),
new PageMetadata(1, 0, 10));
static final StringResource FOO_RES = new StringResource("foo");
static final HttpEntity<EntityModel<String>> FOO_ENTITY = new HttpEntity<>(FOO);
static final ResponseEntity<EntityModel<String>> FOO_RESP_ENTITY = new ResponseEntity<>(FOO,
HttpStatus.OK);
static final ResponseEntity<EntityModel<String>> FOO_RESP_ENTITY = new ResponseEntity<>(FOO, HttpStatus.OK);
static final HttpEntity<StringResource> FOO_RES_ENTITY = new HttpEntity<>(FOO_RES);
static final EntityModel<String> BAR = new EntityModel<>("bar");
static final CollectionModel<EntityModel<String>> BARS = new CollectionModel<>(Collections.singletonList(BAR));
static final StringResource BAR_RES = new StringResource("bar");
static final HttpEntity<EntityModel<String>> BAR_ENTITY = new HttpEntity<>(BAR);
static final ResponseEntity<EntityModel<String>> BAR_RESP_ENTITY = new ResponseEntity<>(BAR,
HttpStatus.OK);
static final ResponseEntity<EntityModel<String>> BAR_RESP_ENTITY = new ResponseEntity<>(BAR, HttpStatus.OK);
static final HttpEntity<StringResource> BAR_RES_ENTITY = new HttpEntity<>(BAR_RES);
static final EntityModel<Long> LONG_10 = new EntityModel<>(10L);
static final EntityModel<Long> LONG_20 = new EntityModel<>(20L);
@@ -273,7 +271,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
ResolvableType type = ResolvableType.forClass(PagedStringResources.class);
assertThat(ResourcesProcessorWrapper.isValueTypeMatch(FOO_PAGE, type)).isTrue();
assertThat(CollectionModelProcessorWrapper.isValueTypeMatch(FOO_PAGE, type)).isTrue();
}
/**
@@ -284,8 +282,8 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
CollectionModel<Object> value = new CollectionModel<>(
Collections.singleton(wrappers.emptyCollectionOf(Object.class)));
ResourcesProcessorWrapper wrapper = new ResourcesProcessorWrapper(new SpecialResourcesProcessor());
Collections.singleton(wrappers.emptyCollectionOf(Object.class)));
CollectionModelProcessorWrapper wrapper = new CollectionModelProcessorWrapper(new SpecialResourcesProcessor());
ResolvableType type = ResolvableType.forMethodReturnType(Controller.class.getMethod("resourcesOfObject"));
@@ -303,7 +301,8 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
resourceProcessors.add((RepresentationModelProcessor<?>) factory.getProxy());
new RepresentationModelProcessorHandlerMethodReturnValueHandler(delegate, new RepresentationModelProcessorInvoker(resourceProcessors));
new RepresentationModelProcessorHandlerMethodReturnValueHandler(delegate,
new RepresentationModelProcessorInvoker(resourceProcessors));
}
/**
@@ -347,7 +346,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
INSTANCE;
@Override
public EntityModel<String> process(EntityModel<String> resource) {
public EntityModel<String> process(EntityModel<String> model) {
return BAR;
}
}
@@ -356,7 +355,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
INSTANCE;
@Override
public EntityModel<Long> process(EntityModel<Long> resource) {
public EntityModel<Long> process(EntityModel<Long> model) {
return LONG_20;
}
}
@@ -365,7 +364,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
INSTANCE;
@Override
public CollectionModel<EntityModel<String>> process(CollectionModel<EntityModel<String>> resource) {
public CollectionModel<EntityModel<String>> process(CollectionModel<EntityModel<String>> model) {
return BARS;
}
}
@@ -374,7 +373,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
INSTANCE;
@Override
public StringResource process(StringResource resource) {
public StringResource process(StringResource model) {
return BAR_RES;
}
}
@@ -383,7 +382,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
INSTANCE;
@Override
public LongResource process(LongResource resource) {
public LongResource process(LongResource model) {
return LONG_20_RES;
}
}
@@ -450,9 +449,9 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
boolean invoked = false;
@Override
public EntityModel<SampleProjection> process(EntityModel<SampleProjection> resource) {
public EntityModel<SampleProjection> process(EntityModel<SampleProjection> model) {
this.invoked = true;
return resource;
return model;
}
}
@@ -467,9 +466,9 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest {
boolean invoked = false;
@Override
public SpecialResources process(SpecialResources resource) {
public SpecialResources process(SpecialResources model) {
this.invoked = true;
return resource;
return model;
}
}
}