From c335bb77e4c89adf0b947ffe7b93ad6ff04368bf Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Tue, 7 Apr 2020 11:42:29 -0500 Subject: [PATCH 1/2] Create branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 26b074f36e8fd9b46e6a439b3ac91f701f0a2667 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Wed, 6 Mar 2019 09:31:22 -0600 Subject: [PATCH 2/2] #864 - Create a fluent API to build hypermedia models. * Implement a `DefaultModelBuilder` that only focuses on entities and links. This allows building some of the simplest representations that are suppoerted by all formats. * Implement a `HalModelBuilder` that supports the same basic operations but also includes HAL-specific embed() and previewFor() as HAL syntax sugar. `DefaultModelBuilder` allows defining "simple" formats (single-item or collection at an aggregate root). A `builder()` static helper is provided to create an instance of this type. `HalModelBuilder` allows going into HAL-specific details, like `embed()` and `previewFor`, where you can specify an entity AND it's link relation. This results in a representation that will generate an "_embedded" entry. It also marks the `RepresntationModel` object with a HAL/HAL-FORMS "preferredMediaType", allowing other serializers to either log warnings, or fail. Having the interface grants hypermedia authors the ability to create their own customized model builders as they see fit. Also, introduces a preferredMediaTypes attribute in RepresentationModel so various serializers can warn if the user is attempting to serialize a HAL-specific representation as, say, Collection+JSON. Original pull request: #1273. Related issue: #193. --- .../org/springframework/hateoas/Model.java | 290 +++++++++++++ .../hateoas/RepresentationModel.java | 15 + .../Jackson2CollectionJsonModule.java | 24 ++ .../mediatype/uber/Jackson2UberModule.java | 21 + .../hateoas/MappingTestUtils.java | 4 + .../hateoas/ModelBuilderUnitTest.java | 406 ++++++++++++++++++ .../hal-embedded-author-illustrator.json | 33 ++ .../hateoas/hal-embedded-collection.json | 40 ++ .../springframework/hateoas/hal-empty.json | 1 + .../hal-explicit-and-implicit-relations.json | 34 ++ .../hateoas/hal-multiple-types.json | 23 + .../hateoas/hal-one-thing.json | 11 + .../hateoas/hal-single-item.json | 10 + .../hateoas/hal-two-things.json | 19 + .../hateoas/zoom-hypermedia.json | 94 ++++ 15 files changed, 1025 insertions(+) create mode 100644 src/main/java/org/springframework/hateoas/Model.java create mode 100644 src/test/java/org/springframework/hateoas/ModelBuilderUnitTest.java create mode 100644 src/test/resources/org/springframework/hateoas/hal-embedded-author-illustrator.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-embedded-collection.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-empty.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-explicit-and-implicit-relations.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-multiple-types.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-one-thing.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-single-item.json create mode 100644 src/test/resources/org/springframework/hateoas/hal-two-things.json create mode 100644 src/test/resources/org/springframework/hateoas/zoom-hypermedia.json 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" + } ] + } +}