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