From 70448a854098343897fd99e3227b4a61db54b921 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Thu, 13 Jul 2017 13:36:36 -0500 Subject: [PATCH] #340 - Add new Affordances API + HAL-FORMS mediatype. * Introduces new Affordances API to build links related to each other to serve other mediatypes * Introduces HAL-FORMS, which uses affordances to automatically generate HTML form data based on Spring MVC annotations. Original pull-request: #340, #447, #581 Related issues: #503, #334, #71 --- src/main/asciidoc/index.adoc | 2 +- .../springframework/hateoas/Affordance.java | 57 +++ .../hateoas/AffordanceModel.java | 25 ++ .../hateoas/AffordanceModelFactory.java | 57 +++ .../org/springframework/hateoas/Link.java | 66 ++- .../springframework/hateoas/MediaTypes.java | 14 + .../config/EnableHypermediaSupport.java | 59 ++- ...ermediaSupportBeanDefinitionRegistrar.java | 120 +++-- .../core/AnnotationMappingDiscoverer.java | 32 ++ .../hateoas/core/LinkBuilderSupport.java | 27 +- .../hateoas/core/MappingDiscoverer.java | 14 +- .../hateoas/hal/Jackson2HalModule.java | 30 +- .../hateoas/hal/LinkMixin.java | 2 +- .../hateoas/hal/ResourceSupportMixin.java | 9 +- .../hateoas/hal/ResourcesMixin.java | 7 + .../hal/forms/HalFormsAffordanceModel.java | 149 ++++++ .../forms/HalFormsAffordanceModelFactory.java | 42 ++ .../hal/forms/HalFormsConfiguration.java | 67 +++ .../hal/forms/HalFormsDeserializers.java | 145 ++++++ .../hateoas/hal/forms/HalFormsDocument.java | 112 +++++ .../hal/forms/HalFormsLinkDiscoverer.java | 31 ++ .../hal/forms/HalFormsMessageConverter.java | 92 ++++ .../hateoas/hal/forms/HalFormsProperty.java | 57 +++ .../hal/forms/HalFormsSerializers.java | 245 ++++++++++ .../hateoas/hal/forms/HalFormsTemplate.java | 92 ++++ .../hal/forms/HalFormsWebMvcConfigurer.java | 43 ++ .../hal/forms/Jackson2HalFormsModule.java | 176 ++++++++ .../hal/forms/PagedResourcesMixin.java | 37 ++ .../hateoas/hal/forms/ResourceMixin.java | 28 ++ .../hateoas/hal/forms/ResourcesMixin.java | 45 ++ .../hateoas/mvc/ControllerLinkBuilder.java | 54 ++- .../mvc/ControllerLinkBuilderFactory.java | 8 +- .../hateoas/mvc/SpringMvcAffordance.java | 81 ++++ .../mvc/SpringMvcAffordanceBuilder.java | 76 ++++ .../hateoas/LinkIntegrationTest.java | 1 + .../springframework/hateoas/LinkUnitTest.java | 31 ++ ...nableHypermediaSupportIntegrationTest.java | 144 ++++++ .../hateoas/hal/forms/Employee.java | 30 ++ .../hateoas/hal/forms/EmployeeResource.java | 31 ++ .../forms/HalFormsLinkDiscovererUnitTest.java | 60 +++ .../forms/HalFormsMessageConverterTest.java | 143 ++++++ .../hal/forms/HalFormsValidationTest.java | 236 ++++++++++ .../hateoas/hal/forms/HalFormsWebMvcTest.java | 253 +++++++++++ .../Jackson2HalFormsIntegrationTest.java | 425 ++++++++++++++++++ .../mvc/ControllerLinkBuilderUnitTest.java | 1 + .../SpringMvcAffordanceBuilderUnitTests.java | 91 ++++ .../mvc/TypeReferencesIntegrationTest.java | 1 + .../hateoas/support/MappingUtils.java | 60 +++ ...nnotated-embedded-resources-reference.json | 21 + .../hal/forms/annotated-paged-resources.json | 35 ++ .../forms/annotated-resource-resources.json | 18 + .../hateoas/hal/forms/curied-document.json | 16 + .../hateoas/hal/forms/empty-document.json | 3 + .../hal/forms/empty-embedded-pojos.json | 5 + .../hateoas/hal/forms/link-template.json | 8 + .../hateoas/hal/forms/link-with-title.json | 8 + .../hal/forms/list-link-reference.json | 9 + .../hal/forms/multiple-curies-document.json | 15 + .../forms/multiple-resource-resources.json | 26 ++ .../hateoas/hal/forms/reference.json | 25 ++ .../simple-embedded-resource-reference.json | 10 + .../hal/forms/simple-resource-unwrapped.json | 9 + .../single-embedded-resource-reference.json | 18 + .../hal/forms/single-link-reference.json | 7 + .../hal/forms/single-non-curie-document.json | 8 + 65 files changed, 3776 insertions(+), 73 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/Affordance.java create mode 100644 src/main/java/org/springframework/hateoas/AffordanceModel.java create mode 100644 src/main/java/org/springframework/hateoas/AffordanceModelFactory.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsDocument.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscoverer.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverter.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsTemplate.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcConfigurer.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/PagedResourcesMixin.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/ResourceMixin.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/ResourcesMixin.java create mode 100644 src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordance.java create mode 100644 src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/Employee.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/EmployeeResource.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscovererUnitTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverterTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/HalFormsValidationTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsIntegrationTest.java create mode 100644 src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTests.java create mode 100644 src/test/java/org/springframework/hateoas/support/MappingUtils.java create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/annotated-embedded-resources-reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/annotated-paged-resources.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/annotated-resource-resources.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/curied-document.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/empty-document.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/empty-embedded-pojos.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/link-template.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/link-with-title.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/list-link-reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/multiple-curies-document.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/multiple-resource-resources.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/simple-embedded-resource-reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/simple-resource-unwrapped.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/single-embedded-resource-reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/single-link-reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/single-non-curie-document.json diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index d7e5baa7..32d59244 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -316,7 +316,7 @@ A `RelProvider` is exposed as Spring bean when using `@EnableHypermediaSupport` [[spis.curie-provider]] === CurieProvider API -The http://tools.ietf.org/html/rfc5988=section-4[Web Linking RFC] describes registered and extension link relation types. Registered rels are well-known strings registered with the http://www.iana.org/assignments/link-relations/link-relations.xhtml[IANA registry of link relation types]. Extension rels can be used by applications that do not wish to register a relation type. They are a URI that uniquely identifies the relation type. The rel URI can be serialized as a compact URI or http://www.w3.org/TR/curie[Curie]. E.g. a curie `ex:persons` stands for the link relation type `http://example.com/rels/persons` if `ex` is defined as `http://example.com/rels/{rels}`. If curies are used, the base URI must be present in the response scope. +The http://tools.ietf.org/html/rfc5988=section-4[Web Linking RFC] describes registered and extension link relation types. Registered rels are well-known strings registered with the http://www.iana.org/assignments/link-relations/link-relations.xhtml[IANA registry of link relation types]. Extension rels can be used by applications that do not wish to register a relation type. They are a URI that uniquely identifies the relation type. The rel URI can be serialized as a compact URI or http://www.w3.org/TR/curie[Curie]. E.g. a curie `ex:persons` stands for the link relation type `http://example.com/rels/persons` if `ex` is defined as `http://example.com/rels/{rel}`. If curies are used, the base URI must be present in the response scope. The rels created by the default `RelProvider` are extension relation types and as such must be URIs, which can cause a lot of overhead. The `CurieProvider` API takes care of that: it allows to define a base URI as URI template and a prefix which stands for that base URI. If a `CurieProvider` is present, the `RelProvider` prepends all rels with the curie prefix. Furthermore a `curies` link is automatically added to the HAL resource. diff --git a/src/main/java/org/springframework/hateoas/Affordance.java b/src/main/java/org/springframework/hateoas/Affordance.java new file mode 100644 index 00000000..63745688 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/Affordance.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 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 + * + * http://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 org.springframework.http.MediaType; + +/** + * Abstract representation of an action a link is able to take. Web frameworks must provide concrete implementation. + * + * @author Greg Turnquist + */ +public interface Affordance { + + /** + * HTTP method this affordance covers. For multiple methods, add multiple {@link Affordance}s. + * + * @return + */ + String getHttpMethod(); + + /** + * Name for the REST action this {@link Affordance} can take. + * + * @return + */ + String getName(); + + /** + * Look up the {@link AffordanceModel} for the requested {@link MediaType}. + * + * @param mediaType + * @return + */ + AffordanceModel getAffordanceModel(MediaType mediaType); + + /** + * Add a new {@link AffordanceModel} for a given {@link MediaType}. + * + * @param mediaType + * @param affordanceModel + */ + void addAffordanceModel(MediaType mediaType, AffordanceModel affordanceModel); + +} diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java new file mode 100644 index 00000000..16f730c6 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java @@ -0,0 +1,25 @@ +/* + * Copyright 2017 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 + * + * http://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; + +/** + * Marker interface for mediatypes to build up type-specific details for an {@link Affordance} + * + * @author Greg Turnquist + */ +public interface AffordanceModel { + +} diff --git a/src/main/java/org/springframework/hateoas/AffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/AffordanceModelFactory.java new file mode 100644 index 00000000..28a95d84 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/AffordanceModelFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 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 + * + * http://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 org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.MediaType; +import org.springframework.plugin.core.Plugin; +import org.springframework.web.util.UriComponents; + +/** + * TODO: Replace this with an interface and a default implementation of {@link #supports(MediaType)} in Java 8. + * + * @author Greg Turnquist + */ +public abstract class AffordanceModelFactory implements Plugin { + + /** + * Look up the {@link MediaType} of this factory. + * + * @return + */ + abstract public MediaType getMediaType(); + + /** + * Look up the {@link AffordanceModel} for this factory. + * + * @param affordance + * @param invocationValue + * @param components + * @return + */ + abstract public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components); + + /** + * Find factories based on {@link MediaType}. + * + * @param delimiter + * @return + */ + @Override + public boolean supports(MediaType delimiter) { + return delimiter != null && delimiter.equals(this.getMediaType()); + } +} diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index 388708b8..1ecb000e 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -19,10 +19,10 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.experimental.Wither; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -34,6 +34,7 @@ import javax.xml.bind.annotation.XmlAttribute; import javax.xml.bind.annotation.XmlTransient; import javax.xml.bind.annotation.XmlType; +import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -48,10 +49,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; */ @XmlType(name = "link", namespace = Link.ATOM_NAMESPACE) @JsonIgnoreProperties("templated") -@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PACKAGE) @Getter -@EqualsAndHashCode(of = { "rel", "href", "hreflang", "media", "title", "deprecation" }) +@EqualsAndHashCode(of = { "rel", "href", "hreflang", "media", "title", "deprecation", "affordances" }) public class Link implements Serializable { private static final long serialVersionUID = -9037755944661782121L; @@ -73,6 +73,7 @@ public class Link implements Serializable { private @XmlAttribute @Wither String type; private @XmlAttribute @Wither String deprecation; private @XmlTransient @JsonIgnore UriTemplate template; + private @XmlTransient @JsonIgnore List affordances; /** * Creates a new link to the given URI with the self rel. @@ -108,6 +109,32 @@ public class Link implements Serializable { this.template = template; this.href = template.toString(); this.rel = rel; + this.affordances = new ArrayList(); + } + + public Link(String href, String rel, List affordances) { + + this(href, rel); + + Assert.notNull(affordances, "affordances must not be null!"); + + this.affordances = affordances; + } + + /** + * Empty constructor required by the marshalling framework. + */ + protected Link() { + this.affordances = new ArrayList(); + } + + /** + * Returns safe copy of {@link Affordance}s. + * + * @return + */ + public List getAffordances() { + return new ArrayList(Collections.unmodifiableCollection(this.affordances)); } /** @@ -119,6 +146,39 @@ public class Link implements Serializable { return withRel(Link.REL_SELF); } + /** + * Create new {@link Link} with an additional {@link Affordance}. + * + * @param affordance + * @return + */ + public Link withAffordance(Affordance affordance) { + + List newAffordances = new ArrayList(); + newAffordances.addAll(this.affordances); + newAffordances.add(affordance); + + return new Link(this.rel, this.href, this.hreflang ,this.media, this.title, this.type, + this.deprecation, this.template, newAffordances); + } + + /** + * Create new {@link Link} with additional {@link Affordance}s. + * + * @param affordances + * @return + */ + public Link addAffordances(List affordances) { + + List newAffordances = new ArrayList(); + newAffordances.addAll(this.affordances); + newAffordances.addAll(affordances); + + return new Link(this.rel, this.href, this.hreflang ,this.media, this.title, this.type, + this.deprecation, this.template, newAffordances); + } + + /** * Returns the variable names contained in the template. * diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 522465ae..af118e05 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -22,7 +22,10 @@ import org.springframework.http.MediaType; * * @author Oliver Gierke * @author Przemek Nowak +<<<<<<< HEAD * @author Drummond Dawson +======= +>>>>>>> f5bf966... #340 - Add new Affordances API + HAL-FORMS mediatype. * @author Greg Turnquist */ public class MediaTypes { @@ -56,4 +59,15 @@ public class MediaTypes { * Public constant media type for {@code application/alps+json}. */ public static final MediaType ALPS_JSON = MediaType.parseMediaType(ALPS_JSON_VALUE); + + /** + * Public constant media type for {@code application/prs.hal-forms+json}. + */ + public static final String HAL_FORMS_JSON_VALUE = "application/prs.hal-forms+json"; + + /** + * Public constant media type for {@code applicatino/prs.hal-forms+json}. + */ + public static final MediaType HAL_FORMS_JSON = MediaType.parseMediaType(HAL_FORMS_JSON_VALUE); + } diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index 43526c02..11ce2144 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -20,11 +20,20 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportSelector; +import org.springframework.core.type.AnnotationMetadata; import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.hal.forms.HalFormsWebMvcConfigurer; /** * Activates hypermedia support in the {@link ApplicationContext}. Will register infrastructure beans available for @@ -39,11 +48,13 @@ import org.springframework.hateoas.LinkDiscoverer; * @see LinkDiscoverer * @see EntityLinks * @author Oliver Gierke + * @author Greg Turnquist */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented -@Import({ HypermediaSupportBeanDefinitionRegistrar.class, HateoasConfiguration.class }) +@Import({ HypermediaSupportBeanDefinitionRegistrar.class, HateoasConfiguration.class, + EnableHypermediaSupport.HypermediaConfigurationImportSelector.class}) public @interface EnableHypermediaSupport { /** @@ -66,6 +77,50 @@ public @interface EnableHypermediaSupport { * @see http://stateless.co/hal_specification.html * @see http://tools.ietf.org/html/draft-kelly-json-hal-05 */ - HAL; + HAL, + + /** + * HAL-FORMS - Independent, backward-compatible extension of the HAL designed to add runtime FORM support + * @see https://rwcbook.github.io/hal-forms/ + */ + HAL_FORMS(HalFormsWebMvcConfigurer.class); + + private final List> configurations; + + HypermediaType(Class... configurations) { + this.configurations = Arrays.asList(configurations); + } + } + + @Slf4j + class HypermediaConfigurationImportSelector implements ImportSelector { + + @Override + public String[] selectImports(AnnotationMetadata metadata) { + + Map attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName()); + + HypermediaType[] types = (HypermediaType[]) attributes.get("type"); + + /** + * If no types are defined inside the annotation, add them all. + */ + if (types.length == 0) { + types = HypermediaType.values(); + } + + log.debug("Registering support for hypermedia types {} according to configuration on {}", + types, metadata.getClassName()); + + List configurationNames = new ArrayList(); + + for (HypermediaType type : types) { + for (Class configuration : type.configurations) { + configurationNames.add(configuration.getName()); + } + } + + return configurationNames.toArray(new String[0]); + } } } diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java index 1cb30c49..0cdf51a9 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java @@ -57,6 +57,9 @@ import org.springframework.hateoas.hal.CurieProvider; import org.springframework.hateoas.hal.HalConfiguration; import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsLinkDiscoverer; +import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule; import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.Jackson2ObjectMapperFactoryBean; @@ -77,12 +80,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; * activated as well). * * @author Oliver Gierke + * @author Greg Turnquist */ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar, BeanFactoryAware { private static final String DELEGATING_REL_PROVIDER_BEAN_NAME = "_relProvider"; private static final String LINK_DISCOVERER_REGISTRY_BEAN_NAME = "_linkDiscovererRegistry"; + private static final String AFFORDANCE_MODEL_FACTORY_REGISTRY_BEAN_NAME = "_affordanceModelFactoryRegistry"; private static final String HAL_OBJECT_MAPPER_BEAN_NAME = "_halObjectMapper"; + private static final String HAL_FORMS_OBJECT_MAPPER_BEAN_NAME = "_halFormsObjectMapper"; private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource"; private static final boolean JACKSON2_PRESENT = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", @@ -117,23 +123,11 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe } if (types.contains(HypermediaType.HAL)) { + registerHypermediaComponents(metadata, registry, HAL_OBJECT_MAPPER_BEAN_NAME); + } - if (JACKSON2_PRESENT) { - - BeanDefinitionBuilder halQueryMapperBuilder = rootBeanDefinition(ObjectMapper.class); - registerSourcedBeanDefinition(halQueryMapperBuilder, metadata, registry, HAL_OBJECT_MAPPER_BEAN_NAME); - - BeanDefinitionBuilder customizerBeanDefinition = rootBeanDefinition(DefaultObjectMapperCustomizer.class); - registerSourcedBeanDefinition(customizerBeanDefinition, metadata, registry); - - BeanDefinitionBuilder builder = rootBeanDefinition(Jackson2ModuleRegisteringBeanPostProcessor.class); - registerSourcedBeanDefinition(builder, metadata, registry); - } - - // If no HalConfiguration bean, create a default one. - if (this.beanFactory.getBeanNamesForType(HalConfiguration.class).length == 0) { - registerSourcedBeanDefinition(rootBeanDefinition(HalConfiguration.class), metadata, registry); - } + if (types.contains(HypermediaType.HAL_FORMS)) { + registerHypermediaComponents(metadata, registry, HAL_FORMS_OBJECT_MAPPER_BEAN_NAME); } if (!types.isEmpty()) { @@ -152,11 +146,26 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe registerRelProviderPluginRegistryAndDelegate(registry); } + private static void registerHypermediaComponents(AnnotationMetadata metadata, BeanDefinitionRegistry registry, String objectMapperBeanName) { + + if (JACKSON2_PRESENT) { + + BeanDefinitionBuilder queryMapperBuilder = rootBeanDefinition(ObjectMapper.class); + registerSourcedBeanDefinition(queryMapperBuilder, metadata, registry, objectMapperBeanName); + + BeanDefinitionBuilder customizerBeanDefinition = rootBeanDefinition(DefaultObjectMapperCustomizer.class); + registerSourcedBeanDefinition(customizerBeanDefinition, metadata, registry); + + BeanDefinitionBuilder builder = rootBeanDefinition(Jackson2ModuleRegisteringBeanPostProcessor.class); + registerSourcedBeanDefinition(builder, metadata, registry); + } + } + @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = (ListableBeanFactory) beanFactory; } - + /** * Registers bean definitions for a {@link PluginRegistry} to capture {@link RelProvider} instances. Wraps the * registry into a {@link DelegatingRelProvider} bean definition backed by the registry. @@ -202,6 +211,9 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe case HAL: definition = new RootBeanDefinition(HalLinkDiscoverer.class); break; + case HAL_FORMS: + definition = new RootBeanDefinition(HalFormsLinkDiscoverer.class); + break; default: throw new IllegalStateException(String.format("Unsupported hypermedia type %s!", type)); } @@ -297,22 +309,59 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe CurieProvider curieProvider = getCurieProvider(beanFactory); RelProvider relProvider = beanFactory.getBean(DELEGATING_REL_PROVIDER_BEAN_NAME, RelProvider.class); - ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class); - MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, - MessageSourceAccessor.class); - - halObjectMapper.registerModule(new Jackson2HalModule()); - halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, - linkRelationMessageSource, beanFactory)); - - MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( - ResourceSupport.class); - halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON, HAL_JSON_UTF8)); - halConverter.setObjectMapper(halObjectMapper); List> result = new ArrayList>(converters.size()); - result.add(halConverter); + + if (beanFactory.containsBean(HAL_OBJECT_MAPPER_BEAN_NAME)) { + + ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class); + MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, + MessageSourceAccessor.class); + + halObjectMapper.registerModule(new Jackson2HalModule()); + + try { + HalConfiguration halConfiguration = beanFactory.getBean(HalConfiguration.class); + halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, halConfiguration)); + } catch (BeansException e) { + halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, new HalConfiguration())); + } + + MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class); + halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON, HAL_JSON_UTF8)); + halConverter.setObjectMapper(halObjectMapper); + result.add(halConverter); + } + + if (beanFactory.containsBean(HAL_FORMS_OBJECT_MAPPER_BEAN_NAME)) { + + ObjectMapper halFormsObjectMapper = beanFactory.getBean(HAL_FORMS_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class); + MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, + MessageSourceAccessor.class); + + halFormsObjectMapper.registerModule(new Jackson2HalFormsModule()); + + try { + HalFormsConfiguration halFormsConfiguration = beanFactory.getBean(HalFormsConfiguration.class); + halFormsObjectMapper.setHandlerInstantiator(new Jackson2HalFormsModule.HalFormsHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, true, halFormsConfiguration)); + } catch (BeansException e) { + halFormsObjectMapper.setHandlerInstantiator(new Jackson2HalFormsModule.HalFormsHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, true, new HalFormsConfiguration())); + } + + MappingJackson2HttpMessageConverter halFormsConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class); + halFormsConverter.setSupportedMediaTypes(Arrays.asList(HAL_FORMS_JSON)); + halFormsConverter.setObjectMapper(halFormsObjectMapper); + result.add(halFormsConverter); + } + result.addAll(converters); + return result; } @@ -341,14 +390,13 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (!HAL_OBJECT_MAPPER_BEAN_NAME.equals(beanName)) { - return bean; + if (HAL_OBJECT_MAPPER_BEAN_NAME.equals(beanName) || HAL_FORMS_OBJECT_MAPPER_BEAN_NAME.equals(beanName)) { + ObjectMapper mapper = (ObjectMapper) bean; + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + return mapper; } - ObjectMapper mapper = (ObjectMapper) bean; - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - - return mapper; + return bean; } /* diff --git a/src/main/java/org/springframework/hateoas/core/AnnotationMappingDiscoverer.java b/src/main/java/org/springframework/hateoas/core/AnnotationMappingDiscoverer.java index 2b6f1555..3d56667f 100644 --- a/src/main/java/org/springframework/hateoas/core/AnnotationMappingDiscoverer.java +++ b/src/main/java/org/springframework/hateoas/core/AnnotationMappingDiscoverer.java @@ -20,15 +20,19 @@ import static org.springframework.core.annotation.AnnotationUtils.*; import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Pattern; import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMethod; /** * {@link MappingDiscoverer} implementation that inspects mappings from a particular annotation. * * @author Oliver Gierke * @author Mark Paluch + * @author Greg Turnquist */ public class AnnotationMappingDiscoverer implements MappingDiscoverer { @@ -106,6 +110,34 @@ public class AnnotationMappingDiscoverer implements MappingDiscoverer { return typeMapping == null || "/".equals(typeMapping) ? mapping[0] : join(typeMapping, mapping[0]); } + /** + * Extract {@link org.springframework.web.bind.annotation.RequestMapping}'s list of {@link RequestMethod}s + * into an array of {@link String}s. + * + * @param type + * @param method + * @return + */ + @Override + public String[] getRequestType(Class type, Method method) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(method, "Method must not be null!"); + + Annotation mergedAnnotation = findMergedAnnotation(method, annotationType); + Object value = getValue(mergedAnnotation, "method"); + + RequestMethod[] requestMethods = (RequestMethod[]) value; + + List requestMethodNames = new ArrayList(); + + for (RequestMethod requestMethod : requestMethods) { + requestMethodNames.add(requestMethod.toString()); + } + + return requestMethodNames.toArray(new String[]{}); + } + private String[] getMappingFrom(Annotation annotation) { if (annotation == null) { diff --git a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java index 1d8444fd..47a44482 100644 --- a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java +++ b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java @@ -18,9 +18,14 @@ package org.springframework.hateoas.core; import static org.springframework.hateoas.core.EncodingUtils.*; import static org.springframework.web.util.UriComponentsBuilder.*; +import lombok.Getter; + import java.net.URI; import java.util.Optional; +import java.util.ArrayList; +import java.util.List; +import org.springframework.hateoas.Affordance; import org.springframework.hateoas.Identifiable; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkBuilder; @@ -36,11 +41,14 @@ import org.springframework.web.util.UriComponentsBuilder; * @author Oliver Gierke * @author Kamill Sokol * @author Kevin Conaway + * @author Greg Turnquist */ public abstract class LinkBuilderSupport implements LinkBuilder { private final UriComponents uriComponents; + private @Getter final List affordances; + /** * Creates a new {@link LinkBuilderSupport} using the given {@link UriComponentsBuilder}. * @@ -50,17 +58,19 @@ public abstract class LinkBuilderSupport implements LinkB Assert.notNull(builder, "UriComponentsBuilder must not be null!"); this.uriComponents = builder.build(); + this.affordances = new ArrayList(); } /** * Creates a new {@link LinkBuilderSupport} using the given {@link UriComponents}. - * + * * @param uriComponents must not be {@literal null}. */ public LinkBuilderSupport(UriComponents uriComponents) { Assert.notNull(uriComponents, "UriComponents must not be null!"); this.uriComponents = uriComponents; + this.affordances = new ArrayList(); } /* @@ -133,12 +143,25 @@ public abstract class LinkBuilderSupport implements LinkB return uriComponents.encode().toUri().normalize(); } + public LinkBuilderSupport addAffordances(List affordances) { + + this.affordances.addAll(affordances); + return this; + } + /* * (non-Javadoc) * @see org.springframework.hateoas.LinkBuilder#withRel(java.lang.String) */ public Link withRel(String rel) { - return new Link(toString(), rel); + + Link link = new Link(toString(), rel); + + for (Affordance affordance : this.affordances) { + link = link.withAffordance(affordance); + } + + return link; } /* diff --git a/src/main/java/org/springframework/hateoas/core/MappingDiscoverer.java b/src/main/java/org/springframework/hateoas/core/MappingDiscoverer.java index 4b04464d..2b6971bd 100644 --- a/src/main/java/org/springframework/hateoas/core/MappingDiscoverer.java +++ b/src/main/java/org/springframework/hateoas/core/MappingDiscoverer.java @@ -18,9 +18,11 @@ package org.springframework.hateoas.core; import java.lang.reflect.Method; /** - * Strategy interface to discover a URI mapping for either a given type or method. + * Strategy interface to discover a URI mapping and related {@link org.springframework.hateoas.Affordance}s + * for either a given type or method. * * @author Oliver Gierke + * @author Greg Turnquist */ public interface MappingDiscoverer { @@ -49,4 +51,14 @@ public interface MappingDiscoverer { * @return the method mapping including the type-level one or {@literal null} if neither of them present. */ String getMapping(Class type, Method method); + + /** + * Returns the HTTP verbs for the given {@link Method} invoked on the given type. This can be used to build + * hypermedia templates. + * + * @param type + * @param method + * @return + */ + String[] getRequestType(Class type, Method method); } diff --git a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java index 91984290..bf17563a 100644 --- a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java +++ b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java @@ -144,6 +144,13 @@ public class Jackson2HalModule extends SimpleModule { this.halConfiguration = halConfiguration; } + /** + * Needed to support Jackson + */ + HalLinkListSerializer() { + this(null, null, null, null, new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_SINGLE)); + } + /* * (non-Javadoc) * @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider) @@ -679,27 +686,6 @@ public class Jackson2HalModule extends SimpleModule { private final Map, Object> serializers = new HashMap<>(); private final AutowireCapableBeanFactory delegate; - /** - * Creates a new {@link HalHandlerInstantiator} using the given {@link RelProvider}, {@link CurieProvider} and - * {@link MessageSourceAccessor} and {@link AutowireCapableBeanFactory}. Registers a prepared - * {@link HalResourcesSerializer} and {@link HalLinkListSerializer} falling back to instantiation using the given - * {@link AutowireCapableBeanFactory} if provided, or simple default constructor instantiation if not. - * - * @param provider must not be {@literal null}. - * @param curieProvider can be {@literal null}. - * @param accessor can be {@literal null}. - * @param beanFactory can be {@literal null} - */ - public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider, MessageSourceAccessor accessor, - AutowireCapableBeanFactory beanFactory, HalConfiguration halConfiguration) { - this(provider, curieProvider, accessor, true, beanFactory, halConfiguration); - } - - public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider, - MessageSourceAccessor messageSourceAccessor, AutowireCapableBeanFactory beanFactory) { - this(provider, curieProvider, messageSourceAccessor, beanFactory, beanFactory.getBean(HalConfiguration.class)); - } - public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider, MessageSourceAccessor messageSourceAccessor) { this(provider, curieProvider, messageSourceAccessor, new HalConfiguration()); @@ -864,7 +850,7 @@ public class Jackson2HalModule extends SimpleModule { * * @author Oliver Gierke */ - private static class EmbeddedMapper { + public static class EmbeddedMapper { private RelProvider relProvider; private CurieProvider curieProvider; diff --git a/src/main/java/org/springframework/hateoas/hal/LinkMixin.java b/src/main/java/org/springframework/hateoas/hal/LinkMixin.java index b7b9293c..4f009be1 100644 --- a/src/main/java/org/springframework/hateoas/hal/LinkMixin.java +++ b/src/main/java/org/springframework/hateoas/hal/LinkMixin.java @@ -31,7 +31,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; * @author Greg Turnquist */ @JsonIgnoreProperties({"rel", "media"}) -abstract class LinkMixin extends Link { +public abstract class LinkMixin extends Link { private static final long serialVersionUID = 4720588561299667409L; diff --git a/src/main/java/org/springframework/hateoas/hal/ResourceSupportMixin.java b/src/main/java/org/springframework/hateoas/hal/ResourceSupportMixin.java index 70a19114..c521caee 100644 --- a/src/main/java/org/springframework/hateoas/hal/ResourceSupportMixin.java +++ b/src/main/java/org/springframework/hateoas/hal/ResourceSupportMixin.java @@ -28,7 +28,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; -abstract class ResourceSupportMixin extends ResourceSupport { +/** + * Custom mixin to render {@link Link}s in HAL. + * + * @author Alexander Baetz + * @author Oliver Gierke + * @author Greg Turnquist + */ +public abstract class ResourceSupportMixin extends ResourceSupport { @Override @XmlElement(name = "link") diff --git a/src/main/java/org/springframework/hateoas/hal/ResourcesMixin.java b/src/main/java/org/springframework/hateoas/hal/ResourcesMixin.java index 7c4a1fbe..eacd65dc 100644 --- a/src/main/java/org/springframework/hateoas/hal/ResourcesMixin.java +++ b/src/main/java/org/springframework/hateoas/hal/ResourcesMixin.java @@ -28,6 +28,13 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +/** + * Custom mixin to to render collection content as {@literal _embedded}. + * + * @author Alexander Baetz + * @author Oliver Gierke + * @author Greg Turnquist + */ @JsonPropertyOrder({ "content", "links" }) public abstract class ResourcesMixin extends Resources { diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java new file mode 100644 index 00000000..3155de5c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModel.java @@ -0,0 +1,149 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.util.UriComponents; + +/** + * {@link AffordanceModel} for a HAL-FORMS {@link org.springframework.http.MediaType}. + * + * @author Greg Turnquist + */ +public class HalFormsAffordanceModel implements AffordanceModel { + + private static final Logger log = LoggerFactory.getLogger(HalFormsAffordanceModel.class); + + /** + * Details about the affordance's + */ + private final UriComponents components; + + /** + * Is this required/not required? + */ + private final boolean required; + + /** + * {@link Map} of property names and their types associated with the incoming request body. + */ + private final Map> properties; + + public HalFormsAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) { + + this.components = components; + this.required = determineRequired(affordance.getHttpMethod()); + + this.properties = new TreeMap>(); + + if (affordance.getHttpMethod().equalsIgnoreCase("POST") || + affordance.getHttpMethod().equalsIgnoreCase("PUT") || + affordance.getHttpMethod().equalsIgnoreCase("PATCH")) { + + determineAffordanceInputs(invocationValue.getMethod()); + } + } + + /** + * Transform the details of the Spring MVC method's {@link RequestBody} into a collection of {@link HalFormsProperty}s. + * + * @return + */ + public List getProperties() { + + List halFormsProperties = new ArrayList(); + + for (Map.Entry> entry : this.properties.entrySet()) { + halFormsProperties.add(new HalFormsProperty(entry.getKey(), null, null, null, null, false, this.required, false)); + } + + return halFormsProperties; + } + + /** + * Look up the path of the {@link UriComponents}. + * + * @return + */ + public String getPath() { + return this.components.getPath(); + } + + /** + * Based on the Spring MVC controller's HTTP method, decided whether or not input attributes are required or not. + * + * @param httpMethod - string representation of an HTTP method, e.g. GET, POST, etc. + * @return + */ + private boolean determineRequired(String httpMethod) { + + if (httpMethod.equalsIgnoreCase("POST") || httpMethod.equalsIgnoreCase("PUT")) { + return true; + } else { + return false; + } + } + + /** + * Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties. + * + * @param method - {@link Method} of the Spring MVC controller tied to this affordance + */ + private void determineAffordanceInputs(Method method) { + + if (method == null) { + return; + } + + log.debug("Gathering details about " + method.getDeclaringClass().getCanonicalName() + "." + method.getName()); + + for (int i = 0; i < method.getParameterTypes().length; i++) { + + for (Annotation annotation : method.getParameterAnnotations()[i]) { + + if (annotation.annotationType().equals(RequestBody.class)) { + + log.debug("\tRequest body: " + method.getParameterTypes()[i].getCanonicalName() + "("); + + for (PropertyDescriptor descriptor : BeanUtils.getPropertyDescriptors(method.getParameterTypes()[i])) { + + if (!descriptor.getName().equals("class")) { + log.debug("\t\t" + descriptor.getPropertyType().getCanonicalName() + " " + descriptor.getName()); + this.properties.put(descriptor.getName(), descriptor.getPropertyType()); + } + } + log.debug(")"); + } + } + } + + log.debug("Assembled " + this.toString()); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java new file mode 100644 index 00000000..02dc1605 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsAffordanceModelFactory.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import lombok.Getter; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.AffordanceModelFactory; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.MediaType; +import org.springframework.web.util.UriComponents; + +/** + * Factory for creating {@link HalFormsAffordanceModel}s. + * + * @author Greg Turnquist + */ +@Getter +public class HalFormsAffordanceModelFactory extends AffordanceModelFactory { + + private final MediaType mediaType = MediaTypes.HAL_FORMS_JSON; + + @Override + public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) { + return new HalFormsAffordanceModel(affordance, invocationValue, components); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.java new file mode 100644 index 00000000..9604707d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.Wither; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.hal.HalConfiguration; + +/** + * @author Greg Turnquist + */ +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class HalFormsConfiguration { + + private @Wither @Getter RenderSingleLinks renderSingleLinks = RenderSingleLinks.AS_SINGLE; + + public enum RenderSingleLinks { + + /** + * A single {@link Link} is rendered as a JSON object. + */ + AS_SINGLE, + + /** + * A single {@link Link} is rendered as a JSON Array. + */ + AS_ARRAY + } + + /** + * Translate a {@link HalFormsConfiguration} into a {@link HalConfiguration}. + * + * @return + */ + public HalConfiguration toHalConfiguration() { + + if (this.getRenderSingleLinks() == RenderSingleLinks.AS_SINGLE) { + return new HalConfiguration().withRenderSingleLinks(HalConfiguration.RenderSingleLinks.AS_SINGLE); + } + + if (this.getRenderSingleLinks() == RenderSingleLinks.AS_ARRAY) { + return new HalConfiguration().withRenderSingleLinks(HalConfiguration.RenderSingleLinks.AS_ARRAY); + } + + throw new IllegalStateException("Don't know how to translate " + this); + } + +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java new file mode 100644 index 00000000..6bce25f1 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java @@ -0,0 +1,145 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.http.MediaType; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase; +import com.fasterxml.jackson.databind.type.TypeFactory; + +/** + * Collection of components needed to deserialize a HAL-FORMS document. + * + * @author Greg Turnquist + */ +public class HalFormsDeserializers { + + static class HalFormsResourcesDeserializer extends ContainerDeserializerBase> implements ContextualDeserializer { + + private JavaType contentType; + + HalFormsResourcesDeserializer(JavaType contentType) { + + super(contentType); + this.contentType = contentType; + } + + HalFormsResourcesDeserializer() { + this(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, Object.class)); + } + + @Override + public List deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + + List result = new ArrayList(); + JsonDeserializer deser = ctxt.findRootValueDeserializer(contentType); + Object object; + + // links is an object, so we parse till we find its end. + while (!JsonToken.END_OBJECT.equals(jp.nextToken())) { + + if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) { + throw new JsonParseException("Expected relation name", jp.getCurrentLocation()); + } + + if (JsonToken.START_ARRAY.equals(jp.nextToken())) { + while (!JsonToken.END_ARRAY.equals(jp.nextToken())) { + object = deser.deserialize(jp, ctxt); + result.add(object); + } + } else { + object = deser.deserialize(jp, ctxt); + result.add(object); + } + } + + return result; + } + + @Override + public JavaType getContentType() { + return this.contentType; + } + + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + + @Override + public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { + + if (property != null) { + JavaType vc = property.getType().getContentType(); + return new HalFormsResourcesDeserializer(vc); + } else { + return new HalFormsResourcesDeserializer(ctxt.getContextualType()); + } + } + } + + /** + * Deserialize a {@link MediaType} embedded inside a HAL-FORMS document. + */ + static class MediaTypesDeserializer extends ContainerDeserializerBase> { + + private static final long serialVersionUID = -7218376603548438390L; + + public MediaTypesDeserializer() { + super(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, MediaType.class)); + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase#getContentType() + */ + @Override + public JavaType getContentType() { + return null; + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase#getContentDeserializer() + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.JsonDeserializer#deserialize(com.fasterxml.jackson.core.JsonParser, com.fasterxml.jackson.databind.DeserializationContext) + */ + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return MediaType.parseMediaTypes(p.getText()); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDocument.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDocument.java new file mode 100644 index 00000000..f0f42392 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDocument.java @@ -0,0 +1,112 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import static com.fasterxml.jackson.annotation.JsonInclude.*; +import static org.springframework.hateoas.hal.Jackson2HalModule.*; + +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.PagedResources; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * Representation of a HAL-FORMS document. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +@Data +@Builder(builderMethodName = "halFormsDocument") +@JsonPropertyOrder({ "resource", "resources", "embedded", "links", "templates", "metadata" }) +public class HalFormsDocument { + + @JsonUnwrapped + @JsonInclude(Include.NON_NULL) + private T resource; + + @JsonIgnore + @JsonInclude(Include.NON_EMPTY) + private Collection resources; + + @JsonProperty("_embedded") + @JsonInclude(Include.NON_NULL) + private Map embedded; + + @JsonProperty("page") + @JsonInclude(Include.NON_NULL) + private PagedResources.PageMetadata pageMetadata; + + @Singular private List links; + + @Singular private Map templates; + + HalFormsDocument(T resource, Collection resources, Map embedded, + PagedResources.PageMetadata pageMetadata, List links, Map templates) { + + this.resource = resource; + this.resources = resources; + this.embedded = embedded; + this.pageMetadata = pageMetadata; + this.links = links; + this.templates = templates; + } + + HalFormsDocument() { + this(null, null, null, null, new ArrayList(), new HashMap()); + } + + @JsonProperty("_links") + @JsonInclude(Include.NON_EMPTY) + @JsonSerialize(using = HalLinkListSerializer.class) + @JsonDeserialize(using = HalLinkListDeserializer.class) + public List getLinks() { + return this.links; + } + + @JsonProperty("_templates") + @JsonInclude(Include.NON_EMPTY) + public Map getTemplates() { + return this.templates; + } + + @JsonIgnore + public HalFormsTemplate getTemplate() { + return getTemplate(HalFormsTemplate.DEFAULT_KEY); + } + + @JsonIgnore + public HalFormsTemplate getTemplate(String key) { + return this.templates.get(key); + } + +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscoverer.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscoverer.java new file mode 100644 index 00000000..65946037 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscoverer.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.core.JsonPathLinkDiscoverer; + +/** + * HAL-FORMS based {@link JsonPathLinkDiscoverer}. + * + * @author Greg Turnquist + */ +public class HalFormsLinkDiscoverer extends JsonPathLinkDiscoverer { + + public HalFormsLinkDiscoverer() { + super("$._links..['%s']..href", MediaTypes.HAL_FORMS_JSON); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverter.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverter.java new file mode 100644 index 00000000..64358777 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverter.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.hal.forms; + +import java.io.IOException; +import java.util.Arrays; + +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * A message converter that converts any object into a HAL-FORMS document before bundling up + * as an {@link HttpOutputMessage}, or that converts any incoming {@link HttpInputMessage} into + * an object. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class HalFormsMessageConverter extends AbstractHttpMessageConverter { + + private final ObjectMapper objectMapper; + + public HalFormsMessageConverter(ObjectMapper objectMapper) { + + this.objectMapper = objectMapper; + this.objectMapper.registerModule(new Jackson2HalFormsModule()); + setSupportedMediaTypes(Arrays.asList(MediaTypes.HAL_FORMS_JSON)); + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.AbstractHttpMessageConverter#supports(java.lang.Class) + */ + @Override + protected boolean supports(final Class clazz) { + return true; + } + + /* + * (non-Javadoc) + * @see org.springframework.http.converter.AbstractHttpMessageConverter#readInternal(java.lang.Class, org.springframework.http.HttpInputMessage) + */ + @Override + protected Object readInternal(final Class clazz, final HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + return this.objectMapper.readValue(inputMessage.getBody(), clazz); + } + + @Override + protected void writeInternal(final Object t, final HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + + JsonGenerator jsonGenerator = objectMapper.getFactory().createGenerator(outputMessage.getBody(), JsonEncoding.UTF8); + + // A workaround for JsonGenerators not applying serialization features + // https://github.com/FasterXML/jackson-databind/issues/12 + if (objectMapper.isEnabled(SerializationFeature.INDENT_OUTPUT)) { + jsonGenerator.useDefaultPrettyPrinter(); + } + + try { + objectMapper.writeValue(jsonGenerator, t); + } catch (JsonProcessingException ex) { + throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); + } + } + +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java new file mode 100644 index 00000000..50cda7c5 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsProperty.java @@ -0,0 +1,57 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.hal.forms; + +import lombok.AllArgsConstructor; +import lombok.Value; + +import org.springframework.hateoas.AffordanceModel; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * Describe a parameter for the associated state transition in a HAL-FORMS document. + * A {@link HalFormsTemplate} may contain a list of {@link HalFormsProperty}s + * + * @see http://mamund.site44.com/misc/hal-forms/ + */ +@JsonInclude(Include.NON_DEFAULT) +@Value +@AllArgsConstructor +public class HalFormsProperty { + + private String name; + + /** + * readOnly uses {@link Boolean} not {@literal boolean}, because if {@literal null}, the element won't be rendered + */ + private Boolean readOnly; + + private String value; + private String prompt; + private String regex; + private boolean templated; + private @JsonInclude(Include.ALWAYS) boolean required; + private boolean multi; + + /** + * Default constructor to support Jackson. + */ + HalFormsProperty() { + this(null, null, null, null, null, false, false, false); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java new file mode 100644 index 00000000..e9d79111 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsSerializers.java @@ -0,0 +1,245 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.hal.Jackson2HalModule; +import org.springframework.http.HttpMethod; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.ser.ContainerSerializer; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; + +/** + * Collection of components needed to serialize a HAL-FORMS document. + * + * @author Greg Turnquist + */ +public class HalFormsSerializers { + + /** + * Serializer for {@link Resources}. + */ + static class HalFormsResourceSerializer extends ContainerSerializer> implements ContextualSerializer { + + private final BeanProperty property; + + HalFormsResourceSerializer(BeanProperty property) { + + super(Resource.class, false); + this.property = property; + } + + HalFormsResourceSerializer() { + this(null); + } + + @Override + public void serialize(Resource value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + HalFormsDocument doc = HalFormsDocument. halFormsDocument() + .resource(value.getContent()) + .links(value.getLinks()) + .templates(findTemplates(value)) + .build(); + + provider + .findValueSerializer(HalFormsDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(Resource resource) { + return false; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer typeSerializer) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new HalFormsResourceSerializer(property); + } + } + + /** + * Serializer for {@link Resources} + */ + static class HalFormsResourcesSerializer extends ContainerSerializer> implements ContextualSerializer { + + private final BeanProperty property; + private final Jackson2HalModule.EmbeddedMapper embeddedMapper; + + HalFormsResourcesSerializer(BeanProperty property, Jackson2HalModule.EmbeddedMapper embeddedMapper) { + + super(Resources.class, false); + this.property = property; + this.embeddedMapper = embeddedMapper; + } + + HalFormsResourcesSerializer(Jackson2HalModule.EmbeddedMapper embeddedMapper) { + this(null, embeddedMapper); + } + + @Override + public void serialize(Resources value, JsonGenerator gen, SerializerProvider provider) throws IOException { + + Map embeddeds = embeddedMapper.map(value); + + HalFormsDocument doc; + + if (value instanceof PagedResources) { + + doc = HalFormsDocument. halFormsDocument() + .embedded(embeddeds) + .pageMetadata(((PagedResources) value).getMetadata()) + .links(value.getLinks()) + .templates(findTemplates(value)) + .build(); + } else { + + doc = HalFormsDocument. halFormsDocument() + .embedded(embeddeds) + .pageMetadata(null) + .links(value.getLinks()) + .templates(findTemplates(value)) + .build(); + } + + provider + .findValueSerializer(HalFormsDocument.class, property) + .serialize(doc, gen, provider); + } + + @Override + public JavaType getContentType() { + return null; + } + + @Override + public JsonSerializer getContentSerializer() { + return null; + } + + @Override + public boolean hasSingleElement(Resources resources) { + return resources.getContent().size() == 1; + } + + @Override + protected ContainerSerializer _withValueTypeSerializer(TypeSerializer typeSerializer) { + return null; + } + + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + return new HalFormsResourcesSerializer(property, embeddedMapper); + } + } + + /** + * Extract template details from a {@link ResourceSupport}'s {@link Affordance}s. + * + * @param resource + * @return + */ + private static Map findTemplates(ResourceSupport resource) { + + Map templates = new HashMap(); + + if (resource.hasLink(Link.REL_SELF)) { + for (Affordance affordance : resource.getLink(Link.REL_SELF).map(Link::getAffordances).orElse(Collections.emptyList())) { + + HalFormsAffordanceModel model = + (HalFormsAffordanceModel) affordance.getAffordanceModel(MediaTypes.HAL_FORMS_JSON); + + if (!affordance.getHttpMethod().equals(HttpMethod.GET.toString())) { + + validate(resource, affordance, model); + + HalFormsTemplate template = new HalFormsTemplate(); + template.setHttpMethod(HttpMethod.valueOf(affordance.getHttpMethod())); + template.setProperties(model.getProperties()); + + /** + * First template in HAL-FORMS is "default". + */ + if (templates.isEmpty()) { + templates.put("default", template); + } else { + templates.put(affordance.getName(), template); + } + } + } + } + + return templates; + } + + /** + * Verify that the resource's self link and the affordance's URI have the same relative path. + * @param resource + * @param affordance + * @param model + */ + private static void validate(ResourceSupport resource, Affordance affordance, HalFormsAffordanceModel model) { + + try { + Optional selfLink = resource.getLink(Link.REL_SELF); + + URI selfLinkUri = new URI(selfLink.map(link -> link.expand().getHref()).orElse("")); + + if (!selfLinkUri.getPath().equals(model.getPath())) { + throw new IllegalStateException("Affordance's URI " + model.getPath() + " doesn't match self link " + selfLinkUri.getPath() + " as expected in HAL-FORMS"); + } + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsTemplate.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsTemplate.java new file mode 100644 index 00000000..3f3c4b1a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsTemplate.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.hal.forms; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.hateoas.hal.forms.HalFormsDeserializers.MediaTypesDeserializer; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * Value object for a HAL-FORMS template. Describes the available state transition details. + * + * @author Dietrich Schulten + * @author Greg Turnquist + * @see https://rwcbook.github.io/hal-forms/#_the_code__templates_code_element + */ +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@JsonInclude(JsonInclude.Include.NON_DEFAULT) +@JsonPropertyOrder({ "title", "method", "contentType", "properties" }) +@JsonIgnoreProperties({ "key" }) +public class HalFormsTemplate { + + public static final String DEFAULT_KEY = "default"; + + private @JsonIgnore String key; + private List properties = new ArrayList(); + + private String title; + private @JsonIgnore HttpMethod httpMethod; + private List contentType; + + /** + * Configure a HAL-FORMS template with a key value. + * @param key + */ + public HalFormsTemplate(String key) { + this.key = key; + } + + /** + * A HAL-FORMS template with no name is dubbed the "default" template. + */ + public HalFormsTemplate() { + this(HalFormsTemplate.DEFAULT_KEY); + } + + public String getContentType() { + return StringUtils.collectionToCommaDelimitedString(contentType); + } + + @JsonDeserialize(using = MediaTypesDeserializer.class) + public void setContentType(List contentType) { + this.contentType = contentType; + } + + public String getMethod() { + return this.httpMethod == null ? null : this.httpMethod.toString().toLowerCase(); + } + + public void setMethod(String method) { + this.httpMethod = HttpMethod.valueOf(method.toUpperCase()); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcConfigurer.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcConfigurer.java new file mode 100644 index 00000000..7959e88d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcConfigurer.java @@ -0,0 +1,43 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.hal.forms; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Configure a HAL-FORMS {@link HttpMessageConverter}. + * + * @author Oliver Gierke + * @author Greg Turnquist + */ +@Configuration +public class HalFormsWebMvcConfigurer extends WebMvcConfigurerAdapter { + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#configureMessageConverters(java.util.List) + */ + @Override + public void configureMessageConverters(List> converters) { + converters.add(new HalFormsMessageConverter(new ObjectMapper())); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java b/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java new file mode 100644 index 00000000..8cedb8b1 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsModule.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016-2017 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 + * + * http://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.hal.forms; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.RelProvider; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.Jackson2HalModule.EmbeddedMapper; +import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator; +import org.springframework.hateoas.hal.Jackson2HalModule.HalLinkListSerializer; +import org.springframework.hateoas.hal.LinkMixin; +import org.springframework.hateoas.hal.ResourceSupportMixin; +import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourcesSerializer; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.KeyDeserializer; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.cfg.MapperConfig; +import com.fasterxml.jackson.databind.introspect.Annotated; +import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; +import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder; +import com.fasterxml.jackson.databind.module.SimpleModule; + +/** + * Serialize/Deserialize all the parts of HAL-FORMS documents using Jackson. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class Jackson2HalFormsModule extends SimpleModule { + + private static final long serialVersionUID = -4496351128468451196L; + + public Jackson2HalFormsModule() { + + super("hal-forms-module", new Version(1, 0, 0, null, "org.springframework.hateoas", "spring-hateoas")); + + setMixInAnnotation(Link.class, LinkMixin.class); + setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class); + setMixInAnnotation(Resource.class, ResourceMixin.class); + setMixInAnnotation(Resources.class, ResourcesMixin.class); + setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class); + } + + /** + * Create new HAL-FORMS serializers based on the context. + */ + public static class HalFormsHandlerInstantiator extends HalHandlerInstantiator { + + private final Map, Object> serializers = new HashMap, Object>(); + + public HalFormsHandlerInstantiator(RelProvider resolver, CurieProvider curieProvider, + MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections, + HalFormsConfiguration halFormsConfiguration) { + + super(resolver, curieProvider, messageSource, enforceEmbeddedCollections, halFormsConfiguration.toHalConfiguration()); + + EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections); + + this.serializers.put(HalFormsResourcesSerializer.class, new HalFormsResourcesSerializer(mapper)); + this.serializers.put(HalLinkListSerializer.class, + new HalLinkListSerializer(curieProvider, mapper, messageSource, halFormsConfiguration.toHalConfiguration())); + } + + public HalFormsHandlerInstantiator(RelProvider relProvider, CurieProvider curieProvider, + MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections, + AutowireCapableBeanFactory beanFactory) { + this(relProvider, curieProvider, messageSource, enforceEmbeddedCollections, beanFactory.getBean(HalFormsConfiguration.class)); + } + + private Object findInstance(Class type) { + return this.serializers.get(type); + } + + /* + * (non-Javadoc) + * + * @see + * com.fasterxml.jackson.databind.cfg.HandlerInstantiator#deserializerInstance(com.fasterxml.jackson.databind. + * DeserializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class) + */ + @Override + public JsonDeserializer deserializerInstance(DeserializationConfig config, Annotated annotated, + Class deserClass) { + + Object jsonDeser = findInstance(deserClass); + return jsonDeser != null ? (JsonDeserializer) jsonDeser + : super.deserializerInstance(config, annotated, deserClass); + } + + /* + * (non-Javadoc) + * + * @see com.fasterxml.jackson.databind.cfg.HandlerInstantiator#keyDeserializerInstance(com.fasterxml.jackson. + * databind. DeserializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class) + */ + @Override + public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, + Class keyDeserClass) { + + Object keyDeser = findInstance(keyDeserClass); + return keyDeser != null ? (KeyDeserializer) keyDeser + : super.keyDeserializerInstance(config, annotated, keyDeserClass); + } + + /* + * (non-Javadoc) + * + * @see + * com.fasterxml.jackson.databind.cfg.HandlerInstantiator#serializerInstance(com.fasterxml.jackson.databind. + * SerializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class) + */ + @Override + public JsonSerializer serializerInstance(SerializationConfig config, Annotated annotated, Class serClass) { + + Object jsonSer = findInstance(serClass); + return jsonSer != null ? (JsonSerializer) jsonSer : super.serializerInstance(config, annotated, serClass); + } + + /* + * (non-Javadoc) + * + * @see + * com.fasterxml.jackson.databind.cfg.HandlerInstantiator#typeResolverBuilderInstance(com.fasterxml.jackson. + * databind .cfg.MapperConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class) + */ + @Override + public TypeResolverBuilder typeResolverBuilderInstance(MapperConfig config, Annotated annotated, + Class builderClass) { + + Object builder = findInstance(builderClass); + return builder != null ? (TypeResolverBuilder) builder + : super.typeResolverBuilderInstance(config, annotated, builderClass); + } + + /* + * (non-Javadoc) + * + * @see + * com.fasterxml.jackson.databind.cfg.HandlerInstantiator#typeIdResolverInstance(com.fasterxml.jackson.databind. + * cfg. MapperConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class) + */ + @Override + public TypeIdResolver typeIdResolverInstance(MapperConfig config, Annotated annotated, Class resolverClass) { + + Object resolver = findInstance(resolverClass); + return resolver != null ? (TypeIdResolver) resolver + : super.typeIdResolverInstance(config, annotated, resolverClass); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/PagedResourcesMixin.java b/src/main/java/org/springframework/hateoas/hal/forms/PagedResourcesMixin.java new file mode 100644 index 00000000..f3942b32 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/PagedResourcesMixin.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import org.springframework.hateoas.PagedResources; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Custom mixin to render {@link org.springframework.hateoas.PagedResources.PageMetadata} in HAL. + * + * @author Greg Turnquist + */ +abstract class PagedResourcesMixin extends PagedResources { + + @Override + @JsonProperty("page") + @JsonInclude(Include.NON_EMPTY) + public PageMetadata getMetadata() { + return super.getMetadata(); + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/ResourceMixin.java b/src/main/java/org/springframework/hateoas/hal/forms/ResourceMixin.java new file mode 100644 index 00000000..5b27bd3b --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/ResourceMixin.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourceSerializer; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * @author Greg Turnquist + */ +@JsonSerialize(using = HalFormsResourceSerializer.class) +abstract class ResourceMixin { + +} diff --git a/src/main/java/org/springframework/hateoas/hal/forms/ResourcesMixin.java b/src/main/java/org/springframework/hateoas/hal/forms/ResourcesMixin.java new file mode 100644 index 00000000..540d4017 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/ResourcesMixin.java @@ -0,0 +1,45 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import java.util.Collection; + +import javax.xml.bind.annotation.XmlElement; + +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.hal.forms.HalFormsDeserializers.HalFormsResourcesDeserializer; +import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourcesSerializer; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +/** + * @author Greg Turnquist + */ +@JsonSerialize(using = HalFormsResourcesSerializer.class) +abstract class ResourcesMixin extends Resources { + + @Override + @XmlElement(name = "embedded") + @JsonProperty("_embedded") + @JsonInclude(Include.NON_EMPTY) + @JsonDeserialize(using = HalFormsResourcesDeserializer.class) + public abstract Collection getContent(); + +} diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java index 81123bef..f9c003e9 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java @@ -22,16 +22,24 @@ import lombok.experimental.Delegate; import java.lang.reflect.Method; import java.net.URI; +import java.util.Arrays; +import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModelFactory; import org.springframework.hateoas.Link; import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.core.AnnotationMappingDiscoverer; import org.springframework.hateoas.core.DummyInvocationUtils; +import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.hal.forms.HalFormsAffordanceModelFactory; +import org.springframework.http.MediaType; +import org.springframework.plugin.core.OrderAwarePluginRegistry; import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.web.bind.annotation.RequestMapping; @@ -83,14 +91,33 @@ public class ControllerLinkBuilder extends LinkBuilderSupport findAffordances(MethodInvocation invocation, UriComponents components) { + + OrderAwarePluginRegistry modelFactories = + OrderAwarePluginRegistry.create(Arrays.asList(new HalFormsAffordanceModelFactory())); + + SpringMvcAffordanceBuilder springMvcAffordanceBuilder = new SpringMvcAffordanceBuilder(modelFactories); + + return springMvcAffordanceBuilder.create(invocation, DISCOVERER, components); } /** @@ -194,6 +221,29 @@ public class ControllerLinkBuilder extends LinkBuilderSupport + * Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel(); + * findOneLink.withAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) + * + * + * This takes a link and adds an {@link Affordance} based on another Spring MVC handler method. + * + * @param invocationValue + * @return + */ + public static Affordance afford(Object invocationValue) { + + ControllerLinkBuilder linkBuilder = linkTo(invocationValue); + + Assert.isTrue(linkBuilder.getAffordances().size() == 1, "A base can only have one affordance, itself"); + + return linkBuilder.getAffordances().get(0); + } + /** * Wrapper for {@link DummyInvocationUtils#methodOn(Class, Object...)} to be available in case you work with static * imports of {@link ControllerLinkBuilder}. diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java index 9afe3f04..e29f686f 100644 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java @@ -65,6 +65,7 @@ import org.springframework.web.util.UriTemplate; * @author Oemer Yildiz * @author Kevin Conaway * @author Andrew Naydyonock + * @author Greg Turnquist */ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory { @@ -137,6 +138,7 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory affordanceModels; + + /** + * Request method verb associated with the Spring MVC controller method. + */ + private final RequestMethod requestMethod; + + /** + * Handle on the Spring MVC controller {@link Method}. + */ + private final Method method; + + /** + * Construct a Spring MVC-based {@link Affordance} based on Spring MVC controller method and {@link RequestMethod}. + */ + public SpringMvcAffordance(RequestMethod requestMethod, Method method) { + + this.requestMethod = requestMethod; + this.method = method; + this.affordanceModels = new HashMap(); + } + + @Override + public String getHttpMethod() { + return this.requestMethod.toString(); + } + + @Override + public String getName() { + return this.method.getName(); + } + + @Override + public AffordanceModel getAffordanceModel(MediaType mediaType) { + return this.affordanceModels.get(mediaType); + } + + @Override + public void addAffordanceModel(MediaType mediaType, AffordanceModel affordanceModel) { + this.affordanceModels.put(mediaType, affordanceModel); + } +} diff --git a/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java new file mode 100644 index 00000000..6d1b3506 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilder.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017 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 + * + * http://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.mvc; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModelFactory; +import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.http.MediaType; +import org.springframework.plugin.core.PluginRegistry; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.util.UriComponents; + +/** + * Construct {@link SpringMvcAffordance}s using a collection of {@link AffordanceModelFactory}s. + * + * @author Greg Turnquist + */ +public class SpringMvcAffordanceBuilder { + + private final PluginRegistry factories; + + public SpringMvcAffordanceBuilder(PluginRegistry factories) { + + Assert.notNull(factories, "Registry of LinkDiscoverer must not be null!"); + this.factories = factories; + } + + /** + * Use the attributes of the current method call along with a collection of {@link AffordanceModelFactory}'s to + * create a set of {@link Affordance}s. + * + * @param invocation + * @param discoverer + * @param components + * @return + */ + public List create(MethodInvocation invocation, MappingDiscoverer discoverer, UriComponents components) { + + Method method = invocation.getMethod(); + String[] httpMethods = discoverer.getRequestType(invocation.getTargetType(), method); + + List affordances = new ArrayList(); + + for (String requestMethod : httpMethods) { + + SpringMvcAffordance springMvcAffordance = new SpringMvcAffordance(RequestMethod.valueOf(requestMethod), invocation.getMethod()); + + for (AffordanceModelFactory factory : factories) { + springMvcAffordance.addAffordanceModel(factory.getMediaType(), factory.getAffordanceModel(springMvcAffordance, invocation, components)); + } + + affordances.add(springMvcAffordance); + } + + return affordances; + } +} diff --git a/src/test/java/org/springframework/hateoas/LinkIntegrationTest.java b/src/test/java/org/springframework/hateoas/LinkIntegrationTest.java index 5cbaa838..1d28bf29 100755 --- a/src/test/java/org/springframework/hateoas/LinkIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/LinkIntegrationTest.java @@ -45,5 +45,6 @@ public class LinkIntegrationTest extends AbstractJackson2MarshallingIntegrationT Link result = read(REFERENCE, Link.class); assertThat(result.getHref()).isEqualTo("location"); assertThat(result.getRel()).isEqualTo("something"); + assertThat(result.getAffordances()).hasSize(0); } } diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java index 41df6b07..6a04eb0d 100755 --- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java +++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java @@ -23,6 +23,9 @@ import java.io.ObjectOutputStream; import org.apache.commons.io.output.ByteArrayOutputStream; import org.junit.Test; +import org.springframework.hateoas.mvc.SpringMvcAffordance; +import org.springframework.web.bind.annotation.RequestMethod; + /** * Unit tests for {@link Link}. * @@ -224,6 +227,27 @@ public class LinkUnitTest { .isEqualTo("http://acme.com/rels/foo-bar"); } + /** + * @see #340 + */ + @Test + public void linkWithAffordancesShouldWorkProperly() { + + Link originalLink = new Link("/foo"); + Link linkWithAffordance = originalLink.withAffordance(new TestSpringMvcAffordance()); + Link linkWithTwoAffordances = linkWithAffordance.withAffordance(new TestSpringMvcAffordance()); + + assertThat(originalLink.getAffordances()).hasSize(0); + assertThat(linkWithAffordance.getAffordances()).hasSize(1); + assertThat(linkWithTwoAffordances.getAffordances()).hasSize(2); + + assertThat(originalLink.hashCode()).isNotEqualTo(linkWithAffordance.hashCode()); + assertThat(originalLink).isNotEqualTo(linkWithAffordance); + + assertThat(linkWithAffordance.hashCode()).isNotEqualTo(linkWithTwoAffordances.hashCode()); + assertThat(linkWithAffordance).isNotEqualTo(linkWithTwoAffordances); + } + /** * @see #671 */ @@ -247,4 +271,11 @@ public class LinkUnitTest { assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> link.hasRel(null)); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> link.hasRel("")); } + + static class TestSpringMvcAffordance extends SpringMvcAffordance { + + TestSpringMvcAffordance() { + super(RequestMethod.PATCH, null); + } + } } diff --git a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java index 11f1f5e3..dca47c50 100755 --- a/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/config/EnableHypermediaSupportIntegrationTest.java @@ -42,6 +42,8 @@ import org.springframework.hateoas.core.DelegatingEntityLinks; import org.springframework.hateoas.core.DelegatingRelProvider; import org.springframework.hateoas.hal.HalConfiguration; import org.springframework.hateoas.hal.HalLinkDiscoverer; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; +import org.springframework.hateoas.hal.forms.HalFormsLinkDiscoverer; import org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @@ -70,6 +72,11 @@ public class EnableHypermediaSupportIntegrationTest { assertHalSetupForConfigClass(HalConfig.class); } + @Test + public void bootstrapHalFormsConfiguration() { + assertHalFormsSetupForConfigClass(HalFormsConfig.class); + } + @Test public void registersLinkDiscoverers() { @@ -84,11 +91,29 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + public void registersHalFormsLinkDiscoverers() { + + withContext(HalFormsConfig.class, context -> { + + LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class); + + assertThat(discoverers).isNotNull(); + assertThat(discoverers.getLinkDiscovererFor(MediaTypes.HAL_FORMS_JSON)).isInstanceOf(HalFormsLinkDiscoverer.class); + assertRelProvidersSetUp(context); + }); + } + @Test public void bootstrapsHalConfigurationForSubclass() { assertHalSetupForConfigClass(ExtendedHalConfig.class); } + @Test + public void bootstrapsHalFormsConfigurationForSubclass() { + assertHalFormsSetupForConfigClass(ExtendedHalFormsConfig.class); + } + /** * @see #134, #219 */ @@ -129,6 +154,44 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + @SuppressWarnings("unchecked") + public void halFormsSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() { + + withContext(HalFormsConfig.class, context -> { + + Jackson2ModuleRegisteringBeanPostProcessor postProcessor = new HypermediaSupportBeanDefinitionRegistrar.Jackson2ModuleRegisteringBeanPostProcessor(); + postProcessor.setBeanFactory(context.getAutowireCapableBeanFactory()); + + RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); + + assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.HAL_FORMS_JSON); + + boolean found = false; + + for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) { + + if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) { + + found = true; + + AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver; + List> converters = (List>) ReflectionTestUtils + .getField(processor, "messageConverters"); + + assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class); + assertThat(converters.get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.HAL_FORMS_JSON); + } + } + + assertThat(found).isTrue(); + }); + } + /** * @see #293 */ @@ -144,6 +207,18 @@ public class EnableHypermediaSupportIntegrationTest { }); } + @Test + public void registersHalFormsHttpMessageConvertersForRestTemplate() { + + withContext(HalFormsConfig.class, context -> { + RestTemplate template = context.getBean(RestTemplate.class); + + assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes()) + .hasSize(1) + .contains(MediaTypes.HAL_FORMS_JSON); + }); + } + /** * @see #341 */ @@ -193,10 +268,23 @@ public class EnableHypermediaSupportIntegrationTest { ConsumerWithException consumer) throws E { try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) { + if (context.containsBean("_halFormsObjectMapper")) { + ObjectMapper mapper = context.getBean("_halFormsObjectMapper", ObjectMapper.class); + assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + } consumer.accept(context); } } + public void configuresDefaultObjectMapperForHalFormsToIgnoreUnknownProperties() { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(HalFormsConfig.class); + ObjectMapper mapper = context.getBean("_halFormsObjectMapper", ObjectMapper.class); + + assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + context.close(); + } + private static void assertEntityLinksSetUp(ApplicationContext context) { assertThat(context.getBeansOfType(EntityLinks.class).values()) // @@ -223,6 +311,20 @@ public class EnableHypermediaSupportIntegrationTest { }); } + private static void assertHalFormsSetupForConfigClass(Class configClass) { + + withContext(configClass, context -> { + + assertEntityLinksSetUp(context); + assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(HalFormsLinkDiscoverer.class); + assertThat(context.getBean(ObjectMapper.class)).isNotNull(); + + RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class); + assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class); + + }); + } + /** * Method to mitigate API changes between Spring 3.2 and 4.0. * @@ -264,6 +366,11 @@ public class EnableHypermediaSupportIntegrationTest { public RestTemplate restTemplate() { return new RestTemplate(); } + + @Bean + public HalConfiguration halConfiguration() { + return new HalConfiguration(); + } } @Configuration @@ -292,7 +399,44 @@ public class EnableHypermediaSupportIntegrationTest { } } + @Import(DelegateHalFormsHypermediaConfig.class) + static class HalFormsConfig { + + static int numberOfMessageConverters = 0; + + @Bean + public RequestMappingHandlerAdapter rmh() { + RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter(); + numberOfMessageConverters = adapter.getMessageConverters().size(); + return adapter; + } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public HalFormsConfiguration halFormsConfiguration() { + return new HalFormsConfiguration(); + } + + } + + @Configuration + static class ExtendedHalFormsConfig extends HalFormsConfig { + + } + + @Configuration + @EnableHypermediaSupport(type = HypermediaType.HAL_FORMS) + static class DelegateHalFormsHypermediaConfig { + + } + interface ConsumerWithException { + void accept(T element) throws E; } + } diff --git a/src/test/java/org/springframework/hateoas/hal/forms/Employee.java b/src/test/java/org/springframework/hateoas/hal/forms/Employee.java new file mode 100644 index 00000000..d520be89 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/Employee.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import lombok.Data; +import lombok.experimental.Wither; + +/** + * @author Greg Turnquist + */ +@Data +@Wither +class Employee { + + private final String name; + private final String role; +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/EmployeeResource.java b/src/test/java/org/springframework/hateoas/hal/forms/EmployeeResource.java new file mode 100644 index 00000000..dd60eb67 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/EmployeeResource.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import org.springframework.hateoas.ResourceSupport; + +/** + * @author Greg Turnquist + */ +@Data +@AllArgsConstructor +class EmployeeResource extends ResourceSupport { + + private String name; +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscovererUnitTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscovererUnitTest.java new file mode 100644 index 00000000..f9d08d5e --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsLinkDiscovererUnitTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2013-2015 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 + * + * http://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.hal.forms; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Test; + +import org.springframework.hateoas.LinkDiscoverer; +import org.springframework.hateoas.core.AbstractLinkDiscovererUnitTest; + +/** + * Unit tests for {@link HalFormsLinkDiscoverer}. + * + * @author Greg Turnquist + */ +public class HalFormsLinkDiscovererUnitTest extends AbstractLinkDiscovererUnitTest { + + static final LinkDiscoverer discoverer = new HalFormsLinkDiscoverer(); + static final String SAMPLE = "{ _links : { self : { href : 'selfHref' }, " + // + "relation : [ { href : 'firstHref' }, { href : 'secondHref' }], " + // + "'http://foo.com/bar' : { href : 'fullRelHref' }, " + "}}"; + + /** + * @see #314 + */ + @Test + public void discoversFullyQualifiedRel() { + assertThat(getDiscoverer().findLinkWithRel("http://foo.com/bar", SAMPLE), is(notNullValue())); + } + + @Override + protected LinkDiscoverer getDiscoverer() { + return discoverer; + } + + @Override + protected String getInputString() { + return SAMPLE; + } + + @Override + protected String getInputStringWithoutLinkContainer() { + return "{}"; + } +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverterTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverterTest.java new file mode 100644 index 00000000..6ebe8e82 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsMessageConverterTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.hasItems; +import static org.junit.Assert.*; +import static org.springframework.hateoas.hal.forms.HalFormsDocument.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.converter.HttpMessageConverter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Greg Turnquist + */ +public class HalFormsMessageConverterTest { + + ObjectMapper mapper; + HttpMessageConverter messageConverter; + + @Before + public void setUp() { + + this.mapper = new ObjectMapper(); + this.messageConverter = new HalFormsMessageConverter(this.mapper); + } + + @Test + public void verifyBasicAttributes() { + + assertThat(this.messageConverter.getSupportedMediaTypes(), hasItems(MediaTypes.HAL_FORMS_JSON)); + assertThat(this.messageConverter.canRead(HalFormsDocument.class, MediaTypes.HAL_FORMS_JSON), is(true)); + assertThat(this.messageConverter.canWrite(HalFormsDocument.class, MediaTypes.HAL_FORMS_JSON), is(true)); + } + + @Test + public void canReadAHalFormsDocumentMessage() throws IOException { + + HttpInputMessage message = new HttpInputMessage() { + @Override + public InputStream getBody() throws IOException { + return new ClassPathResource("reference.json", getClass()).getInputStream(); + } + + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + }; + + Object convertedMessage = this.messageConverter.read(HalFormsDocument.class, message); + + assertThat(convertedMessage, instanceOf(HalFormsDocument.class)); + + HalFormsDocument halFormsDocument = (HalFormsDocument) convertedMessage; + + assertThat(halFormsDocument.getLinks().size(), is(2)); + assertThat(halFormsDocument.getLinks().get(0).getHref(), is("/employees")); + assertThat(halFormsDocument.getLinks().get(1).getHref(), is("/employees/1")); + + assertThat(halFormsDocument.getTemplates().size(), is(1)); + assertThat(halFormsDocument.getTemplates().keySet(), hasItems("default")); + assertThat(halFormsDocument.getTemplates().get("default").getContentType(), is("application/hal+json")); + assertThat(halFormsDocument.getTemplates().get("default").getKey(), is(HalFormsTemplate.DEFAULT_KEY)); + assertThat(halFormsDocument.getTemplates().get("default").getHttpMethod(), is(HttpMethod.GET)); + assertThat(halFormsDocument.getTemplates().get("default").getMethod(), is(HttpMethod.GET.toString().toLowerCase())); + } + + @Test + public void canWriteAHalFormsDocumentMessage() throws IOException { + + HalFormsProperty property = new HalFormsProperty("my-name", true, "my-value", "my-prompt", + "my-regex", false, true, false); + HalFormsTemplate template = new HalFormsTemplate(); + template.setHttpMethod(HttpMethod.GET); + template.setContentType(Collections.singletonList(MediaTypes.HAL_JSON)); + template.setTitle("HAL-FORMS unit test"); + template.getProperties().add(property); + + HalFormsDocument expected = halFormsDocument() + .link(new Link("/employees").withRel("collection")) + .link(new Link("/employees/1").withSelfRel()) + .template("foo", template) + .build(); + + + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + HttpOutputMessage convertedMessage = new HttpOutputMessage() { + @Override + public OutputStream getBody() throws IOException { + return stream; + } + + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + }; + + this.messageConverter.write(expected, MediaTypes.HAL_FORMS_JSON, convertedMessage); + + String json = stream.toString(); + + System.out.println(json); + + HalFormsDocument actual = this.mapper.readValue(json, HalFormsDocument.class); + + assertThat(actual, is(expected)); + } + +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsValidationTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsValidationTest.java new file mode 100644 index 00000000..766b403c --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsValidationTest.java @@ -0,0 +1,236 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Test that when an {@link org.springframework.hateoas.Affordance} is included that does NOT match the self link, + * an exception is thrown. + * + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class HalFormsValidationTest { + + @Autowired + WebApplicationContext context; + + @Autowired + ObjectMapper mapper; + + MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = webAppContextSetup(this.context) + .build(); + } + + @Test + public void singleEmployee() throws Exception { + + Exception exception = this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) + .andDo(print()) + .andExpect(status().is5xxServerError()) + .andReturn() + .getResolvedException(); + + assertThat(exception.getMessage(), containsString("Affordance's URI /employees")); + assertThat(exception.getMessage(), containsString("doesn't match self link /employees/0")); + } + + @Test + public void collectionOfEmployees() throws Exception { + + Exception exception = this.mockMvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON)) + .andDo(print()) + .andExpect(status().is5xxServerError()) + .andReturn() + .getResolvedException(); + + assertThat(exception.getMessage(), containsString("Affordance's URI /employees/0")); + assertThat(exception.getMessage(), containsString("doesn't match self link /employees")); + } + + /** + * This controller violates HAL-FORMS spec requirements. We use it to verify the serializers can catch it. + */ + @RestController + static class BadController { + + private final static Map EMPLOYEES = new TreeMap(); + + static { + EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); + EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + } + + @GetMapping("/employees") + public Resources> all() { + + // Create a list of Resource's to return + List> employees = new ArrayList>(); + + // Fetch each Resource using the controller's findOne method. + for (int i=0; i < EMPLOYEES.size(); i++) { + employees.add(findOne(String.valueOf(i))); + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(BadController.class).all()).withSelfRel() + .withAffordance(afford(methodOn(BadController.class).updateEmployee(null, "0"))); + + // Return the collection of employee resources along with the composite affordance + return new Resources>(employees, selfLink); + } + + @GetMapping("/employees/{id}") + public Resource findOne(@PathVariable String id) { + + // Start the affordance with the "self" link, i.e. this method. + Link findOneLink = + linkTo(methodOn(BadController.class).findOne(id)).withSelfRel(); + + // Define final link as means to find entire collection. + Link employeesLink = + linkTo(methodOn(BadController.class).all()).withRel("employees") + .withAffordance(afford(methodOn(BadController.class).newEmployee(null))); + + // Return the affordance + a link back to the entire collection resource. + return new Resource( + EMPLOYEES.get(Integer.parseInt(id)), + findOneLink + .addAffordances(employeesLink.getAffordances()), + employeesLink); + } + + @PostMapping("/employees") + public ResponseEntity newEmployee(@RequestBody Employee employee) { + + int newEmployeeId = EMPLOYEES.size(); + + EMPLOYEES.put(newEmployeeId, employee); + + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(String.valueOf(newEmployeeId)).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/employees/{id}") + public ResponseEntity updateEmployee(@RequestBody Employee employee, @PathVariable String id) { + + EMPLOYEES.put(Integer.parseInt(id), employee); + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PatchMapping("/employees/{id}") + public ResponseEntity partiallyUpdateEmployee(@RequestBody Employee employee, @PathVariable String id) { + + Employee oldEmployee = EMPLOYEES.get(id); + + Employee newEmployee = oldEmployee; + + if (employee.getName() != null) { + newEmployee = newEmployee.withName(employee.getName()); + } + + if (employee.getRole() != null) { + newEmployee = newEmployee.withRole(employee.getRole()); + } + + EMPLOYEES.put(Integer.parseInt(id), newEmployee); + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + } + + @Configuration + @EnableWebMvc + @EnableHypermediaSupport(type = {HypermediaType.HAL_FORMS}) + static class TestConfig { + + @Bean + BadController employeeController() { + return new BadController(); + } + } + + +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcTest.java b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcTest.java new file mode 100644 index 00000000..00cf1519 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/HalFormsWebMvcTest.java @@ -0,0 +1,253 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.collection.IsCollectionWithSize.*; +import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Greg Turnquist + */ +@RunWith(SpringRunner.class) +@WebAppConfiguration +@ContextConfiguration +public class HalFormsWebMvcTest { + + @Autowired + WebApplicationContext context; + + @Autowired + ObjectMapper mapper; + + MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = webAppContextSetup(this.context) + .build(); + } + + @Test + public void singleEmployee() throws Exception { + + this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name", is("Frodo Baggins"))) + .andExpect(jsonPath("$.role", is("ring bearer"))) + + .andExpect(jsonPath("$._links.*", hasSize(2))) + .andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees"))) + + .andExpect(jsonPath("$._templates.*", hasSize(2))) + .andExpect(jsonPath("$._templates['default'].method", is("put"))) + .andExpect(jsonPath("$._templates['default'].properties[0].name", is("name"))) + .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true))) + .andExpect(jsonPath("$._templates['default'].properties[1].name", is("role"))) + .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true))) + + .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch"))) + .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name"))) + .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false))) + .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role"))) + .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false))); + } + + @Test + public void collectionOfEmployees() throws Exception { + + this.mockMvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins"))) + .andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer"))) + .andExpect(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0"))) + .andExpect(jsonPath("$._embedded.employees[1].name", is("Bilbo Baggins"))) + .andExpect(jsonPath("$._embedded.employees[1].role", is("burglar"))) + .andExpect(jsonPath("$._embedded.employees[1]._links['self'].href", is("http://localhost/employees/1"))) + + .andExpect(jsonPath("$._links.*", hasSize(1))) + .andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees"))) + + .andExpect(jsonPath("$._templates.*", hasSize(1))) + .andExpect(jsonPath("$._templates['default'].method", is("post"))) + .andExpect(jsonPath("$._templates['default'].properties[0].name", is("name"))) + .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true))) + .andExpect(jsonPath("$._templates['default'].properties[1].name", is("role"))) + .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true))); + } + + @RestController + static class EmployeeController { + + private final static Map EMPLOYEES = new TreeMap(); + + static { + EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer")); + EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar")); + } + + @GetMapping("/employees") + public Resources> all() { + + // Create a list of Resource's to return + List> employees = new ArrayList>(); + + // Fetch each Resource using the controller's findOne method. + for (int i=0; i < EMPLOYEES.size(); i++) { + employees.add(findOne(String.valueOf(i))); + } + + // Generate an "Affordance" based on this method (the "self" link) + Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel() + .withAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))); + + // Return the collection of employee resources along with the composite affordance + return new Resources>(employees, selfLink); + } + + @GetMapping("/employees/{id}") + public Resource findOne(@PathVariable String id) { + + // Start the affordance with the "self" link, i.e. this method. + Link findOneLink = + linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel(); + + // Define final link as means to find entire collection. + Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees"); + + // Return the affordance + a link back to the entire collection resource. + return new Resource( + EMPLOYEES.get(Integer.parseInt(id)), + findOneLink + .withAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) + .withAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))), + employeesLink); + } + + @PostMapping("/employees") + public ResponseEntity newEmployee(@RequestBody Employee employee) { + + int newEmployeeId = EMPLOYEES.size(); + + EMPLOYEES.put(newEmployeeId, employee); + + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(String.valueOf(newEmployeeId)).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/employees/{id}") + public ResponseEntity updateEmployee(@RequestBody Employee employee, @PathVariable String id) { + + EMPLOYEES.put(Integer.parseInt(id), employee); + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PatchMapping("/employees/{id}") + public ResponseEntity partiallyUpdateEmployee(@RequestBody Employee employee, @PathVariable String id) { + + Employee oldEmployee = EMPLOYEES.get(id); + + Employee newEmployee = oldEmployee; + + if (employee.getName() != null) { + newEmployee = newEmployee.withName(employee.getName()); + } + + if (employee.getRole() != null) { + newEmployee = newEmployee.withRole(employee.getRole()); + } + + EMPLOYEES.put(Integer.parseInt(id), newEmployee); + try { + return ResponseEntity + .noContent() + .location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse(""))) + .build(); + } catch (URISyntaxException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + } + + @Configuration + @EnableWebMvc + @EnableHypermediaSupport(type = {HypermediaType.HAL_FORMS}) + static class TestConfig { + + @Bean + EmployeeController employeeController() { + return new EmployeeController(); + } + } + + +} diff --git a/src/test/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsIntegrationTest.java b/src/test/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsIntegrationTest.java new file mode 100644 index 00000000..304591b1 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/hal/forms/Jackson2HalFormsIntegrationTest.java @@ -0,0 +1,425 @@ +/* + * Copyright 2017 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 + * + * http://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.hal.forms; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Links; +import org.springframework.hateoas.PagedResources; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceSupport; +import org.springframework.hateoas.Resources; +import org.springframework.hateoas.UriTemplate; +import org.springframework.hateoas.core.AnnotationRelProvider; +import org.springframework.hateoas.core.EmbeddedWrappers; +import org.springframework.hateoas.hal.CurieProvider; +import org.springframework.hateoas.hal.DefaultCurieProvider; +import org.springframework.hateoas.hal.SimpleAnnotatedPojo; +import org.springframework.hateoas.hal.SimplePojo; +import org.springframework.hateoas.hal.forms.Jackson2HalFormsModule.HalFormsHandlerInstantiator; +import org.springframework.hateoas.support.MappingUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * @author Greg Turnquist + */ +public class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegrationTest { + + static final Links PAGINATION_LINKS = new Links(new Link("foo", Link.REL_NEXT), new Link("bar", Link.REL_PREVIOUS)); + + @Before + public void setUpModule() { + + mapper.registerModule(new Jackson2HalFormsModule()); + mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator( + new AnnotationRelProvider(), null, null, true, new HalFormsConfiguration())); + mapper.configure(SerializationFeature.INDENT_OUTPUT, true); + } + + @Test + public void rendersSingleLinkAsObject() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost")); + + assertThat(write(resourceSupport), + is(MappingUtils.read(new ClassPathResource("single-link-reference.json", getClass())))); + } + + @Test + public void deserializeSingleLink() throws Exception { + + ResourceSupport expected = new ResourceSupport(); + expected.add(new Link("localhost")); + + assertThat(read(MappingUtils.read(new ClassPathResource("single-link-reference.json", getClass())), + ResourceSupport.class), is(expected)); + } + + @Test + public void rendersMultipleLinkAsArray() throws Exception { + + ResourceSupport resourceSupport = new ResourceSupport(); + resourceSupport.add(new Link("localhost")); + resourceSupport.add(new Link("localhost2")); + + assertThat(write(resourceSupport), + is(MappingUtils.read(new ClassPathResource("list-link-reference.json", getClass())))); + } + + @Test + public void deserializeMultipleLinks() throws Exception { + + ResourceSupport expected = new ResourceSupport(); + expected.add(new Link("localhost")); + expected.add(new Link("localhost2")); + + assertThat(read(MappingUtils.read(new ClassPathResource("list-link-reference.json", getClass())), + ResourceSupport.class), is(expected)); + } + + @Test + public void rendersResource() throws Exception { + + Resource resource = new Resource(new SimplePojo("test1", 1), new Link("localhost")); + + assertThat(write(resource), + is(MappingUtils.read(new ClassPathResource("simple-resource-unwrapped.json", getClass())))); + } + + @Test + public void deserializesResource() throws IOException { + + Resource expected = new Resource(new SimplePojo("test1", 1), new Link("localhost")); + + Resource result = mapper.readValue( + MappingUtils.read(new ClassPathResource("simple-resource-unwrapped.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resource.class, SimplePojo.class)); + + assertThat(result, is(expected)); + } + + @Test + public void rendersSimpleResourcesAsEmbedded() throws Exception { + + List content = new ArrayList(); + content.add("first"); + content.add("second"); + + Resources resources = new Resources(content); + resources.add(new Link("localhost")); + + assertThat(write(resources), + is(MappingUtils.read(new ClassPathResource("simple-embedded-resource-reference.json", getClass())))); + } + + @Test + public void deserializesSimpleResourcesAsEmbedded() throws Exception { + + List content = new ArrayList(); + content.add("first"); + content.add("second"); + + Resources expected = new Resources(content); + expected.add(new Link("localhost")); + + Resources result = mapper.readValue( + MappingUtils.read(new ClassPathResource("simple-embedded-resource-reference.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, String.class)); + + assertThat(result, is(expected)); + + } + + @Test + public void rendersSingleResourceResourcesAsEmbedded() throws Exception { + + List> content = new ArrayList>(); + content.add(new Resource(new SimplePojo("test1", 1), new Link("localhost"))); + + Resources> resources = new Resources>(content); + resources.add(new Link("localhost")); + + assertThat(write(resources), + is(MappingUtils.read(new ClassPathResource("single-embedded-resource-reference.json", getClass())))); + } + + @Test + public void deserializesSingleResourceResourcesAsEmbedded() throws Exception { + + List> content = new ArrayList>(); + content.add(new Resource(new SimplePojo("test1", 1), new Link("localhost"))); + + Resources> expected = new Resources>(content); + expected.add(new Link("localhost")); + + Resources> result = mapper.readValue( + MappingUtils.read(new ClassPathResource("single-embedded-resource-reference.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, SimplePojo.class))); + + assertThat(result, is(expected)); + } + + @Test + public void rendersMultipleResourceResourcesAsEmbedded() throws Exception { + + Resources> resources = setupResources(); + resources.add(new Link("localhost")); + + assertThat(write(resources), + is(MappingUtils.read(new ClassPathResource("multiple-resource-resources.json", getClass())))); + } + + @Test + public void deserializesMultipleResourceResourcesAsEmbedded() throws Exception { + + Resources> expected = setupResources(); + expected.add(new Link("localhost")); + + Resources> result = + mapper.readValue(MappingUtils.read(new ClassPathResource("multiple-resource-resources.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, SimplePojo.class))); + + assertThat(result, is(expected)); + } + + @Test + public void serializesAnnotatedResourceResourcesAsEmbedded() throws Exception { + + List> content = new ArrayList>(); + content.add(new Resource(new SimpleAnnotatedPojo("test1", 1), new Link("localhost"))); + + Resources> resources = new Resources>(content); + resources.add(new Link("localhost")); + + assertThat(write(resources), + is(MappingUtils.read(new ClassPathResource("annotated-resource-resources.json", getClass())))); + } + + @Test + public void deserializesAnnotatedResourceResourcesAsEmbedded() throws Exception { + + List> content = new ArrayList>(); + content.add(new Resource(new SimpleAnnotatedPojo("test1", 1), new Link("localhost"))); + + Resources> expected = new Resources>(content); + expected.add(new Link("localhost")); + + Resources> result = + mapper.readValue(MappingUtils.read(new ClassPathResource("annotated-resource-resources.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, SimpleAnnotatedPojo.class))); + + assertThat(result, is(expected)); + } + + @Test + public void serializesMultipleAnnotatedResourceResourcesAsEmbedded() throws Exception { + assertThat(write(setupAnnotatedResources()), + is(MappingUtils.read(new ClassPathResource("annotated-embedded-resources-reference.json", getClass())))); + } + + @Test + public void deserializesMultipleAnnotatedResourceResourcesAsEmbedded() throws Exception { + + Resources> result = + mapper.readValue(MappingUtils.read(new ClassPathResource("annotated-embedded-resources-reference.json", getClass())), + mapper.getTypeFactory().constructParametricType(Resources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, SimpleAnnotatedPojo.class))); + + assertThat(result, is(setupAnnotatedResources())); + } + + @Test + public void serializesPagedResource() throws Exception { + assertThat(write(setupAnnotatedPagedResources()), + is(MappingUtils.read(new ClassPathResource("annotated-paged-resources.json", getClass())))); + } + + @Test + public void deserializesPagedResource() throws Exception { + PagedResources> result = + mapper.readValue(MappingUtils.read(new ClassPathResource("annotated-paged-resources.json", getClass())), + mapper.getTypeFactory().constructParametricType(PagedResources.class, + mapper.getTypeFactory().constructParametricType(Resource.class, SimpleAnnotatedPojo.class))); + + assertThat(result, is(setupAnnotatedPagedResources())); + } + + @Test + public void rendersCuriesCorrectly() throws Exception { + + Resources resources = new Resources(Collections.emptySet(), new Link("foo"), + new Link("bar", "myrel")); + + assertThat(getCuriedObjectMapper().writeValueAsString(resources), + is(MappingUtils.read(new ClassPathResource("curied-document.json", getClass())))); + } + + @Test + public void doesNotRenderCuriesIfNoLinkIsPresent() throws Exception { + + Resources resources = new Resources(Collections.emptySet()); + assertThat(getCuriedObjectMapper().writeValueAsString(resources), + is(MappingUtils.read(new ClassPathResource("empty-document.json", getClass())))); + } + + @Test + public void doesNotRenderCuriesIfNoCurieLinkIsPresent() throws Exception { + + Resources resources = new Resources(Collections.emptySet()); + resources.add(new Link("foo")); + + assertThat(getCuriedObjectMapper().writeValueAsString(resources), + is(MappingUtils.read(new ClassPathResource("single-non-curie-document.json", getClass())))); + } + + @Test + public void rendersTemplate() throws Exception { + + ResourceSupport support = new ResourceSupport(); + support.add(new Link("/foo{?bar}", "search")); + + assertThat(write(support), + is(MappingUtils.read(new ClassPathResource("link-template.json", getClass())))); + } + + @Test + public void rendersMultipleCuries() throws Exception { + + Resources resources = new Resources(Collections.emptySet()); + resources.add(new Link("foo", "myrel")); + + CurieProvider provider = new DefaultCurieProvider("default", new UriTemplate("/doc{?rel}")) { + @Override + public Collection getCurieInformation(Links links) { + return Arrays.asList(new Curie("foo", "bar"), new Curie("bar", "foo")); + } + }; + + assertThat(getCuriedObjectMapper(provider, null).writeValueAsString(resources), + is(MappingUtils.read(new ClassPathResource("multiple-curies-document.json", getClass())))); + } + + @Test + public void rendersEmptyEmbeddedCollections() throws Exception { + + EmbeddedWrappers wrappers = new EmbeddedWrappers(false); + + List values = new ArrayList(); + values.add(wrappers.emptyCollectionOf(SimpleAnnotatedPojo.class)); + + Resources resources = new Resources(values); + + assertThat(write(resources), + is(MappingUtils.read(new ClassPathResource("empty-embedded-pojos.json", getClass())))); + } + + @Test + public void rendersTitleIfMessageSourceResolvesNamespacedKey() throws Exception { + verifyResolvedTitle("_links.ns:foobar.title"); + } + + @Test + public void rendersTitleIfMessageSourceResolvesLocalKey() throws Exception { + verifyResolvedTitle("_links.foobar.title"); + } + + private void verifyResolvedTitle(String resourceBundleKey) throws Exception { + + LocaleContextHolder.setLocale(Locale.US); + + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage(resourceBundleKey, Locale.US, "Foobar's title!"); + + ObjectMapper objectMapper = getCuriedObjectMapper(null, messageSource); + + ResourceSupport resource = new ResourceSupport(); + resource.add(new Link("target", "ns:foobar")); + + assertThat(objectMapper.writeValueAsString(resource), + is(MappingUtils.read(new ClassPathResource("link-with-title.json", getClass())))); + } + + private static Resources> setupResources() { + + List> content = new ArrayList>(); + content.add(new Resource(new SimplePojo("test1", 1), new Link("localhost"))); + content.add(new Resource(new SimplePojo("test2", 2), new Link("localhost"))); + + return new Resources>(content); + } + + private static Resources> setupAnnotatedResources() { + + List> content = new ArrayList>(); + content.add(new Resource(new SimpleAnnotatedPojo("test1", 1), new Link("localhost"))); + content.add(new Resource(new SimpleAnnotatedPojo("test2", 2), new Link("localhost"))); + + return new Resources>(content); + } + + private static Resources> setupAnnotatedPagedResources() { + + List> content = new ArrayList>(); + content.add(new Resource(new SimpleAnnotatedPojo("test1", 1), new Link("localhost"))); + content.add(new Resource(new SimpleAnnotatedPojo("test2", 2), new Link("localhost"))); + + return new PagedResources>(content, new PagedResources.PageMetadata(2, 0, 4), PAGINATION_LINKS); + } + + private static ObjectMapper getCuriedObjectMapper() { + + return getCuriedObjectMapper(new DefaultCurieProvider("foo", new UriTemplate("http://localhost:8080/rels/{rel}")), + null); + } + + private static ObjectMapper getCuriedObjectMapper(CurieProvider provider, MessageSource messageSource) { + + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new Jackson2HalFormsModule()); + mapper.setHandlerInstantiator(new HalFormsHandlerInstantiator(new AnnotationRelProvider(), provider, + messageSource == null ? null : new MessageSourceAccessor(messageSource), true, new HalFormsConfiguration())); + mapper.configure(SerializationFeature.INDENT_OUTPUT, true); + + return mapper; + } + + +} diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java index 3ce3c1fc..f7e237e7 100755 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java @@ -672,6 +672,7 @@ public class ControllerLinkBuilderUnitTest extends TestUtils { return null; } + @RequestMapping HttpEntity methodWithJdk8Optional(@RequestParam Optional value) { return null; } diff --git a/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTests.java b/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTests.java new file mode 100644 index 00000000..84a9573f --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/SpringMvcAffordanceBuilderUnitTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2017 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 + * + * http://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.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.junit.Test; +import org.springframework.core.annotation.Order; +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.AffordanceModelFactory; +import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation; +import org.springframework.http.MediaType; +import org.springframework.plugin.core.OrderAwarePluginRegistry; +import org.springframework.web.util.UriComponents; + +/** + * @author Greg Turnquist + */ +public class SpringMvcAffordanceBuilderUnitTests { + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullPluginRegistry() { + new SpringMvcAffordanceBuilder(null); + } + + @Test + public void favorsCustomLinkDiscovererOverDefault() { + + AffordanceModelFactory low = new LowPriorityModelFactory(); + AffordanceModelFactory high = new HighPriorityModelFactory(); + + OrderAwarePluginRegistry registry = + OrderAwarePluginRegistry.create(Arrays.asList(low, high)); + + assertThat(registry.getPluginFor(MediaType.APPLICATION_JSON).get()).isEqualTo(high); + } + + @Order(20) + static class LowPriorityModelFactory extends AffordanceModelFactory { + + @Override + public MediaType getMediaType() { + return MediaType.APPLICATION_JSON; + } + + @Override + public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) { + return null; + } + + @Override + public boolean supports(MediaType delimiter) { + return true; + } + } + + @Order(10) + static class HighPriorityModelFactory extends AffordanceModelFactory { + + @Override + public MediaType getMediaType() { + return MediaType.APPLICATION_JSON; + } + + @Override + public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) { + return null; + } + + @Override + public boolean supports(MediaType delimiter) { + return true; + } + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/TypeReferencesIntegrationTest.java b/src/test/java/org/springframework/hateoas/mvc/TypeReferencesIntegrationTest.java index 307266b3..1dc7e5f1 100755 --- a/src/test/java/org/springframework/hateoas/mvc/TypeReferencesIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/TypeReferencesIntegrationTest.java @@ -33,6 +33,7 @@ import org.springframework.hateoas.Resource; import org.springframework.hateoas.Resources; import org.springframework.hateoas.config.EnableHypermediaSupport; import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.hateoas.hal.HalConfiguration; import org.springframework.hateoas.mvc.TypeReferences.ResourceType; import org.springframework.hateoas.mvc.TypeReferences.ResourcesType; import org.springframework.http.HttpMethod; diff --git a/src/test/java/org/springframework/hateoas/support/MappingUtils.java b/src/test/java/org/springframework/hateoas/support/MappingUtils.java new file mode 100644 index 00000000..3db954ee --- /dev/null +++ b/src/test/java/org/springframework/hateoas/support/MappingUtils.java @@ -0,0 +1,60 @@ +/* + * Copyright 2015-2017 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 + * + * http://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.support; + +import java.io.IOException; +import java.util.Scanner; + +import org.springframework.core.io.Resource; + +/** + * @author Greg Turnquist + */ +public final class MappingUtils { + + /** + * Read test files. + * + * @param resource as a {@link Resource} + * @return + * @throws IOException + */ + public static String read(Resource resource) throws IOException { + + Scanner scanner = null; + + try { + + scanner = new Scanner(resource.getInputStream()); + StringBuilder builder = new StringBuilder(); + + while (scanner.hasNextLine()) { + + builder.append(scanner.nextLine()); + + if (scanner.hasNextLine()) { + builder.append("\n"); + } + } + + return builder.toString(); + } finally { + if (scanner != null) { + scanner.close(); + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/annotated-embedded-resources-reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-embedded-resources-reference.json new file mode 100644 index 00000000..fc9a4305 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-embedded-resources-reference.json @@ -0,0 +1,21 @@ +{ + "_embedded" : { + "pojos" : [ { + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } + }, { + "text" : "test2", + "number" : 2, + "_links" : { + "self" : { + "href" : "localhost" + } + } + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/annotated-paged-resources.json b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-paged-resources.json new file mode 100644 index 00000000..8d890390 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-paged-resources.json @@ -0,0 +1,35 @@ +{ + "_embedded" : { + "pojos" : [ { + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } + }, { + "text" : "test2", + "number" : 2, + "_links" : { + "self" : { + "href" : "localhost" + } + } + } ] + }, + "_links" : { + "next" : { + "href" : "foo" + }, + "prev" : { + "href" : "bar" + } + }, + "page" : { + "size" : 2, + "totalElements" : 4, + "totalPages" : 2, + "number" : 0 + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/annotated-resource-resources.json b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-resource-resources.json new file mode 100644 index 00000000..bd9811a3 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/annotated-resource-resources.json @@ -0,0 +1,18 @@ +{ + "_embedded" : { + "pojos" : [ { + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } + } ] + }, + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/curied-document.json b/src/test/resources/org/springframework/hateoas/hal/forms/curied-document.json new file mode 100644 index 00000000..da89ff40 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/curied-document.json @@ -0,0 +1,16 @@ +{ + "_embedded" : { }, + "_links" : { + "self" : { + "href" : "foo" + }, + "foo:myrel" : { + "href" : "bar" + }, + "curies" : [ { + "href" : "http://localhost:8080/rels/{rel}", + "name" : "foo", + "templated" : true + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/empty-document.json b/src/test/resources/org/springframework/hateoas/hal/forms/empty-document.json new file mode 100644 index 00000000..cf947791 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/empty-document.json @@ -0,0 +1,3 @@ +{ + "_embedded" : { } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/empty-embedded-pojos.json b/src/test/resources/org/springframework/hateoas/hal/forms/empty-embedded-pojos.json new file mode 100644 index 00000000..081e87b8 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/empty-embedded-pojos.json @@ -0,0 +1,5 @@ +{ + "_embedded" : { + "pojos" : [ ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/link-template.json b/src/test/resources/org/springframework/hateoas/hal/forms/link-template.json new file mode 100644 index 00000000..27e89aee --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/link-template.json @@ -0,0 +1,8 @@ +{ + "_links" : { + "search" : { + "href" : "/foo{?bar}", + "templated" : true + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/link-with-title.json b/src/test/resources/org/springframework/hateoas/hal/forms/link-with-title.json new file mode 100644 index 00000000..9ef9709a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/link-with-title.json @@ -0,0 +1,8 @@ +{ + "_links" : { + "ns:foobar" : { + "href" : "target", + "title" : "Foobar's title!" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/list-link-reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/list-link-reference.json new file mode 100644 index 00000000..092c7de9 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/list-link-reference.json @@ -0,0 +1,9 @@ +{ + "_links" : { + "self" : [ { + "href" : "localhost" + }, { + "href" : "localhost2" + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/multiple-curies-document.json b/src/test/resources/org/springframework/hateoas/hal/forms/multiple-curies-document.json new file mode 100644 index 00000000..c963301b --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/multiple-curies-document.json @@ -0,0 +1,15 @@ +{ + "_embedded" : { }, + "_links" : { + "default:myrel" : { + "href" : "foo" + }, + "curies" : [ { + "href" : "bar", + "name" : "foo" + }, { + "href" : "foo", + "name" : "bar" + } ] + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/multiple-resource-resources.json b/src/test/resources/org/springframework/hateoas/hal/forms/multiple-resource-resources.json new file mode 100644 index 00000000..a970fe3a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/multiple-resource-resources.json @@ -0,0 +1,26 @@ +{ + "_embedded" : { + "content" : [ { + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } + }, { + "text" : "test2", + "number" : 2, + "_links" : { + "self" : { + "href" : "localhost" + } + } + } ] + }, + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/reference.json new file mode 100644 index 00000000..50712e00 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/reference.json @@ -0,0 +1,25 @@ +{ + "_links" : { + "collection" : { + "href" : "/employees" + }, + "self" : { + "href" : "/employees/1" + } + }, + "_templates" : { + "default" : { + "title" : "HAL-FORMS unit test", + "method" : "get", + "contentType" : "application/hal+json", + "properties" : [ { + "name" : "my-name", + "readOnly" : true, + "value" : "my-value", + "prompt" : "my-prompt", + "regex" : "my-regex", + "required" : true + } ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/simple-embedded-resource-reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/simple-embedded-resource-reference.json new file mode 100644 index 00000000..471907bd --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/simple-embedded-resource-reference.json @@ -0,0 +1,10 @@ +{ + "_embedded" : { + "content" : [ "first", "second" ] + }, + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/simple-resource-unwrapped.json b/src/test/resources/org/springframework/hateoas/hal/forms/simple-resource-unwrapped.json new file mode 100644 index 00000000..58cef78d --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/simple-resource-unwrapped.json @@ -0,0 +1,9 @@ +{ + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/single-embedded-resource-reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/single-embedded-resource-reference.json new file mode 100644 index 00000000..21f2fd08 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/single-embedded-resource-reference.json @@ -0,0 +1,18 @@ +{ + "_embedded" : { + "content" : [ { + "text" : "test1", + "number" : 1, + "_links" : { + "self" : { + "href" : "localhost" + } + } + } ] + }, + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/single-link-reference.json b/src/test/resources/org/springframework/hateoas/hal/forms/single-link-reference.json new file mode 100644 index 00000000..958bb197 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/single-link-reference.json @@ -0,0 +1,7 @@ +{ + "_links" : { + "self" : { + "href" : "localhost" + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/hal/forms/single-non-curie-document.json b/src/test/resources/org/springframework/hateoas/hal/forms/single-non-curie-document.json new file mode 100644 index 00000000..ca582e97 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/hal/forms/single-non-curie-document.json @@ -0,0 +1,8 @@ +{ + "_embedded" : { }, + "_links" : { + "self" : { + "href" : "foo" + } + } +} \ No newline at end of file