#864 - Introduced HalModelBuilder.

HalModelBuilder expose HAL-idiomatic API to set up representations. That includes embeds, previews and syntactic sugar around the inclusion of potentially empty collections as embeds.

Related tickets: #175, #193, #270, #920.
Original pull request: #1273.
This commit is contained in:
Oliver Drotbohm
2020-05-07 18:11:36 +02:00
parent 36ddb281f9
commit 2cab91a332
14 changed files with 1124 additions and 0 deletions

View File

@@ -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 <<server.rel-provider>> 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<Order> 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

View File

@@ -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<Object> 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:
*
* <pre>
* ….preview(…).forLink("…", "relation")
* </pre>
*
* will result in the link added to the representation and an embedded being registered for the link's relation:
*
* <pre>
* {
* "_links" : {
* "relation" : { … }
* },
* "_embedded" : {
* "relation" : …
* }
* }
* </pre>
*
* @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.
* <p/>
* 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<Link> 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<T> extends EntityModel<T> {
private final T entity;
private final CollectionModel<?> embeddeds;
public HalRepresentationModel(@Nullable T entity, CollectionModel<T> 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));
}
}
}

View File

@@ -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) {

View File

@@ -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<ObjectMapper> configurer) {
ObjectMapper mapper = defaultObjectMapper();

View File

@@ -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<Integer, ZoomProduct> 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<EntityModel<ZoomProduct>> 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<ZoomProduct> 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;
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,11 @@
{
"name" : "Greg L. Turnquist",
"_links" : {
"self" : {
"href" : "http://localhost/author/1"
},
"authors" : {
"href" : "http://localhost/authors"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name" : "Alan Watts",
"born" : "January 6, 1915",
"died" : "November 16, 1973",
"_links" : {
"self" : {
"href" : "/people/alan-watts"
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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"
} ]
}
}