diff --git a/src/main/asciidoc/mediatypes.adoc b/src/main/asciidoc/mediatypes.adoc index e9907719..070f93d9 100644 --- a/src/main/asciidoc/mediatypes.adoc +++ b/src/main/asciidoc/mediatypes.adoc @@ -14,6 +14,114 @@ and most widely adopted hypermedia media types adopted when not discussing speci It was the first spec-based media type adopted by Spring HATEOAS. +[[mediatypes.hal.models]] +=== Building HAL representation models + +As of Spring HATEOAS 1.1, we ship a dedicated `HalModelBuilder` that allows to create `RepresentationModel` instances through a HAL-idiomatic API. +These are its fundamental assumptions: + +1. A HAL representation can be backed by an arbitrary object (an entity) that builds up the domain fields contained in the representation. +2. The representation can be enriched by a variety of embedded documents, which can be either arbitrary objects or HAL representations themselves (i.e. containing nested embeddeds and links). +3. Certain HAL specific patterns (e.g. previews) can be directly used in the API so that the code setting up the representation reads like you'd describe a HAL representation following those idioms. + +Here's an example of the API used: + +[source, java] +---- +// An order +var order = new Order(…); <1> + +// The customer who placed the order +var customer = customer.findById(order.getCustomerId()); + +var customerLink = Link.of("/orders/{id}/customer") <2> + .expand(order.getId()) + .withRel("customer"); + +var additional = … + +var model = HalModelBuilder.halModel(order) + .preview(new CustomerSummary(customer)) <3> + .forLink(customerLink) <4> + .embed(additional) <5> + .link(Link.of(…, IanaLinkRelations.SELF)); + .build(); +---- +<1> We set up some domain type. In this case, an order that has a relationship to the customer that placed it. +<2> We prepare a link pointing to a resource that will expose customer details +<3> We start building a preview by providing the payload that's supposed to be rendered inside the `_embeddable` clause. +<4> We conclude that preview by providing the target link. It transparently gets added to the `_links` object and its link relation is used as the key for the object provided in the previous step. +<5> Other objects can be added to show up under `_embedded`. +The key under which they're listed is derived from the objects relation settings. They're customizable via `@Relation` or a dedicated `LinkRelationProvider` (see <> for details). + +[source, json] +---- +{ + "_links" : { + "self" : { "href" : "…" }, <1> + "customer" : { "href" : "/orders/4711/customer" } <2> + }, + "_embedded" : { + "customer" : { … }, <3> + "additional" : { … } <4> + } +} +---- +<1> The `self` link as explicitly provided. +<2> The `customer` link transparently added through `….preview(…).forLink(…)`. +<3> The preview object provided. +<4> Additional elements added via explicit `….embed(…)`. + +In HAL `_embedded` is also used to represent top collections. +They're usually grouped under the link relation derived from the object's type. +I.e. a list of orders would look like this in HAL: + +[source, json] +---- +{ + "_embedded" : { + "orders : [ + … <1> + ] + } +} +---- +<1> Individual order documents go here. + +Creating such a representation is as easy as this: + +[source, java] +---- +Collection orders = …; + +HalModelBuilder.emptyHalDocument() + .embed(orders); +---- + +That said, if the order is empty, there's no way to derive the link relation to appear inside `_embedded`, so that the document will stay empty if the collection is empty. + +If you prefer to explicitly communicate an empty collection, a type can be handed into the overload of the `….embed(…)` method taking a `Collection`. +If the collection handed into the method is empty, this will cause a field rendered with its link relation derived from the given type. + +[source, java] +---- +HalModelBuilder.emptyHalModel() + .embed(Collections.emptyList(), Order.class); + // or + .embed(Collections.emptyList(), LinkRelation.of("orders")); +---- + +will create the following, more explicit representation. + +[source, json] +---- +{ + "_embedded" : { + "orders" : [] + } +} +---- + [[mediatypes.hal.configuration]] === Configuring link rendering diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java b/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java new file mode 100644 index 00000000..b49eeda0 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/HalModelBuilder.java @@ -0,0 +1,375 @@ +/* + * Copyright 2020 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.mediatype.hal; + +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.Link; +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; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +/** + * Builder API to create complex HAL representations exposing a HAL idiomatic API. It's built around the notion of a the + * representation consisting of an optional primary entity and e set of embeds. There's also explicit API for common HAL + * patterns like previews contained in {@literal _embedded} for links present in the representation. + * + * @author Greg Turnquist + * @author Oliver Drotbohm + * @since 1.1 + */ +public class HalModelBuilder { + + private static final LinkRelation NO_RELATION = LinkRelation.of("___norel___"); + + private final EmbeddedWrappers wrappers; + + private Object model; + private Links links = Links.NONE; + private final List embeddeds = new ArrayList<>(); + + private HalModelBuilder(EmbeddedWrappers wrappers) { + this.wrappers = wrappers; + } + + /** + * Creates a new {@link HalModelBuilder}. + * + * @return will never be {@literal null}. + */ + public static HalModelBuilder halModel() { + return new HalModelBuilder(new EmbeddedWrappers(false)); + } + + /** + * Creates a new {@link HalModelBuilder} using the given {@link EmbeddedWrappers}. + * + * @param wrappers must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static HalModelBuilder halModel(EmbeddedWrappers wrappers) { + + Assert.notNull(wrappers, "EmbeddedWrappers must not be null!"); + + return new HalModelBuilder(wrappers); + } + + /** + * Creates a new {@link HalModelBuilder} with the given entity as primary payload. + * + * @param entity must not be {@literal null}. + * @return + */ + public static HalModelBuilder halModelOf(Object entity) { + return halModel().entity(entity); + } + + /** + * Creates a new {@link HalModelBuilder} without a primary payload. + * + * @return + */ + public static HalModelBuilder emptyHalModel() { + return halModel(); + } + + /** + * Embed the entity, but with no relation. + * + * @param entity + * @return + */ + public HalModelBuilder entity(Object entity) { + + Assert.notNull(entity, "Entity must not be null!"); + + if (model != null) { + throw new IllegalStateException("Model object already set!"); + } + + this.model = entity; + + return this; + } + + /** + * Embed the entity and associate it with the {@link LinkRelation}. + * + * @param entity must not be {@literal null}. + * @param linkRelation must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder embed(Object entity, LinkRelation linkRelation) { + + Assert.notNull(entity, "Entity must not be null!"); + Assert.notNull(linkRelation, "Link relation must not be null!"); + + this.embeddeds.add(wrappers.wrap(entity, linkRelation)); + + return this; + } + + /** + * Embeds the given entity into the {@link RepresentationModel}. + * + * @param entity must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder embed(Object entity) { + + Assert.notNull(entity, "Entity must not be null!"); + + this.embeddeds.add(wrappers.wrap(entity)); + + return this; + } + + public HalModelBuilder embed(Collection collection) { + + return embed(collection, Void.class); + } + + public HalModelBuilder embed(Collection collection, Class type) { + + if (!collection.isEmpty()) { + + EmbeddedWrapper wrapper = wrappers.wrap(collection); + + return wrapper == null ? this : embed(wrapper); + } + + if (Void.class.equals(type)) { + return this; + } + + return 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}. + * + * @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 wrapper == null ? this : embed(wrapper); + } + + /** + * Initiates the setup of a preview given the current payload. Clients have to conclude the setup calling any of the + * {@link EntityPreviewBuilder#forLink(Link)} methods. As an example, the call chain of: + * + *
+	 * ….preview(…).forLink("…", "relation")
+	 * 
+ * + * will result in the link added to the representation and an embedded being registered for the link's relation: + * + *
+	 * {
+	 *   "_links" : {
+	 *     "relation" : { … }
+	 *   },
+	 *   "_embedded" : {
+	 *     "relation" : …
+	 *   }
+	 * }
+	 * 
+ * + * @param entity + * @return will never be {@literal null}. + */ + public PreviewBuilder preview(Object entity) { + + Assert.notNull(entity, "Preview entity must not be null!"); + + return link -> this.previewFor(entity, link); + } + + /** + * Starts a preview setup for the given {@link Collection} as preview. + * + * @param collection + * @return will never be {@literal null}. + * @see #preview(Object) + */ + public PreviewBuilder preview(Collection collection) { + + Assert.notNull(collection, "Preview collection must not be null!"); + + return link -> this.previewFor(collection, link); + } + + /** + * Starts a preview setup for the given {@link Collection} as preview falling back to the given type if the + * {@link Collection} is empty. + * + * @param collection must not be {@literal null}. + * @param type must not be {@literal null}. + * @return will never be {@literal null}. + * @see #preview(Object) + */ + public PreviewBuilder preview(Collection collection, Class type) { + + Assert.notNull(collection, "Preview collection must not be null!"); + Assert.notNull(type, "Type must not be null!"); + + return link -> this.previewFor(type, link); + } + + /** + * Add a {@link Link} to the whole thing. + *

+ * NOTE: This adds it to the top level. If you need a link inside an entity, then use the {@link Model.Builder} to + * define it as well. + * + * @param link must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder link(Link link) { + + this.links = links.and(link); + + return this; + } + + /** + * Adds a {@link Link} with the given href and {@link LinkRelation} to the {@link RepresentationModel} to be built. + * + * @param href must not be {@literal null}. + * @param relation must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder link(String href, LinkRelation relation) { + return link(Link.of(href, relation)); + } + + /** + * Adds the given {@link Link}s to the {@link RepresentationModel} to be built. + * + * @param links must not be {@literal null}. + * @return will never be {@literal null}. + */ + public HalModelBuilder links(Iterable links) { + + this.links = this.links.and(links); + + return this; + } + + /** + * Transform the entities and {@link Link}s into a {@link RepresentationModel}. If there are embedded entries, add a + * preferred media type of {@link MediaTypes#HAL_JSON} and {@link MediaTypes#HAL_FORMS_JSON}. + * + * @return will never be {@literal null}. + */ + public RepresentationModel build() { + return new HalRepresentationModel<>(model, CollectionModel.of(embeddeds), links); + } + + /** + * A common usage of embedded entries are to define a read-only preview. This method provides syntax sugar for + * {@link #embed(Object, LinkRelation)}. + * + * @param entity + * @param link + * @return + */ + private HalModelBuilder previewFor(Object entity, Link link) { + + link(link); + embed(entity, link.getRel()); + + return this; + } + + @RequiredArgsConstructor + private static class HalRepresentationModel extends EntityModel { + + private final T entity; + private final CollectionModel embeddeds; + + public HalRepresentationModel(@Nullable T entity, CollectionModel embeddeds, Links links) { + + this.entity = entity; + this.embeddeds = embeddeds; + + add(links); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.EntityModel#getContent() + */ + @Nullable + @Override + public T getContent() { + return entity; + } + + @JsonUnwrapped + public CollectionModel getEmbeddeds() { + return embeddeds; + } + } + + public interface PreviewBuilder { + + /** + * Concludes the set up of a preview for the given {@link Link}. + * + * @param link must not be {@literal null}. + * @return will never be {@literal null}. + * @see HalModelBuilder#preview(Object) + */ + HalModelBuilder forLink(Link link); + + /** + * Concludes the set up of a preview for the {@link Link} consisting ot the given href and {@link LinkRelation}. + * + * @param href must not be {@literal null}. + * @param relation must not be {@literal null}. + * @return will never be {@literal null}. + * @see HalModelBuilder#preview(Object) + */ + default HalModelBuilder forLink(String href, LinkRelation relation) { + return forLink(Link.of(href, relation)); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/server/core/WebHandler.java b/src/main/java/org/springframework/hateoas/server/core/WebHandler.java index e058eb8c..b5d5f5b7 100644 --- a/src/main/java/org/springframework/hateoas/server/core/WebHandler.java +++ b/src/main/java/org/springframework/hateoas/server/core/WebHandler.java @@ -377,6 +377,10 @@ public class WebHandler { value = ObjectUtils.unwrapOptional(value); + // Try to lookup ConversionService from the request's context + + // Guard with ….canConvert(…) + // if not, fall back to ….toString(); Object result = CONVERSION_SERVICE.convert(value, typeDescriptor, STRING_DESCRIPTOR); if (result == null) { diff --git a/src/test/java/org/springframework/hateoas/MappingTestUtils.java b/src/test/java/org/springframework/hateoas/MappingTestUtils.java index 3d3e44c1..0dd6307a 100644 --- a/src/test/java/org/springframework/hateoas/MappingTestUtils.java +++ b/src/test/java/org/springframework/hateoas/MappingTestUtils.java @@ -51,6 +51,10 @@ public class MappingTestUtils { return mapper; } + public static ContextualMapper createMapper(Class context) { + return createMapper(context, it -> {}); + } + public static ContextualMapper createMapper(Class context, Consumer configurer) { ObjectMapper mapper = defaultObjectMapper(); diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java new file mode 100644 index 00000000..2ff98aa6 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/HalModelBuilderUnitTest.java @@ -0,0 +1,370 @@ +/* + * Copyright 2020 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.mediatype.hal; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.springframework.hateoas.IanaLinkRelations.*; +import static org.springframework.hateoas.MappingTestUtils.*; +import static org.springframework.hateoas.mediatype.hal.HalModelBuilder.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.Value; +import net.minidev.json.JSONArray; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.MappingTestUtils.ContextualMapper; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.mediatype.MessageResolver; +import org.springframework.hateoas.server.core.EvoInflectorLinkRelationProvider; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; + +/** + * @author Greg Turnquist + * @author Oliver Drotbohm + */ +@SuppressWarnings("null") +public class HalModelBuilderUnitTest { + + private static final Link JOHN_SMITH_SELF = Link.of("/people/john-smith"); + private static final Link ALAN_WATTS_SELF = Link.of("/people/alan-watts"); + + private static final LinkRelation ILLUSTRATOR_REL = LinkRelation.of("illustrator"); + private static final LinkRelation AUTHOR_REL = LinkRelation.of("author"); + + private ObjectMapper mapper; + private ContextualMapper contextualMapper; + + @BeforeEach + void setUp() { + + this.mapper = new ObjectMapper(); + this.mapper.registerModule(new Jackson2HalModule()); + this.mapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator( + new EvoInflectorLinkRelationProvider(), CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY)); + this.mapper.enable(SerializationFeature.INDENT_OUTPUT); + + this.contextualMapper = createMapper(getClass()); + } + + @Test // #864 + void embeddedSpecUsingHalModelBuilder() throws Exception { + + RepresentationModel model = halModel() // + .embed(halModel() // + .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) // + .link(ALAN_WATTS_SELF) // + .build(), AUTHOR_REL) + .embed(halModel() // + .entity(new Author("John Smith", null, null)) // + .link(JOHN_SMITH_SELF) // + .build(), ILLUSTRATOR_REL) + .link(Link.of("/books/the-way-of-zen")) // + .link(Link.of("/people/alan-watts", AUTHOR_REL)) // + .link(Link.of("/people/john-smith", ILLUSTRATOR_REL)) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) + .isEqualTo(contextualMapper.readFile("hal-embedded-author-illustrator.json")); + } + + @Test // #864 + void previewForLinkRelationsUsingHalModelBuilder() throws Exception { + + RepresentationModel model = halModel() // + .link("/books/the-way-of-zen", IanaLinkRelations.SELF) // + .preview(halModel() // + .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) // + .link(ALAN_WATTS_SELF) // + .build()) + .forLink(Link.of("/people/alan-watts", AUTHOR_REL)) // + .preview(halModel() // + .entity(new Author("John Smith", null, null)) // + .link(JOHN_SMITH_SELF) // + .build()) // + .forLink(Link.of("/people/john-smith", ILLUSTRATOR_REL)).build(); + + assertThat(this.mapper.writeValueAsString(model)) + .isEqualTo(contextualMapper.readFile("hal-embedded-author-illustrator.json")); + } + + @Test // #864 + void renderSingleItemUsingHalModelBuilder() throws Exception { + + RepresentationModel model = halModel() // + .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) // + .link(ALAN_WATTS_SELF) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)).isEqualTo(contextualMapper.readFile("hal-single-item.json")); + } + + @Test // #864 + void renderSingleItemUsingDefaultModelBuilder() throws Exception { + + RepresentationModel model = halModel()// + .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) // + .link(ALAN_WATTS_SELF) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) // + .isEqualTo(contextualMapper.readFile("hal-single-item.json")); + } + + @Test // #864 + void renderCollectionUsingDefaultModelBuilder() throws Exception { + + Link authorsLink = Link.of("http://localhost/authors", LinkRelation.of("authors")); + + RepresentationModel model = halModel() // + .embed( // + halModel() // + .entity(new Author("Greg L. Turnquist", null, null)) // + .link(Link.of("http://localhost/author/1")) // + .link(authorsLink) // + .build()) + .embed( // + halModel() // + .entity(new Author("Craig Walls", null, null)) // + .link(Link.of("http://localhost/author/2")) // + .link(authorsLink) // + .build()) + .embed( // + halModel() // + .entity(new Author("Oliver Drotbohm", null, null)) // + .link(Link.of("http://localhost/author/3")) // + .link(authorsLink) // + .build()) + .link(Link.of("http://localhost/authors")) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) + .isEqualTo(contextualMapper.readFile("hal-embedded-collection.json")); + } + + @Test // #864 + void renderCollectionUsingHalModelBuilder() throws Exception { + + RepresentationModel model = halModel() // + .embed( // + halModel() // + .entity(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"))) // + .build()) + .embed( // + halModel() // + .entity(new Author("Oliver Drotbohm", null, null)) // + .link(Link.of("http://localhost/author/3")) // + .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) // + .build()) + .link(Link.of("http://localhost/authors")) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) + .isEqualTo(contextualMapper.readFile("hal-embedded-collection.json")); + } + + @Test + void progressivelyAddingContentUsingHalModelBuilder() throws JsonProcessingException { + + HalModelBuilder halModelBuilder = halModel(); + + assertThat(this.mapper.writeValueAsString(halModelBuilder.build())) + .isEqualTo(contextualMapper.readFile("hal-empty.json")); + + halModelBuilder // + .entity(halModel() // + .entity(new Author("Greg L. Turnquist", null, null)) // + .link(Link.of("http://localhost/author/1")) // + .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) // + .build()); + + assertThat(this.mapper.writeValueAsString(halModelBuilder.build())) + .isEqualTo(contextualMapper.readFile("hal-one-thing.json")); + + halModelBuilder // + .embed(new Product("Alf alarm clock", 19.99), LinkRelation.of("product")).build(); + + assertThat(this.mapper.writeValueAsString(halModelBuilder.build())) + .isEqualTo(contextualMapper.readFile("hal-two-things.json")); + } + + @Test // #193 + void renderDifferentlyTypedEntities() throws Exception { + + RepresentationModel model = emptyHalModel() // + .embed(new Staff("Frodo Baggins", "ring bearer")) // + .embed(new Staff("Bilbo Baggins", "burglar")) // + .embed(new Product("ring of power", 999.99)) // + .embed(new Product("Saruman's staff", 9.99)) // + .link(ALAN_WATTS_SELF) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) // + .isEqualTo(contextualMapper.readFile("hal-multiple-types.json")); + } + + @Test // #193 + void renderExplicitAndImplicitLinkRelations() throws Exception { + + Staff staff1 = new Staff("Frodo Baggins", "ring bearer"); + Staff staff2 = new Staff("Bilbo Baggins", "burglar"); + + RepresentationModel model = halModel() // + .embed(staff1) // + .embed(staff2) // + .embed(new Product("ring of power", 999.99)) // + .embed(new Product("Saruman's staff", 9.99)) // + .link(ALAN_WATTS_SELF) // + .embed(staff1, LinkRelation.of("ring bearers")) // + .embed(staff2, LinkRelation.of("burglars")) // + .link(Link.of("/people/frodo-baggins", LinkRelation.of("frodo"))) // + .build(); + + assertThat(this.mapper.writeValueAsString(model)) + .isEqualTo(contextualMapper.readFile("hal-explicit-and-implicit-relations.json")); + } + + @Test // #175 #864 + void renderZoomProtocolUsingHalModelBuilder() throws JsonProcessingException { + + Map products = new TreeMap<>(); + + products.put(998, new ZoomProduct("someValue", true, true)); + products.put(777, new ZoomProduct("someValue", true, false)); + products.put(444, new ZoomProduct("someValue", false, true)); + products.put(333, new ZoomProduct("someValue", false, true)); + products.put(222, new ZoomProduct("someValue", false, true)); + products.put(111, new ZoomProduct("someValue", false, true)); + products.put(555, new ZoomProduct("someValue", false, true)); + products.put(666, new ZoomProduct("someValue", false, true)); + + List> productCollectionModel = products.keySet().stream() // + .map(id -> EntityModel.of(products.get(id), Link.of("http://localhost/products/{id}").expand(id))) // + .collect(Collectors.toList()); + + LinkRelation favoriteProducts = LinkRelation.of("favorite products"); + LinkRelation purchasedProducts = LinkRelation.of("purchased products"); + + HalModelBuilder builder = halModel(); + + builder.link(Link.of("/products").withSelfRel()); + + for (EntityModel productEntityModel : productCollectionModel) { + + ZoomProduct content = productEntityModel.getContent(); + + if (content.isFavorite()) { + + builder.embed(productEntityModel, favoriteProducts) // + .link(productEntityModel.getRequiredLink(SELF).withRel(favoriteProducts)); + } + + if (content.isPurchased()) { + + builder.embed(productEntityModel, purchasedProducts) // + .link(productEntityModel.getRequiredLink(SELF).withRel(purchasedProducts)); + } + } + + assertThat(this.mapper.writeValueAsString(builder.build())) + .isEqualTo(contextualMapper.readFile("zoom-hypermedia.json")); + } + + @Test + 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(); + } + + @Test + void addsEmptyCollectionForLinkRelation() throws Exception { + + RepresentationModel model = halModel() // + .embed(Collections.emptyList(), LinkRelation.of("authors")) // + .build(); + + DocumentContext context = JsonPath.parse(mapper.writeValueAsString(model)); + + assertThat(context.read("$._embedded.authors", JSONArray.class).isEmpty()).isTrue(); + } + + @Value + @AllArgsConstructor + static class Author { + + private String name; + @Getter(onMethod = @__({ @JsonInclude(JsonInclude.Include.NON_NULL) })) private String born; + @Getter(onMethod = @__({ @JsonInclude(JsonInclude.Include.NON_NULL) })) private String died; + } + + @Value + @AllArgsConstructor + static class Staff { + + private String name; + private String role; + } + + @Value + @AllArgsConstructor + static class Product { + + private String name; + private Double price; + } + + @Data + @AllArgsConstructor + static class ZoomProduct { + + private String someProductProperty; + @Getter(onMethod = @__({ @JsonIgnore })) private boolean favorite = false; + @Getter(onMethod = @__({ @JsonIgnore })) private boolean purchased = false; + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-author-illustrator.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-author-illustrator.json new file mode 100644 index 00000000..6b26ee82 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-author-illustrator.json @@ -0,0 +1,33 @@ +{ + "_embedded" : { + "author" : { + "name" : "Alan Watts", + "born" : "January 6, 1915", + "died" : "November 16, 1973", + "_links" : { + "self" : { + "href" : "/people/alan-watts" + } + } + }, + "illustrator" : { + "name" : "John Smith", + "_links" : { + "self" : { + "href" : "/people/john-smith" + } + } + } + }, + "_links" : { + "self" : { + "href" : "/books/the-way-of-zen" + }, + "author" : { + "href" : "/people/alan-watts" + }, + "illustrator" : { + "href" : "/people/john-smith" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-collection.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-collection.json new file mode 100644 index 00000000..a2007858 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-embedded-collection.json @@ -0,0 +1,40 @@ +{ + "_embedded" : { + "authors" : [ { + "name" : "Greg L. Turnquist", + "_links" : { + "self" : { + "href" : "http://localhost/author/1" + }, + "authors" : { + "href" : "http://localhost/authors" + } + } + }, { + "name" : "Craig Walls", + "_links" : { + "self" : { + "href" : "http://localhost/author/2" + }, + "authors" : { + "href" : "http://localhost/authors" + } + } + }, { + "name" : "Oliver Drotbohm", + "_links" : { + "self" : { + "href" : "http://localhost/author/3" + }, + "authors" : { + "href" : "http://localhost/authors" + } + } + } ] + }, + "_links" : { + "self" : { + "href" : "http://localhost/authors" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-empty.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-empty.json new file mode 100644 index 00000000..ffcd4415 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-empty.json @@ -0,0 +1 @@ +{ } diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-explicit-and-implicit-relations.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-explicit-and-implicit-relations.json new file mode 100644 index 00000000..70acc1c1 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-explicit-and-implicit-relations.json @@ -0,0 +1,34 @@ +{ + "_embedded" : { + "burglars" : { + "name" : "Bilbo Baggins", + "role" : "burglar" + }, + "staffs" : [ { + "name" : "Frodo Baggins", + "role" : "ring bearer" + }, { + "name" : "Bilbo Baggins", + "role" : "burglar" + } ], + "ring bearers" : { + "name" : "Frodo Baggins", + "role" : "ring bearer" + }, + "products" : [ { + "name" : "ring of power", + "price" : 999.99 + }, { + "name" : "Saruman's staff", + "price" : 9.99 + } ] + }, + "_links" : { + "self" : { + "href" : "/people/alan-watts" + }, + "frodo" : { + "href" : "/people/frodo-baggins" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-multiple-types.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-multiple-types.json new file mode 100644 index 00000000..81114f05 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-multiple-types.json @@ -0,0 +1,23 @@ +{ + "_embedded" : { + "staffs" : [ { + "name" : "Frodo Baggins", + "role" : "ring bearer" + }, { + "name" : "Bilbo Baggins", + "role" : "burglar" + } ], + "products" : [ { + "name" : "ring of power", + "price" : 999.99 + }, { + "name" : "Saruman's staff", + "price" : 9.99 + } ] + }, + "_links" : { + "self" : { + "href" : "/people/alan-watts" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-one-thing.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-one-thing.json new file mode 100644 index 00000000..58b55554 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-one-thing.json @@ -0,0 +1,11 @@ +{ + "name" : "Greg L. Turnquist", + "_links" : { + "self" : { + "href" : "http://localhost/author/1" + }, + "authors" : { + "href" : "http://localhost/authors" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-single-item.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-single-item.json new file mode 100644 index 00000000..73acd096 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-single-item.json @@ -0,0 +1,10 @@ +{ + "name" : "Alan Watts", + "born" : "January 6, 1915", + "died" : "November 16, 1973", + "_links" : { + "self" : { + "href" : "/people/alan-watts" + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-two-things.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-two-things.json new file mode 100644 index 00000000..5c551103 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/hal-two-things.json @@ -0,0 +1,17 @@ +{ + "name" : "Greg L. Turnquist", + "_links" : { + "self" : { + "href" : "http://localhost/author/1" + }, + "authors" : { + "href" : "http://localhost/authors" + } + }, + "_embedded" : { + "product" : { + "name" : "Alf alarm clock", + "price" : 19.99 + } + } +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/zoom-hypermedia.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/zoom-hypermedia.json new file mode 100644 index 00000000..e567ca98 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/zoom-hypermedia.json @@ -0,0 +1,94 @@ +{ + "_embedded" : { + "favorite products" : [ { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/777" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/998" + } + } + } ], + "purchased products" : [ { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/111" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/222" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/333" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/444" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/555" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/666" + } + } + }, { + "someProductProperty" : "someValue", + "_links" : { + "self" : { + "href" : "http://localhost/products/998" + } + } + } ] + }, + "_links" : { + "self" : { + "href" : "/products" + }, + "purchased products" : [ { + "href" : "http://localhost/products/111" + }, { + "href" : "http://localhost/products/222" + }, { + "href" : "http://localhost/products/333" + }, { + "href" : "http://localhost/products/444" + }, { + "href" : "http://localhost/products/555" + }, { + "href" : "http://localhost/products/666" + }, { + "href" : "http://localhost/products/998" + } ], + "favorite products" : [ { + "href" : "http://localhost/products/777" + }, { + "href" : "http://localhost/products/998" + } ] + } +}