From a1d2752dd09d8cb7e703b6a706ccbd65e10115b3 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Thu, 29 Jul 2021 16:16:39 +0200 Subject: [PATCH] #1590 - Support for explicit type information on CollectionModel. We now allow explicit definition of a fallback collection element type on CollectionModel to be used in cases of an empty model, so that the RepresentationModelProcessor infrastructure can still reason about the element type and also invoke the processor for empty collection models. Fixes #1590. --- src/main/asciidoc/fundamentals.adoc | 14 ++ src/main/asciidoc/server.adoc | 10 +- .../hateoas/CollectionModel.java | 160 +++++++++++++++++- .../springframework/hateoas/PagedModel.java | 147 ++++++++++++++-- .../RepresentationModelProcessorInvoker.java | 7 +- .../hateoas/CollectionModelUnitTest.java | 47 ++++- .../hateoas/PagedModelUnitTest.java | 12 +- ...ntationModelProcessorInvokerUnitTests.java | 12 ++ 8 files changed, 385 insertions(+), 24 deletions(-) diff --git a/src/main/asciidoc/fundamentals.adoc b/src/main/asciidoc/fundamentals.adoc index fcfefd19..1626c704 100644 --- a/src/main/asciidoc/fundamentals.adoc +++ b/src/main/asciidoc/fundamentals.adoc @@ -182,6 +182,7 @@ EntityModel model = EntityModel.of(person); ---- ==== +[[fundamentals.collection-model]] === Collection resource representation model For resources that are conceptually collections, a `CollectionModel` is available. @@ -195,3 +196,16 @@ Collection people = Collections.singleton(new Person("Dave", "Matthews") CollectionModel model = CollectionModel.of(people); ---- ==== + +While an `EntityModel` is constrained to always contain a payload and thus allows to reason about the type arrangement on the sole instance, a ``CollectionModel``'s underlying collection can be empty. +Due to Java's type erasure, we cannot actually detect that a `CollectionModel model = CollectionModel.empty()` is actually a `CollectionModel` because all we see is the runtime instance and an empty collection. +That missing type information can be added to the model by either adding it to the empty instance on construction via `CollectionModel.empty(Person.class)` or as fallback in case the underlying collection might be empty: + +==== +[source, java] +---- +Iterable people = repository.findAll(); +var model = CollectionModel.of(people).withFallbackType(Person.class); +---- +==== + diff --git a/src/main/asciidoc/server.adoc b/src/main/asciidoc/server.adoc index 6e3cef1e..1b111460 100644 --- a/src/main/asciidoc/server.adoc +++ b/src/main/asciidoc/server.adoc @@ -548,7 +548,6 @@ 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: ==== @@ -590,6 +589,15 @@ This example is quite simple, but you can easily: 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.processors.empty-collections]] +=== Processing empty collection models + +To find the right set of ``RepresentationModelProcessor`` instance to invoke for a `RepresentationModel` instance, the invoking infrastructure performs a detailed analysis of the generics declaration of the ``RepresentationModelProcessor``s registered. +For `CollectionModel` instances, this includes inspecting the elements of the underlying collection, as at runtime, the sole model instance does not expose generics information (due to Java's type erasure). +That means, by default, `RepresentationModelProcessor` instances are not invoked for empty collection models. +To still allow the infrastructure to deduce the payload types correctly, you can initialize empty `CollectionModel` instances with an explicit fallback payload type right from the start, or register it by calling `CollectionModel.withFallbackType(…)`. +See <> for details. + [[server.rel-provider]] == [[spis.rel-provider]] Using the `LinkRelationProvider` API diff --git a/src/main/java/org/springframework/hateoas/CollectionModel.java b/src/main/java/org/springframework/hateoas/CollectionModel.java index 59d672af..c98dda6d 100644 --- a/src/main/java/org/springframework/hateoas/CollectionModel.java +++ b/src/main/java/org/springframework/hateoas/CollectionModel.java @@ -20,10 +20,17 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Iterator; +import java.util.Objects; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.ResolvableTypeProvider; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; /** @@ -32,9 +39,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @author Oliver Gierke * @author Greg Turnquist */ -public class CollectionModel extends RepresentationModel> implements Iterable { +public class CollectionModel extends RepresentationModel> + implements Iterable, ResolvableTypeProvider { private final Collection content; + private final @Nullable ResolvableType fallbackType; + private ResolvableType fullType; /** * Creates an empty {@link CollectionModel} instance. @@ -64,6 +74,10 @@ public class CollectionModel extends RepresentationModel> */ @Deprecated public CollectionModel(Iterable content, Iterable links) { + this(content, links, null); + } + + protected CollectionModel(Iterable content, Iterable links, @Nullable ResolvableType fallbackType) { Assert.notNull(content, "Content must not be null!"); @@ -74,6 +88,7 @@ public class CollectionModel extends RepresentationModel> } this.add(links); + this.fallbackType = fallbackType; } /** @@ -87,6 +102,42 @@ public class CollectionModel extends RepresentationModel> return of(Collections.emptyList()); } + /** + * Creates a new empty collection model with the given type defined as fallback type. + * + * @param + * @return + * @since 1.4 + * @see #withFallbackType(Class, Class...) + */ + public static CollectionModel empty(Class elementType, Class... generics) { + return empty(ResolvableType.forClassWithGenerics(elementType, generics)); + } + + /** + * Creates a new empty collection model with the given type defined as fallback type. + * + * @param + * @return + * @since 1.4 + * @see #withFallbackType(ParameterizedTypeReference) + */ + public static CollectionModel empty(ParameterizedTypeReference type) { + return empty(ResolvableType.forType(type.getType())); + } + + /** + * Creates a new empty collection model with the given type defined as fallback type. + * + * @param + * @return + * @since 1.4 + * @see #withFallbackType(ResolvableType) + */ + public static CollectionModel empty(ResolvableType elementType) { + return new CollectionModel<>(Collections.emptyList(), Collections.emptyList(), elementType); + } + /** * Creates a new empty collection model with the given links. * @@ -117,6 +168,8 @@ public class CollectionModel extends RepresentationModel> * @param content must not be {@literal null}. * @return * @since 1.1 + * @see #withFallbackType(Class) + * @see #withFallbackType(ResolvableType) */ public static CollectionModel of(Iterable content) { return of(content, Collections.emptyList()); @@ -129,6 +182,8 @@ public class CollectionModel extends RepresentationModel> * @param links the links to be added to the {@link CollectionModel}. * @return * @since 1.1 + * @see #withFallbackType(Class) + * @see #withFallbackType(ResolvableType) */ public static CollectionModel of(Iterable content, Link... links) { return of(content, Arrays.asList(links)); @@ -141,6 +196,8 @@ public class CollectionModel extends RepresentationModel> * @param links the links to be added to the {@link CollectionModel}. * @return * @since 1.1 + * @see #withFallbackType(Class) + * @see #withFallbackType(ResolvableType) */ public static CollectionModel of(Iterable content, Iterable links) { return new CollectionModel<>(content, links); @@ -177,6 +234,74 @@ public class CollectionModel extends RepresentationModel> return Collections.unmodifiableCollection(content); } + /** + * Declares the given type as fallback element type in case the underlying collection is empty. This allows client + * components to still apply type matches at runtime. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public CollectionModel withFallbackType(Class type, Class... generics) { + + Assert.notNull(type, "Fallback type must not be null!"); + Assert.notNull(generics, "Generics must not be null!"); + + return withFallbackType(ResolvableType.forClassWithGenerics(type, generics)); + } + + /** + * Declares the given type as fallback element type in case the underlying collection is empty. This allows client + * components to still apply type matches at runtime. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public CollectionModel withFallbackType(ParameterizedTypeReference type) { + + Assert.notNull(type, "Fallback type must not be null!"); + + return withFallbackType(ResolvableType.forType(type)); + } + + /** + * Declares the given type as fallback element type in case the underlying collection is empty. This allows client + * components to still apply type matches at runtime. + * + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + */ + public CollectionModel withFallbackType(ResolvableType type) { + + Assert.notNull(type, "Fallback type must not be null!"); + + return new CollectionModel<>(content, getLinks(), type); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.ResolvableTypeProvider#getResolvableType() + */ + @NonNull + @Override + @JsonIgnore + public ResolvableType getResolvableType() { + + if (fullType == null) { + + ResolvableType elementType = deriveElementType(this.content, fallbackType); + Class type = this.getClass(); + + this.fullType = elementType == null || type.getTypeParameters().length == 0 // + ? ResolvableType.forClass(type) // + : ResolvableType.forClassWithGenerics(type, elementType); + } + + return fullType; + } + /* * (non-Javadoc) * @see java.lang.Iterable#iterator() @@ -192,7 +317,9 @@ public class CollectionModel extends RepresentationModel> */ @Override public String toString() { - return String.format("CollectionModel { content: %s, %s }", getContent(), super.toString()); + + return String.format("CollectionModel { content: %s, fallbackType: %s, %s }", // + getContent(), fallbackType, super.toString()); } /* @@ -212,8 +339,9 @@ public class CollectionModel extends RepresentationModel> CollectionModel that = (CollectionModel) obj; - boolean contentEqual = this.content == null ? that.content == null : this.content.equals(that.content); - return contentEqual && super.equals(obj); + return Objects.equals(this.content, that.content) + && Objects.equals(this.fallbackType, that.fallbackType) + && super.equals(obj); } /* @@ -222,10 +350,28 @@ public class CollectionModel extends RepresentationModel> */ @Override public int hashCode() { + return super.hashCode() + Objects.hash(content, fallbackType); + } - int result = super.hashCode(); - result += content == null ? 0 : 17 * content.hashCode(); + /** + * Determines the most common element type from the given elements defaulting to the given fallback type. + * + * @param elements must not be {@literal null}. + * @param fallbackType can be {@literal null}. + * @return + */ + @Nullable + private static ResolvableType deriveElementType(Collection elements, @Nullable ResolvableType fallbackType) { - return result; + if (elements.isEmpty()) { + return fallbackType; + } + + return elements.stream() + .filter(it -> it != null) + .> map(Object::getClass) + .reduce(ClassUtils::determineCommonAncestor) + .map(ResolvableType::forClass) + .orElse(fallbackType); } } diff --git a/src/main/java/org/springframework/hateoas/PagedModel.java b/src/main/java/org/springframework/hateoas/PagedModel.java index 9971b36c..00cd520d 100644 --- a/src/main/java/org/springframework/hateoas/PagedModel.java +++ b/src/main/java/org/springframework/hateoas/PagedModel.java @@ -19,8 +19,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Objects; import java.util.Optional; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -37,7 +40,8 @@ public class PagedModel extends CollectionModel { public static PagedModel NO_PAGE = new PagedModel<>(); - private PageMetadata metadata; + private final PageMetadata metadata; + private final @Nullable ResolvableType fallbackType; /** * Default constructor to allow instantiation by reflection. @@ -69,23 +73,69 @@ public class PagedModel extends CollectionModel { */ @Deprecated public PagedModel(Collection content, @Nullable PageMetadata metadata, Iterable links) { + this(content, metadata, links, null); + } - super(content, links); + protected PagedModel(Collection content, @Nullable PageMetadata metadata, Iterable links, + @Nullable ResolvableType fallbackType) { + + super(content, links, fallbackType); this.metadata = metadata; + this.fallbackType = fallbackType; } /** * Creates an empty {@link PagedModel}. * * @param - * @return + * @return will never be {@literal null}. * @since 1.1 */ public static PagedModel empty() { return empty(Collections.emptyList()); } + /** + * Creates an empty {@link PagedModel} with the given fallback type. + * + * @param + * @param fallbackElementType must not be {@literal null}. + * @param generics must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + * @see #withFallbackType(Class, Class...) + */ + public static PagedModel empty(Class fallbackElementType, Class generics) { + return empty(ResolvableType.forClassWithGenerics(fallbackElementType, generics)); + } + + /** + * Creates an empty {@link PagedModel} with the given fallback type. + * + * @param + * @param fallbackElementType must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + * @see #withFallbackType(ParameterizedTypeReference) + */ + public static PagedModel empty(ParameterizedTypeReference fallbackElementType) { + return empty(ResolvableType.forType(fallbackElementType)); + } + + /** + * Creates an empty {@link PagedModel} with the given fallback type. + * + * @param + * @param fallbackElementType must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + * @see #withFallbackType(ResolvableType) + */ + public static PagedModel empty(ResolvableType fallbackElementType) { + return new PagedModel<>(Collections.emptyList(), null, Collections.emptyList(), fallbackElementType); + } + /** * Creates an empty {@link PagedModel} with the given links. * @@ -122,6 +172,58 @@ public class PagedModel extends CollectionModel { return empty(metadata, Collections.emptyList()); } + /** + * Creates an empty {@link PagedModel} with the given {@link PageMetadata} and fallback type. + * + * @param + * @param metadata can be {@literal null}. + * @param fallbackType must not be {@literal null}. + * @param generics must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.4 + * @see #withFallbackType(Class, Class...) + */ + public static PagedModel empty(@Nullable PageMetadata metadata, Class fallbackType, Class... generics) { + + Assert.notNull(fallbackType, "Fallback type must not be null!"); + Assert.notNull(generics, "Generics must not be null!"); + + return empty(metadata, ResolvableType.forClassWithGenerics(fallbackType, generics)); + } + + /** + * Creates an empty {@link PagedModel} with the given {@link PageMetadata} and fallback type. + * + * @param + * @param metadata can be {@literal null}. + * @return + * @since 1.4 + * @see #withFallbackType(ParameterizedTypeReference) + */ + public static PagedModel empty(@Nullable PageMetadata metadata, ParameterizedTypeReference fallbackType) { + + Assert.notNull(fallbackType, "Fallback type must not be null!"); + + return empty(metadata, ResolvableType.forType(fallbackType)); + } + + /** + * Creates an empty {@link PagedModel} with the given {@link PageMetadata} and fallback type. + * + * @param + * @param metadata can be {@literal null}. + * @param fallbackType must not be {@literal null}. + * @return + * @since 1.4 + * @see #withFallbackType(ResolvableType) + */ + public static PagedModel empty(@Nullable PageMetadata metadata, ResolvableType fallbackType) { + + Assert.notNull(fallbackType, "Fallback type must not be null!"); + + return new PagedModel<>(Collections.emptyList(), metadata, Collections.emptyList(), fallbackType); + } + /** * Creates an empty {@link PagedModel} with the given {@link PageMetadata} and links. * @@ -232,13 +334,41 @@ public class PagedModel extends CollectionModel { return getLink(IanaLinkRelations.PREV); } + /* + * (non-Javadoc) + * @see org.springframework.hateoas.CollectionModel#withFallbackType(java.lang.Class, java.lang.Class[]) + */ + @Override + public PagedModel withFallbackType(Class type, Class... generics) { + return withFallbackType(ResolvableType.forClassWithGenerics(type, generics)); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.CollectionModel#withFallbackType(org.springframework.core.ParameterizedTypeReference) + */ + @Override + public PagedModel withFallbackType(ParameterizedTypeReference type) { + return withFallbackType(ResolvableType.forType(type)); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.CollectionModel#withFallbackType(org.springframework.core.ResolvableType) + */ + @Override + public PagedModel withFallbackType(ResolvableType type) { + return new PagedModel<>(getContent(), metadata, getLinks(), type); + } + /* * (non-Javadoc) * @see org.springframework.hateoas.RepresentationModel#toString() */ @Override public String toString() { - return String.format("PagedModel { content: %s, metadata: %s, links: %s }", getContent(), metadata, getLinks()); + return String.format("PagedModel { content: %s, fallbackType: %s, metadata: %s, links: %s }", // + getContent(), fallbackType, metadata, getLinks()); } /* @@ -257,9 +387,9 @@ public class PagedModel extends CollectionModel { } PagedModel that = (PagedModel) obj; - boolean metadataEquals = this.metadata == null ? that.metadata == null : this.metadata.equals(that.metadata); - return metadataEquals && super.equals(obj); + return Objects.equals(this.metadata, that.metadata) // + && super.equals(obj); } /* @@ -268,10 +398,7 @@ public class PagedModel extends CollectionModel { */ @Override public int hashCode() { - - int result = super.hashCode(); - result += this.metadata == null ? 0 : 31 * this.metadata.hashCode(); - return result; + return super.hashCode() + Objects.hash(metadata); } /** 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 feee2f34..8816cc2b 100644 --- a/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java +++ b/src/main/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvoker.java @@ -370,9 +370,8 @@ public class RepresentationModelProcessorInvoker { return false; } - boolean defaultSupports = super.supports(type, value); - boolean valueTypeMatch = isValueTypeMatch((CollectionModel) value, getTargetType()); - return defaultSupports && valueTypeMatch; + return super.supports(type, value) // + && isValueTypeMatch((CollectionModel) value, getTargetType()); } /** @@ -392,7 +391,7 @@ public class RepresentationModelProcessorInvoker { Collection content = collectionModel.getContent(); if (content.isEmpty()) { - return false; + return collectionModel.getResolvableType().isAssignableFrom(target); } ResolvableType superType = null; diff --git a/src/test/java/org/springframework/hateoas/CollectionModelUnitTest.java b/src/test/java/org/springframework/hateoas/CollectionModelUnitTest.java index 2f7e65c7..4813d857 100755 --- a/src/test/java/org/springframework/hateoas/CollectionModelUnitTest.java +++ b/src/test/java/org/springframework/hateoas/CollectionModelUnitTest.java @@ -17,14 +17,21 @@ package org.springframework.hateoas; import static org.assertj.core.api.Assertions.*; +import lombok.Value; + +import java.util.Arrays; import java.util.Collections; import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.lang.Nullable; /** * Unit tests for {@link CollectionModel}. - * + * * @author Oliver Gierke */ class CollectionModelUnitTest { @@ -69,4 +76,42 @@ class CollectionModelUnitTest { assertThat(left).isNotEqualTo(right); assertThat(right).isNotEqualTo(left); } + + @TestFactory // #1590 + Stream exposesElementTypeForCollection() { + return DynamicTest.stream(Fixture.probes(), Fixture::toString, Fixture::verify); + } + + @Value(staticConstructor = "$") + static class Fixture { + + CollectionModel model; + @Nullable Class expectedElementType; + + static Stream probes() { + + return Stream.of( + $(CollectionModel.empty(), null), + $(CollectionModel.empty(String.class), String.class), + $(CollectionModel.of(Arrays.asList(new Person())).withFallbackType(Contact.class), Person.class), + $(CollectionModel.of(Arrays.asList(new Person(), new Company())).withFallbackType(Object.class), + Contact.class)); + } + + void verify() { + assertThat(model.getResolvableType().getGeneric(0).resolve()).isEqualTo(expectedElementType); + } + + @Override + public String toString() { + return String.format("Expect element type %s for collection model %s.", expectedElementType, model); + } + } + + static class Contact {} + + static class Person extends Contact {} + + static class Company extends Contact {} + } diff --git a/src/test/java/org/springframework/hateoas/PagedModelUnitTest.java b/src/test/java/org/springframework/hateoas/PagedModelUnitTest.java index 71d01b27..36dd1526 100755 --- a/src/test/java/org/springframework/hateoas/PagedModelUnitTest.java +++ b/src/test/java/org/springframework/hateoas/PagedModelUnitTest.java @@ -21,11 +21,12 @@ import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.ResolvableType; import org.springframework.hateoas.PagedModel.PageMetadata; /** * Unit tests for {@link PagedModel}. - * + * * @author Oliver Gierke */ class PagedModelUnitTest { @@ -114,4 +115,13 @@ class PagedModelUnitTest { void calculatesTotalPagesCorrectly() { assertThat(new PageMetadata(5, 0, 16).getTotalPages()).isEqualTo(4L); } + + @Test // #1590 + void exposesElementTypeForEmpty() { + + ResolvableType fallbackType = ResolvableType.forClassWithGenerics(EntityModel.class, String.class); + PagedModel model = PagedModel.empty(fallbackType); + + assertThat(model.getResolvableType().getGeneric(0).resolve()).isEqualTo(EntityModel.class); + } } diff --git a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokerUnitTests.java b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokerUnitTests.java index 5c93455e..2c70207e 100644 --- a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokerUnitTests.java +++ b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorInvokerUnitTests.java @@ -83,6 +83,18 @@ public class RepresentationModelProcessorInvokerUnitTests { assertThat(processor.invoked).isTrue(); } + @Test // #1590 + void invokesProcessorForEmptyCollectionModelIfFallbackTypeDefined() { + + FirstEntityProcessor processor = new FirstEntityProcessor(); + RepresentationModelProcessorInvoker invoker = new RepresentationModelProcessorInvoker(singletonList(processor)); + CollectionModel model = CollectionModel.empty(EntityModel.class, FirstEntity.class); + + invoker.invokeProcessorsFor(model); + + assertThat(processor.invoked).isTrue(); + } + // #1280 static class GenericPostProcessor> implements RepresentationModelProcessor {