#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:
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name" : "Greg L. Turnquist",
|
||||
"_links" : {
|
||||
"self" : {
|
||||
"href" : "http://localhost/author/1"
|
||||
},
|
||||
"authors" : {
|
||||
"href" : "http://localhost/authors"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name" : "Alan Watts",
|
||||
"born" : "January 6, 1915",
|
||||
"died" : "November 16, 1973",
|
||||
"_links" : {
|
||||
"self" : {
|
||||
"href" : "/people/alan-watts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
} ]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user