diff --git a/pom.xml b/pom.xml
index 814229520..dab4f52e0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.hateoas
spring-hateoas
- 1.1.0.BUILD-SNAPSHOT
+ 1.1.0.HATEOAS-864-SNAPSHOT
Spring HATEOAS
https://github.com/spring-projects/spring-hateoas
diff --git a/src/main/java/org/springframework/hateoas/Model.java b/src/main/java/org/springframework/hateoas/Model.java
new file mode 100644
index 000000000..15c22b882
--- /dev/null
+++ b/src/main/java/org/springframework/hateoas/Model.java
@@ -0,0 +1,290 @@
+/*
+ * 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;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.springframework.hateoas.server.core.EmbeddedWrappers;
+
+/**
+ * Builder for hypermedia representations.
+ *
+ * @author Greg Turnquist
+ * @since 1.1
+ */
+public interface Model {
+
+ /**
+ * Helper method to create a basic {@link Builder} that will support adding entities and {@link Link}s.
+ *
+ * @return
+ */
+ static Model.Builder builder() {
+ return new DefaultModelBuilder();
+ }
+
+ /**
+ * Helper method to create a {@link HalModelBuilder} that supports basic and embedded operations.
+ *
+ * @return
+ */
+ static Model.HalModelBuilder hal() {
+ return new HalModelBuilder();
+ }
+
+ /**
+ * The contract for any hypermedia representation builder.
+ *
+ * @author Greg Turnquist
+ * @since 1.1
+ */
+ interface Builder {
+
+ /**
+ * Add an entity to the representation.
+ *
+ * @param entity
+ * @return
+ */
+ Builder entity(Object entity);
+
+ /**
+ * Add a {@link Link} to the representation.
+ *
+ * @param link
+ * @return
+ */
+ Builder link(Link link);
+
+ /**
+ * Transform the collected details into a {@link RepresentationModel}.
+ *
+ * @return
+ */
+ RepresentationModel> build();
+ }
+
+ /**
+ * Default {@link Builder} that assembles simple hypermedia representations with a list of entities and a {@link List}
+ * of {@link Link}s.
+ *
+ * The {@link RepresentationModel} that gets built should work with any hypermedia type.
+ *
+ * @author Greg Turnquist
+ * @since 1.1
+ */
+ final class DefaultModelBuilder implements Builder {
+
+ private final List entities = new ArrayList<>();
+ private final List links = new ArrayList<>();
+
+ /**
+ * Add an entity. Can be anything, whether a bare domain object or some {@link RepresentationModel}.
+ *
+ * @param entity
+ * @return
+ */
+ @Override
+ public Builder entity(Object entity) {
+
+ this.entities.add(entity);
+ return this;
+ }
+
+ /**
+ * Add a {@link Link}.
+ *
+ * @param link
+ * @return
+ */
+ @Override
+ public Builder link(Link link) {
+
+ this.links.add(link);
+ return this;
+ }
+
+ /**
+ * Transform the entities and {@link Link}s into a {@link RepresentationModel} with no preferred media type format.
+ *
+ * @return
+ */
+ @Override
+ public RepresentationModel> build() {
+
+ if (this.entities.isEmpty()) {
+
+ return new RepresentationModel<>(this.links);
+
+ } else if (this.entities.size() == 1) {
+
+ Object content = this.entities.get(0);
+
+ if (RepresentationModel.class.isInstance(content)) {
+ return (RepresentationModel>) content;
+ } else {
+ return EntityModel.of(content, this.links);
+ }
+
+ } else {
+
+ return CollectionModel.of(this.entities, this.links);
+ }
+ }
+ }
+
+ /**
+ * HAL-specific {@link Builder} that assembles a potentially more complex hypermedia representation.
+ *
+ * The {@link RepresentationModel} that is built, if it has embedded entries, will contain a preferred hypermedia
+ * representation of {@link MediaTypes#HAL_JSON} or {@link MediaTypes#HAL_FORMS_JSON}.
+ *
+ * @author Greg Turnquist
+ * @since 1.1
+ */
+ final class HalModelBuilder implements Builder {
+
+ private static final LinkRelation NO_RELATION = LinkRelation.of("___norel___");
+
+ private final Map> entityModels = new LinkedHashMap<>(); // maintain the original order
+ private final List links = new ArrayList<>();
+
+ /**
+ * Embed the entity, but with no relation.
+ *
+ * @param entity
+ * @return
+ */
+ @Override
+ public HalModelBuilder entity(Object entity) {
+ return embed(NO_RELATION, entity);
+ }
+
+ /**
+ * Embed the entity and associate it with the {@link LinkRelation}.
+ *
+ * @param linkRelation
+ * @param entity
+ * @return
+ */
+ public HalModelBuilder embed(LinkRelation linkRelation, Object entity) {
+
+ this.entityModels.computeIfAbsent(linkRelation, r -> new ArrayList<>()).add(entity);
+ return this;
+ }
+
+ /**
+ * A common usage of embedded entries are to define a read-only preview. This method provides syntax sugar for
+ * {@link #embed(LinkRelation, Object)}.
+ *
+ * @param linkRelation
+ * @param entity
+ * @return
+ */
+ public HalModelBuilder previewFor(LinkRelation linkRelation, Object entity) {
+ return embed(linkRelation, entity);
+ }
+
+ /**
+ * 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
+ * @return
+ */
+ @Override
+ public HalModelBuilder link(Link link) {
+
+ this.links.add(link);
+ return this;
+ }
+
+ /**
+ * Transform the entities and {@link Link}s into a {@link RepresentationModel}. If there are embedded entries, add a
+ * preferred mediatype of {@link MediaTypes#HAL_JSON} and {@link MediaTypes#HAL_FORMS_JSON}.
+ *
+ * @return
+ */
+ @Override
+ public RepresentationModel> build() {
+
+ /**
+ * If there are no specific {@link LinkRelation}s, and there is no more than one entity, give a simplified
+ * response.
+ */
+ if (hasNoSpecificLinkRelations()) {
+
+ if (noEntities()) {
+ return new RepresentationModel<>(this.links);
+ }
+
+ if (justOneEntity()) {
+ return EntityModel.of(this.entityModels.get(NO_RELATION).get(0), this.links);
+ }
+
+ // If there is more, just use the code below.
+ }
+
+ EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
+
+ return this.entityModels.keySet().stream() //
+ .flatMap(linkRelation -> this.entityModels.get(linkRelation).stream() //
+ .map(source -> {
+ if (RepresentationModel.class.isInstance(source)) {
+ ((RepresentationModel>) source).addPreferredMediaType(MediaTypes.HAL_JSON,
+ MediaTypes.HAL_FORMS_JSON);
+ }
+ return wrappers.wrap(source, linkRelation);
+ })) //
+ .collect(Collectors.collectingAndThen(Collectors.toList(),
+ embeddedWrappers -> CollectionModel.of(embeddedWrappers, this.links)));
+ }
+
+ /**
+ * Are there no specific link relations?
+ *
+ * @return
+ */
+ private boolean hasNoSpecificLinkRelations() {
+ return this.entityModels.keySet().size() == 1 && this.entityModels.containsKey(NO_RELATION);
+ }
+
+ /**
+ * Are there no entities contained in the unrelated {@link #entityModels}?
+ *
+ * @return
+ */
+ private boolean noEntities() {
+ return this.entityModels.containsKey(NO_RELATION) && this.entityModels.get(NO_RELATION).size() == 0;
+ }
+
+ /**
+ * Is there just one entity in the unrelated {@link #entityModels}?
+ *
+ * @return
+ */
+ private boolean justOneEntity() {
+ return this.entityModels.containsKey(NO_RELATION) && this.entityModels.get(NO_RELATION).size() == 1;
+ }
+
+ }
+}
diff --git a/src/main/java/org/springframework/hateoas/RepresentationModel.java b/src/main/java/org/springframework/hateoas/RepresentationModel.java
index b4a75f5e9..5ea47ffec 100755
--- a/src/main/java/org/springframework/hateoas/RepresentationModel.java
+++ b/src/main/java/org/springframework/hateoas/RepresentationModel.java
@@ -15,6 +15,8 @@
*/
package org.springframework.hateoas;
+import lombok.Getter;
+
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -24,9 +26,11 @@
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
@@ -40,6 +44,8 @@ public class RepresentationModel> {
private final List links;
+ private final @Getter(onMethod = @__(@JsonIgnore)) List preferredMediaTypes = new ArrayList<>();
+
public RepresentationModel() {
this.links = new ArrayList<>();
}
@@ -299,6 +305,15 @@ public List getLinks(LinkRelation relation) {
return getLinks(relation.value());
}
+ /**
+ * Add a hint about what {@link MediaType} this model prefers.
+ *
+ * @param preferredMediaTypes
+ */
+ public void addPreferredMediaType(MediaType... preferredMediaTypes) {
+ this.preferredMediaTypes.addAll(Arrays.asList(preferredMediaTypes));
+ }
+
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
index 94c2fdb77..0e50df7ac 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
@@ -191,6 +191,12 @@ static class CollectionJsonResourceSupportSerializer extends ContainerSerializer
public void serialize(RepresentationModel> value, JsonGenerator jgen, SerializerProvider provider)
throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty()
+ && !value.getPreferredMediaTypes().contains(MediaTypes.COLLECTION_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate Collection+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
String href = value.getRequiredLink(IanaLinkRelations.SELF.value()).getHref();
CollectionJson collectionJson = new CollectionJson<>() //
@@ -292,6 +298,12 @@ static class CollectionJsonResourceSerializer extends ContainerSerializer value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty()
+ && !value.getPreferredMediaTypes().contains(MediaTypes.COLLECTION_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate Collection+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
String href = value.getRequiredLink(IanaLinkRelations.SELF).getHref();
Links withoutSelfLink = value.getLinks().without(IanaLinkRelations.SELF);
@@ -382,6 +394,12 @@ static class CollectionJsonResourcesSerializer extends ContainerSerializer value, JsonGenerator jgen, SerializerProvider provider)
throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty()
+ && !value.getPreferredMediaTypes().contains(MediaTypes.COLLECTION_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate Collection+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
CollectionJson collectionJson = new CollectionJson<>() //
.withVersion("1.0") //
.withHref(value.getRequiredLink(IanaLinkRelations.SELF).getHref()) //
@@ -473,6 +491,12 @@ static class CollectionJsonPagedResourcesSerializer extends ContainerSerializer<
@SuppressWarnings("null")
public void serialize(PagedModel> value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty()
+ && !value.getPreferredMediaTypes().contains(MediaTypes.COLLECTION_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate Collection+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
CollectionJson> collectionJson = new CollectionJson<>() //
.withVersion("1.0") //
.withHref(value.getRequiredLink(IanaLinkRelations.SELF).getHref()) //
diff --git a/src/main/java/org/springframework/hateoas/mediatype/uber/Jackson2UberModule.java b/src/main/java/org/springframework/hateoas/mediatype/uber/Jackson2UberModule.java
index 1ec149025..4dad5b0ea 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/uber/Jackson2UberModule.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/uber/Jackson2UberModule.java
@@ -32,6 +32,7 @@
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.Links;
+import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.PagedModel.PageMetadata;
import org.springframework.hateoas.RepresentationModel;
@@ -150,6 +151,11 @@ static class UberRepresentationModelSerializer extends ContainerSerializer value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty() && !value.getPreferredMediaTypes().contains(MediaTypes.UBER_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate UBER+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
UberDocument doc = new UberDocument() //
.withUber(new Uber() //
.withVersion("1.0") //
@@ -240,6 +246,11 @@ static class UberEntityModelSerializer extends ContainerSerializer value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty() && !value.getPreferredMediaTypes().contains(MediaTypes.UBER_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate UBER+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
UberDocument doc = new UberDocument().withUber(new Uber() //
.withVersion("1.0") //
.withData(extractLinksAndContent(value)));
@@ -330,6 +341,11 @@ static class UberCollectionModelSerializer extends ContainerSerializer value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty() && !value.getPreferredMediaTypes().contains(MediaTypes.UBER_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate UBER+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
UberDocument doc = new UberDocument() //
.withUber(new Uber() //
.withVersion("1.0") //
@@ -420,6 +436,11 @@ static class UberPagedModelSerializer extends ContainerSerializer>
@SuppressWarnings("null")
public void serialize(PagedModel> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+ if (!value.getPreferredMediaTypes().isEmpty() && !value.getPreferredMediaTypes().contains(MediaTypes.UBER_JSON)) {
+ throw new IllegalStateException(
+ "You are about to generate UBER+JSON for a model that prefers " + value.getPreferredMediaTypes());
+ }
+
UberDocument doc = new UberDocument() //
.withUber(new Uber() //
.withVersion("1.0") //
diff --git a/src/test/java/org/springframework/hateoas/MappingTestUtils.java b/src/test/java/org/springframework/hateoas/MappingTestUtils.java
index 3d3e44c10..af2d48b61 100644
--- a/src/test/java/org/springframework/hateoas/MappingTestUtils.java
+++ b/src/test/java/org/springframework/hateoas/MappingTestUtils.java
@@ -51,6 +51,10 @@ public static ObjectMapper defaultObjectMapper() {
return mapper;
}
+ public static ContextualMapper createMapper(Class> context) {
+ return createMapper(context, objectMapper -> {});
+ }
+
public static ContextualMapper createMapper(Class> context, Consumer configurer) {
ObjectMapper mapper = defaultObjectMapper();
diff --git a/src/test/java/org/springframework/hateoas/ModelBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/ModelBuilderUnitTest.java
new file mode 100644
index 000000000..ccc6bd627
--- /dev/null
+++ b/src/test/java/org/springframework/hateoas/ModelBuilderUnitTest.java
@@ -0,0 +1,406 @@
+/*
+ * 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;
+
+import static org.assertj.core.api.AssertionsForClassTypes.*;
+import static org.springframework.hateoas.IanaLinkRelations.*;
+import static org.springframework.hateoas.MappingTestUtils.*;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.Getter;
+import lombok.Value;
+
+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.mediatype.MessageResolver;
+import org.springframework.hateoas.mediatype.collectionjson.Jackson2CollectionJsonModule;
+import org.springframework.hateoas.mediatype.hal.CurieProvider;
+import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
+import org.springframework.hateoas.mediatype.uber.Jackson2UberModule;
+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.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+/**
+ * @author Greg Turnquist
+ */
+public class ModelBuilderUnitTest {
+
+ 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 = Model.hal() //
+ .embed(LinkRelation.of("author"), Model.builder() //
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build())
+ .embed(LinkRelation.of("illustrator"), Model.builder() //
+ .entity(new Author("John Smith", null, null)) //
+ .link(Link.of("/people/john-smith")) //
+ .build())
+ .link(Link.of("/books/the-way-of-zen")) //
+ .link(Link.of("/people/alan-watts", LinkRelation.of("author"))) //
+ .link(Link.of("/people/john-smith", LinkRelation.of("illustrator"))) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model))
+ .isEqualTo(contextualMapper.readFile("hal-embedded-author-illustrator.json"));
+ }
+
+ @Test // #864
+ void previewForLinkRelationsUsingHalModelBuilder() throws Exception {
+
+ RepresentationModel> model = Model.hal() //
+ .previewFor(LinkRelation.of("author"), Model.builder() //
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build())
+ .previewFor(LinkRelation.of("illustrator"), Model.builder() //
+ .entity(new Author("John Smith", null, null)) //
+ .link(Link.of("/people/john-smith")) //
+ .build())
+ .link(Link.of("/books/the-way-of-zen")) //
+ .link(Link.of("/people/alan-watts", LinkRelation.of("author"))) //
+ .link(Link.of("/people/john-smith", LinkRelation.of("illustrator"))) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model))
+ .isEqualTo(contextualMapper.readFile("hal-embedded-author-illustrator.json"));
+ }
+
+ @Test // #864
+ void embeddedSpecUsingHalModelBuilderButRenderedAsCollectionJson() throws Exception {
+
+ RepresentationModel> model = Model.hal() //
+ .embed(LinkRelation.of("author"), Model.builder() //
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build())
+ .embed(LinkRelation.of("illustrator"), Model.builder() //
+ .entity(new Author("John Smith", null, null)) //
+ .link(Link.of("/people/john-smith")) //
+ .build())
+ .link(Link.of("/books/the-way-of-zen")) //
+ .link(Link.of("/people/alan-watts", LinkRelation.of("author"))) //
+ .link(Link.of("/people/john-smith", LinkRelation.of("illustrator"))) //
+ .build();
+
+ this.mapper = new ObjectMapper();
+ this.mapper.registerModule(new Jackson2CollectionJsonModule());
+ this.mapper.enable(SerializationFeature.INDENT_OUTPUT);
+
+ assertThatExceptionOfType(JsonMappingException.class).isThrownBy(() -> this.mapper.writeValueAsString(model));
+ }
+
+ @Test // #864
+ void embeddedSpecUsingHalModelBuilderButRenderedAsUber() throws Exception {
+
+ RepresentationModel> model = Model.hal() //
+ .embed(LinkRelation.of("author"), Model.builder() //
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build())
+ .embed(LinkRelation.of("illustrator"), Model.builder() //
+ .entity(new Author("John Smith", null, null)) //
+ .link(Link.of("/people/john-smith")) //
+ .build())
+ .link(Link.of("/books/the-way-of-zen")) //
+ .link(Link.of("/people/alan-watts", LinkRelation.of("author"))) //
+ .link(Link.of("/people/john-smith", LinkRelation.of("illustrator"))) //
+ .build();
+
+ this.mapper = new ObjectMapper();
+ this.mapper.registerModule(new Jackson2UberModule());
+ this.mapper.enable(SerializationFeature.INDENT_OUTPUT);
+
+ assertThatExceptionOfType(JsonMappingException.class).isThrownBy(() -> this.mapper.writeValueAsString(model));
+ }
+
+ @Test // #864
+ void renderSingleItemUsingHalModelBuilder() throws Exception {
+
+ RepresentationModel> model = Model.hal() //
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model)).isEqualTo(contextualMapper.readFile("hal-single-item.json"));
+ }
+
+ @Test // #864
+ void renderSingleItemUsingDefaultModelBuilder() throws Exception {
+
+ RepresentationModel> model = Model.builder()
+ .entity(new Author("Alan Watts", "January 6, 1915", "November 16, 1973")) //
+ .link(Link.of("/people/alan-watts")) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model)).isEqualTo(contextualMapper.readFile("hal-single-item.json"));
+ }
+
+ @Test // #864
+ void renderCollectionUsingDefaultModelBuilder() throws Exception {
+
+ RepresentationModel> model = Model.builder() //
+ .entity( //
+ Model.builder() //
+ .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())
+ .entity( //
+ Model.builder() //
+ .entity(new Author("Craig Walls", null, null)) //
+ .link(Link.of("http://localhost/author/2")) //
+ .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) //
+ .build())
+ .entity( //
+ Model.builder() //
+ .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 // #864
+ void renderCollectionUsingHalModelBuilder() throws Exception {
+
+ RepresentationModel> model = Model.hal() //
+ .entity( //
+ Model.builder() //
+ .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())
+ .entity( //
+ Model.builder() //
+ .entity(new Author("Craig Walls", null, null)) //
+ .link(Link.of("http://localhost/author/2")) //
+ .link(Link.of("http://localhost/authors", LinkRelation.of("authors"))) //
+ .build())
+ .entity( //
+ Model.builder() //
+ .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 {
+
+ Model.HalModelBuilder halModelBuilder = Model.hal();
+
+ assertThat(this.mapper.writeValueAsString(halModelBuilder.build()))
+ .isEqualTo(contextualMapper.readFile("hal-empty.json"));
+
+ halModelBuilder //
+ .entity(Model.builder() //
+ .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(LinkRelation.of("products"), new Product("Alf alarm clock", 19.99)).build();
+
+ assertThat(this.mapper.writeValueAsString(halModelBuilder.build()))
+ .isEqualTo(contextualMapper.readFile("hal-two-things.json"));
+ }
+
+ @Test // #193
+ void renderDifferentlyTypedEntitiesUsingDefaultModelBuilder() throws Exception {
+
+ Staff staff1 = new Staff("Frodo Baggins", "ring bearer");
+ Staff staff2 = new Staff("Bilbo Baggins", "burglar");
+
+ Product product1 = new Product("ring of power", 999.99);
+ Product product2 = new Product("Saruman's staff", 9.99);
+
+ RepresentationModel> model = Model.builder() //
+ .entity(staff1) //
+ .entity(staff2) //
+ .entity(product1) //
+ .entity(product2) //
+ .link(Link.of("/people/alan-watts")) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model)).isEqualTo(contextualMapper.readFile("hal-multiple-types.json"));
+ }
+
+ @Test // #193
+ void renderDifferentlyTypedEntitiesUsingHalModelBuilder() throws Exception {
+
+ Staff staff1 = new Staff("Frodo Baggins", "ring bearer");
+ Staff staff2 = new Staff("Bilbo Baggins", "burglar");
+
+ Product product1 = new Product("ring of power", 999.99);
+ Product product2 = new Product("Saruman's staff", 9.99);
+
+ RepresentationModel> model = Model.hal() //
+ .entity(staff1) //
+ .entity(staff2) //
+ .entity(product1) //
+ .entity(product2) //
+ .link(Link.of("/people/alan-watts")) //
+ .build();
+
+ assertThat(this.mapper.writeValueAsString(model)).isEqualTo(contextualMapper.readFile("hal-multiple-types.json"));
+ }
+
+ @Test // #193
+ void renderExplicitAndImplicitLinkRelationsUsingHalModelBuilder() throws Exception {
+
+ Staff staff1 = new Staff("Frodo Baggins", "ring bearer");
+ Staff staff2 = new Staff("Bilbo Baggins", "burglar");
+
+ Product product1 = new Product("ring of power", 999.99);
+ Product product2 = new Product("Saruman's staff", 9.99);
+
+ RepresentationModel> model = Model.hal() //
+ .entity(staff1) //
+ .entity(staff2) //
+ .entity(product1) //
+ .entity(product2) //
+ .link(Link.of("/people/alan-watts")) //
+ .embed(LinkRelation.of("ring bearers"), staff1) //
+ .embed(LinkRelation.of("burglars"), staff2) //
+ .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");
+
+ Model.HalModelBuilder builder = Model.hal();
+
+ builder.link(Link.of("/products").withSelfRel());
+
+ for (EntityModel productEntityModel : productCollectionModel) {
+
+ if (productEntityModel.getContent().isFavorite()) {
+
+ builder //
+ .embed(favoriteProducts, productEntityModel) //
+ .link(productEntityModel.getRequiredLink(SELF).withRel(favoriteProducts));
+ }
+
+ if (productEntityModel.getContent().isPurchased()) {
+
+ builder //
+ .embed(purchasedProducts, productEntityModel) //
+ .link(productEntityModel.getRequiredLink(SELF).withRel(purchasedProducts));
+ }
+ }
+
+ assertThat(this.mapper.writeValueAsString(builder.build()))
+ .isEqualTo(contextualMapper.readFile("zoom-hypermedia.json"));
+ }
+
+ @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/hal-embedded-author-illustrator.json b/src/test/resources/org/springframework/hateoas/hal-embedded-author-illustrator.json
new file mode 100644
index 000000000..6b26ee821
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-embedded-collection.json b/src/test/resources/org/springframework/hateoas/hal-embedded-collection.json
new file mode 100644
index 000000000..a20078589
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-empty.json b/src/test/resources/org/springframework/hateoas/hal-empty.json
new file mode 100644
index 000000000..ffcd4415b
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/hal-empty.json
@@ -0,0 +1 @@
+{ }
diff --git a/src/test/resources/org/springframework/hateoas/hal-explicit-and-implicit-relations.json b/src/test/resources/org/springframework/hateoas/hal-explicit-and-implicit-relations.json
new file mode 100644
index 000000000..70acc1c15
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-multiple-types.json b/src/test/resources/org/springframework/hateoas/hal-multiple-types.json
new file mode 100644
index 000000000..81114f05c
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-one-thing.json b/src/test/resources/org/springframework/hateoas/hal-one-thing.json
new file mode 100644
index 000000000..58b55554e
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-single-item.json b/src/test/resources/org/springframework/hateoas/hal-single-item.json
new file mode 100644
index 000000000..73acd0966
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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/hal-two-things.json b/src/test/resources/org/springframework/hateoas/hal-two-things.json
new file mode 100644
index 000000000..e710184e8
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/hal-two-things.json
@@ -0,0 +1,19 @@
+{
+ "_embedded" : {
+ "author" : {
+ "name" : "Greg L. Turnquist",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost/author/1"
+ },
+ "authors" : {
+ "href" : "http://localhost/authors"
+ }
+ }
+ },
+ "products" : {
+ "name" : "Alf alarm clock",
+ "price" : 19.99
+ }
+ }
+}
diff --git a/src/test/resources/org/springframework/hateoas/zoom-hypermedia.json b/src/test/resources/org/springframework/hateoas/zoom-hypermedia.json
new file mode 100644
index 000000000..e567ca98c
--- /dev/null
+++ b/src/test/resources/org/springframework/hateoas/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"
+ } ]
+ }
+}