diff --git a/src/docs/java/org/springframework/hateoas/PaymentProcessingApp.java b/src/docs/java/org/springframework/hateoas/PaymentProcessingApp.java new file mode 100644 index 00000000..3aa73e01 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/PaymentProcessingApp.java @@ -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[] diff --git a/src/docs/java/org/springframework/hateoas/PaymentProcessor.java b/src/docs/java/org/springframework/hateoas/PaymentProcessor.java new file mode 100644 index 00000000..208c4407 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/PaymentProcessor.java @@ -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> { // <1> + + @Override + public EntityModel process(EntityModel model) { + + model.add( // <2> + new Link("/payments/{orderId}").withRel(LinkRelation.of("payments")) // + .expand(model.getContent().getOrderId())); + + return model; // <3> + } +} +// end::code[] diff --git a/src/docs/java/org/springframework/hateoas/support/Customer.java b/src/docs/java/org/springframework/hateoas/support/Customer.java new file mode 100644 index 00000000..19875bf4 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/support/Customer.java @@ -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; +} diff --git a/src/docs/java/org/springframework/hateoas/support/Order.java b/src/docs/java/org/springframework/hateoas/support/Order.java new file mode 100644 index 00000000..390fd35c --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/support/Order.java @@ -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; +} + diff --git a/src/docs/java/org/springframework/hateoas/support/Payment.java b/src/docs/java/org/springframework/hateoas/support/Payment.java new file mode 100644 index 00000000..982ac338 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/support/Payment.java @@ -0,0 +1,29 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.support; + +import lombok.RequiredArgsConstructor; +import lombok.Value; + +/** + * @author Greg Turnquist + */ +@Value +@RequiredArgsConstructor +public class Payment { + private final Order order; + private final double amount; +} diff --git a/src/docs/java/org/springframework/hateoas/support/PaymentController.java b/src/docs/java/org/springframework/hateoas/support/PaymentController.java new file mode 100644 index 00000000..1d1ea8ea --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/support/PaymentController.java @@ -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 { + +} diff --git a/src/docs/resources/org/springframework/hateoas/docs/order-plain.json b/src/docs/resources/org/springframework/hateoas/docs/order-plain.json new file mode 100644 index 00000000..0d57b3ba --- /dev/null +++ b/src/docs/resources/org/springframework/hateoas/docs/order-plain.json @@ -0,0 +1,9 @@ +{ + "orderId" : "42", + "state" : "AWAITING_PAYMENT", + "_links" : { + "self" : { + "href" : "http://localhost/orders/999" + } + } +} \ No newline at end of file diff --git a/src/docs/resources/org/springframework/hateoas/docs/order-with-payment-link.json b/src/docs/resources/org/springframework/hateoas/docs/order-with-payment-link.json new file mode 100644 index 00000000..4332e5cb --- /dev/null +++ b/src/docs/resources/org/springframework/hateoas/docs/order-with-payment-link.json @@ -0,0 +1,12 @@ +{ + "orderId" : "42", + "state" : "AWAITING_PAYMENT", + "_links" : { + "self" : { + "href" : "http://localhost/orders/999" + }, + "payments" : { // <1> + "href" : "/payments/42" // <2> + } + } +} \ No newline at end of file diff --git a/src/main/asciidoc/server.adoc b/src/main/asciidoc/server.adoc index 98df9893..7bf4ad6b 100644 --- a/src/main/asciidoc/server.adoc +++ b/src/main/asciidoc/server.adoc @@ -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 <> 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 model = assembler.toCollectionModel(people); ---- ==== +[[server.processors]] +== Representation Model Processors + +Sometimes you need to tweak and adjust hypermedia representations after they have been <>. + +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` 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`. 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 diff --git a/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java index 98bf8798..11d52b05 100644 --- a/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/WebMvcHateoasConfiguration.java @@ -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> processors) { + + return new HypermediaRepresentationModelBeanProcessorPostProcessor(processors); + } + @Bean static HypermediaRestTemplateBeanPostProcessor restTemplateBeanPostProcessor( ObjectProvider configurer) { @@ -92,6 +105,34 @@ class WebMvcHateoasConfiguration { } } + /** + * @author Greg Turnquist + */ + @RequiredArgsConstructor + static class HypermediaRepresentationModelBeanProcessorPostProcessor implements BeanPostProcessor { + + private final List> 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. diff --git a/src/main/java/org/springframework/hateoas/server/RepresentationModelProcessor.java b/src/main/java/org/springframework/hateoas/server/RepresentationModelProcessor.java index 0a96542e..175766d6 100644 --- a/src/main/java/org/springframework/hateoas/server/RepresentationModelProcessor.java +++ b/src/main/java/org/springframework/hateoas/server/RepresentationModelProcessor.java @@ -32,8 +32,8 @@ public interface RepresentationModelProcessor> /** * 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); } diff --git a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorHandlerMethodReturnValueHandler.java b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorHandlerMethodReturnValueHandler.java index 57aa90cf..8660cf7b 100644 --- a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorHandlerMethodReturnValueHandler.java +++ b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorHandlerMethodReturnValueHandler.java @@ -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"); diff --git a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java index 1c676433..0ca7352d 100644 --- a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java +++ b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java @@ -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 result = new ArrayList<>(resources.getContent().size()); + List 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.> asList(resources.getClass(), CollectionModel.class)) { + for (Class collectionModelType : Arrays.> 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()); } diff --git a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokingHandlerAdapter.java b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokingHandlerAdapter.java deleted file mode 100644 index 3b017163..00000000 --- a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokingHandlerAdapter.java +++ /dev/null @@ -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}. - *

- * 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 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) handlers); - } -} diff --git a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java new file mode 100644 index 00000000..df3efb45 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java @@ -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> { + + private @Getter @Setter boolean triggered = false; + + @Override + public EntityModel process(EntityModel model) { + + triggered = true; + model.add(new Link("/entity/link", ENTITY_LINK_RELATION)); + return model; + } + } + + static class CollectionModelProcessor + implements RepresentationModelProcessor>> { + + private @Getter @Setter boolean triggered = false; + + @Override + public CollectionModel> process(CollectionModel> model) { + + triggered = true; + model.add(new Link("/collection/link", COLLECTION_LINK_RELATION)); + return model; + } + } + + static class NonSpecificDomainObjectProcessor implements RepresentationModelProcessor> { + + 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; + } + } +} diff --git a/src/test/java/org/springframework/hateoas/server/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java b/src/test/java/org/springframework/hateoas/server/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java index 742a9261..dfb0befe 100644 --- a/src/test/java/org/springframework/hateoas/server/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java +++ b/src/test/java/org/springframework/hateoas/server/mvc/ResourceProcessorHandlerMethodReturnValueHandlerUnitTest.java @@ -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 FOO = new EntityModel<>("foo"); static final CollectionModel> FOOS = new CollectionModel<>(Collections.singletonList(FOO)); - static final PagedModel> FOO_PAGE = new PagedModel<>( - Collections.singleton(FOO), new PageMetadata(1, 0, 10)); + static final PagedModel> FOO_PAGE = new PagedModel<>(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 ResponseEntity> FOO_RESP_ENTITY = new ResponseEntity<>(FOO, HttpStatus.OK); static final HttpEntity FOO_RES_ENTITY = new HttpEntity<>(FOO_RES); static final EntityModel BAR = new EntityModel<>("bar"); static final CollectionModel> BARS = new CollectionModel<>(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 ResponseEntity> BAR_RESP_ENTITY = new ResponseEntity<>(BAR, HttpStatus.OK); static final HttpEntity BAR_RES_ENTITY = new HttpEntity<>(BAR_RES); static final EntityModel LONG_10 = new EntityModel<>(10L); static final EntityModel 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 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 process(EntityModel resource) { + public EntityModel process(EntityModel model) { return BAR; } } @@ -356,7 +355,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest { INSTANCE; @Override - public EntityModel process(EntityModel resource) { + public EntityModel process(EntityModel model) { return LONG_20; } } @@ -365,7 +364,7 @@ class ResourceProcessorHandlerMethodReturnValueHandlerUnitTest { INSTANCE; @Override - public CollectionModel> process(CollectionModel> resource) { + public CollectionModel> process(CollectionModel> 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 process(EntityModel resource) { + public EntityModel process(EntityModel 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; } } }