diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java b/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java index 94d29f86..52f724ec 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java @@ -18,6 +18,8 @@ package org.springframework.hateoas.mediatype.hal; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.hateoas.CollectionModel; import org.springframework.hateoas.EntityModel; @@ -26,7 +28,6 @@ import org.springframework.hateoas.LinkRelation; import org.springframework.hateoas.Links; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.RepresentationModel; -import org.springframework.hateoas.server.core.EmbeddedWrapper; import org.springframework.hateoas.server.core.EmbeddedWrappers; import org.springframework.lang.Nullable; import org.springframework.ui.Model; @@ -147,44 +148,105 @@ public class HalModelBuilder { return this; } + /** + * Embeds the given collection in the {@link RepresentationModel}. If the collection is empty nothing will be added to + * the {@link RepresentationModel}. + * + * @param collection must not be {@literal null}. + * @return will never be {@literal null}. + */ public HalModelBuilder embed(Collection collection) { - return embed(collection, Void.class); } + /** + * Embeds the given collection in the {@link RepresentationModel} and the {@link LinkRelation} derived from the given + * type. If the collection is empty nothing will be added to the {@link RepresentationModel} an empty embed for the + * derived {@link LinkRelation} will be added. + * + * @param collection must not be {@literal null}. + * @param type the type to derive the {@link LinkRelation} to be used, must not be {@literal null}. + * @return will never be {@literal null}. + * @see #embed(Collection, LinkRelation) + */ public HalModelBuilder embed(Collection collection, Class type) { + Assert.notNull(collection, "Collection must not be null!"); + Assert.notNull(type, "Type must not be null!"); + if (!collection.isEmpty()) { - - EmbeddedWrapper wrapper = wrappers.wrap(collection); - - return wrapper == null ? this : embed(wrapper); + return embed(wrappers.wrap(collection)); } - if (Void.class.equals(type)) { - return this; - } - - return embed(wrappers.emptyCollectionOf(type)); + return Void.class.equals(type) ? this : embed(wrappers.emptyCollectionOf(type)); } /** * Embeds the given collection in the {@link RepresentationModel} for the given {@link LinkRelation}. If the - * collection is empty nothing will be added to the {@link RepresentationModel}. + * collection is empty nothing will be added to the {@link RepresentationModel} an empty embed for the derived + * {@link LinkRelation} will be added. * * @param collection must not be {@literal null}. * @param relation must not be {@literal null}. * @return will never be {@literal null}. - * @see #embed(Collection, LinkRelation, Class) */ public HalModelBuilder embed(Collection collection, LinkRelation relation) { Assert.notNull(collection, "Collection must not be null!"); Assert.notNull(relation, "Link relation must not be null!"); - EmbeddedWrapper wrapper = wrappers.wrap(collection, relation); + return embed(wrappers.wrap(collection, relation)); + } - return wrapper == null ? this : embed(wrapper); + /** + * Embeds the given {@link Stream} in the {@link RepresentationModel}. The given {@link Stream} will be resolved + * immediately, i.e. the method acts as mere syntactic sugar to avoid clients having to collect the {@link Stream} + * into a {@link Collection} beforehand. If the {@link Stream} is empty nothing will be added to the + * {@link RepresentationModel}. + * + * @param stream must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder embed(Stream stream) { + return embed(stream, Void.class); + } + + /** + * Embeds the given {@link Stream} in the {@link RepresentationModel} and the {@link LinkRelation} derived from the + * given type. The given {@link Stream} will be resolved immediately, i.e. the method acts as mere syntactic sugar to + * avoid clients having to collect the {@link Stream} into a {@link Collection} beforehand. If the {@link Stream} is + * empty nothing will be added to the {@link RepresentationModel} an empty embed for the derived {@link LinkRelation} + * will be added. + * + * @param stream must not be {@literal null}. + * @param type the type to derive the {@link LinkRelation} to be used, must not be {@literal null}. + * @return will never be {@literal null}. + * @see #embed(Collection, LinkRelation) + */ + public HalModelBuilder embed(Stream stream, Class type) { + + Assert.notNull(stream, "Stream must not be null!"); + Assert.notNull(type, "Type must not be null!"); + + return embed(stream.collect(Collectors.toList()), type); + } + + /** + * Embeds the given {@link Stream} in the {@link RepresentationModel} for the given {@link LinkRelation}. The given + * {@link Stream} will be resolved immediately, i.e. the method acts as mere syntactic sugar to avoid clients having + * to collect the {@link Stream} into a {@link Collection} beforehand. If the {@link Stream} is empty nothing will be + * added to the {@link RepresentationModel} an empty embed for the derived {@link LinkRelation} will be added. + * + * @param stream must not be {@literal null}. + * @param relation must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder embed(Stream stream, LinkRelation relation) { + + Assert.notNull(stream, "Stream must not be null!"); + Assert.notNull(relation, "Link relation must not be null!"); + + return embed(stream.collect(Collectors.toList()), relation); } /** @@ -295,6 +357,7 @@ public class HalModelBuilder { * * @return will never be {@literal null}. */ + @SuppressWarnings("unchecked") public > RepresentationModel build() { return (T) new HalRepresentationModel<>(model, CollectionModel.of(embeddeds), links); } @@ -317,7 +380,7 @@ public class HalModelBuilder { private static class HalRepresentationModel extends EntityModel { - private final T entity; + private final @Nullable T entity; private final CollectionModel embeddeds; public HalRepresentationModel(@Nullable T entity, CollectionModel embeddeds, Links links) { @@ -327,7 +390,9 @@ public class HalModelBuilder { add(links); } - public HalRepresentationModel(T entity, CollectionModel embeddeds) { + private HalRepresentationModel(@Nullable T entity, CollectionModel embeddeds) { + + Assert.notNull(embeddeds, "Embedds must not be null!"); this.entity = entity; this.embeddeds = embeddeds; diff --git a/src/main/java/org/springframework/hateoas/server/core/EmbeddedWrappers.java b/src/main/java/org/springframework/hateoas/server/core/EmbeddedWrappers.java index 52341be1..b7907787 100644 --- a/src/main/java/org/springframework/hateoas/server/core/EmbeddedWrappers.java +++ b/src/main/java/org/springframework/hateoas/server/core/EmbeddedWrappers.java @@ -15,9 +15,12 @@ */ package org.springframework.hateoas.server.core; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.aop.support.AopUtils; import org.springframework.hateoas.EntityModel; @@ -47,11 +50,10 @@ public class EmbeddedWrappers { /** * Creates a new {@link EmbeddedWrapper} that * - * @param source - * @return + * @param source must not be {@literal null}. + * @return will never be {@literal null}. */ - @Nullable - public EmbeddedWrapper wrap(@Nullable Object source) { + public EmbeddedWrapper wrap(Object source) { return wrap(source, AbstractEmbeddedWrapper.NO_REL); } @@ -68,31 +70,44 @@ public class EmbeddedWrappers { /** * Creates a new {@link EmbeddedWrapper} with the given rel. * - * @param source can be {@literal null}, will return {@literal null} if so. + * @param source must not be {@literal null}. * @param rel must not be {@literal null} or empty. - * @return + * @return will never be {@literal null}. */ - @SuppressWarnings("unchecked") - @Nullable - public EmbeddedWrapper wrap(@Nullable Object source, LinkRelation rel) { + public EmbeddedWrapper wrap(Object source, LinkRelation rel) { - if (source == null) { - return null; - } + Assert.notNull(source, "Source must not be null!"); + Assert.notNull(rel, "Link relation must not be null!"); if (source instanceof EmbeddedWrapper) { return (EmbeddedWrapper) source; } - if (source instanceof Collection) { - return new EmbeddedCollection((Collection) source, rel); + return source instanceof Collection || source instanceof Stream || preferCollections + ? new EmbeddedCollection(asCollection(source), rel) + : new EmbeddedElement(source, rel); + } + + @SuppressWarnings("unchecked") + private static Collection asCollection(@Nullable Object source) { + + if (source == null) { + return Collections.emptyList(); } - if (preferCollections) { - return new EmbeddedCollection(Collections.singleton(source), rel); + if (Collection.class.isInstance(source)) { + return Collection.class.cast(source); } - return new EmbeddedElement(source, rel); + if (Stream.class.isInstance(source)) { + return (Collection) Stream.class.cast(source).collect(Collectors.toList()); + } + + if (source.getClass().isArray()) { + return Arrays.asList((Object[]) source); + } + + return Collections.singleton(source); } private static abstract class AbstractEmbeddedWrapper implements EmbeddedWrapper { diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java index 2ff98aa6..73cac9f5 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java @@ -15,7 +15,7 @@ */ package org.springframework.hateoas.mediatype.hal; -import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.Assertions.*; import static org.springframework.hateoas.IanaLinkRelations.*; import static org.springframework.hateoas.MappingTestUtils.*; import static org.springframework.hateoas.mediatype.hal.HalModelBuilder.*; @@ -26,11 +26,13 @@ import lombok.Getter; import lombok.Value; import net.minidev.json.JSONArray; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -179,20 +181,17 @@ public class HalModelBuilderUnitTest { RepresentationModel model = halModel() // .embed( // - halModel() // - .entity(new Author("Greg L. Turnquist", null, null)) // + halModelOf(new Author("Greg L. Turnquist", null, null)) .link(Link.of("http://localhost/author/1")) // .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) // .build()) .embed( // - halModel() // - .entity(new Author("Craig Walls", null, null)) // - .link(Link.of("http://localhost/author/2")) // - .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) // + halModelOf(new Author("Craig Walls", null, null)) // + .links(Arrays.asList(Link.of("http://localhost/author/2"), // + Link.of("http://localhost/authors", LinkRelation.of("authors")))) // .build()) .embed( // - halModel() // - .entity(new Author("Oliver Drotbohm", null, null)) // + halModelOf(new Author("Oliver Drotbohm", null, null)) // .link(Link.of("http://localhost/author/3")) // .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) // .build()) @@ -310,28 +309,62 @@ public class HalModelBuilderUnitTest { .isEqualTo(contextualMapper.readFile("zoom-hypermedia.json")); } - @Test + @Test // #864 void addsTypedEmptyCollection() throws Exception { RepresentationModel model = halModel() // .embed(Collections.emptyList(), Author.class) // .build(); - DocumentContext context = JsonPath.parse(mapper.writeValueAsString(model)); - - assertThat(context.read("$._embedded.authors", JSONArray.class).isEmpty()).isTrue(); + assertEmptyEmbed(model, "authors"); } - @Test + @Test // #864 void addsEmptyCollectionForLinkRelation() throws Exception { RepresentationModel model = halModel() // .embed(Collections.emptyList(), LinkRelation.of("authors")) // .build(); + assertEmptyEmbed(model, "authors"); + } + + @Test // #864 + @SuppressWarnings("unchecked") + void doesNotAddEmbeddForSimpleEmptyCollection() throws Exception { + + RepresentationModel model = halModel() + .embed(Stream.of(new Product("iPad", 699.0))) + .embed(Collections.emptyList()) + .build(); + DocumentContext context = JsonPath.parse(mapper.writeValueAsString(model)); - assertThat(context.read("$._embedded.authors", JSONArray.class).isEmpty()).isTrue(); + assertThat(context.read("$._embedded", Map.class)).containsOnlyKeys("products"); + } + + @Test // #1335 + void embedsStream() throws Exception { + + RepresentationModel model = halModel().embed(Stream.of(new Product("iPad", 699.0))).build(); + + DocumentContext context = JsonPath.parse(mapper.writeValueAsString(model)); + + assertThat(context.read("$._embedded.products[0].name", String.class)).isEqualTo("iPad"); + } + + @Test // #1335 + void embedsEmptyStream() throws Exception { + + assertEmptyEmbed(halModel().embed(Stream.empty(), Product.class).build(), "products"); + assertEmptyEmbed(halModel().embed(Stream.empty(), LinkRelation.of("products")).build(), "products"); + } + + private void assertEmptyEmbed(RepresentationModel model, String name) throws Exception { + + DocumentContext context = JsonPath.parse(mapper.writeValueAsString(model)); + + assertThat(context.read("$._embedded." + name, JSONArray.class).isEmpty()).isTrue(); } @Value