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 {