From 24a48a4229c31379c6b813c4cd9a36ffe3537f21 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Fri, 12 May 2017 18:19:15 -0500 Subject: [PATCH] #340 - Affordances API + HAL-Forms * Adds new Affordances API * Introduces HAL-Forms, which uses affordances to automatically generate HTML form data based on marked up domain objects and controllers Attempted rebase of review/affordances against master Original pull-request: #340, #447 Related issues: #503, #334 --- pom.xml | 89 +- src/main/asciidoc/index.adoc | 145 ++++ .../org/springframework/hateoas/IanaRels.java | 4 +- .../hateoas/JacksonHelper.java | 35 + .../org/springframework/hateoas/Link.java | 6 +- .../springframework/hateoas/MediaTypes.java | 12 +- .../hateoas/TemplateVariable.java | 72 +- .../hateoas/TemplateVariables.java | 71 +- .../springframework/hateoas/UriTemplate.java | 412 ++++++--- .../hateoas/UriTemplateComponents.java | 106 +++ .../hateoas/affordance/ActionDescriptor.java | 153 ++++ .../affordance/ActionInputParameter.java | 227 +++++ .../ActionInputParameterVisitor.java | 27 + .../hateoas/affordance/Affordance.java | 562 +++++++++++++ .../hateoas/affordance/ParameterType.java | 26 + .../hateoas/affordance/Select.java | 50 ++ .../hateoas/affordance/SimpleSuggest.java | 52 ++ .../hateoas/affordance/Suggest.java | 70 ++ .../hateoas/affordance/SuggestImpl.java | 116 +++ .../affordance/SuggestObjectWrapper.java | 52 ++ .../hateoas/affordance/SuggestType.java | 38 + .../hateoas/affordance/SuggestionVisitor.java | 36 + .../hateoas/affordance/Suggestions.java | 207 +++++ .../affordance/SuggestionsProvider.java | 32 + .../hateoas/affordance/TypedResource.java | 65 ++ .../hateoas/affordance/WrappedValue.java | 27 + .../hateoas/affordance/formaction/Action.java | 38 + .../affordance/formaction/Cardinality.java | 37 + .../affordance/formaction/DTOParam.java | 39 + .../hateoas/affordance/formaction/Input.java | 141 ++++ .../affordance/formaction/Options.java | 68 ++ .../formaction/ResourceHandler.java | 49 ++ .../hateoas/affordance/formaction/Select.java | 80 ++ .../affordance/formaction/StringOptions.java | 48 ++ .../hateoas/affordance/formaction/Type.java | 155 ++++ .../affordance/formaction/package-info.java | 21 + .../hateoas/affordance/package-info.java | 5 + .../springmvc/ActionDescriptorBuilder.java | 195 +++++ .../springmvc/AffordanceBuilder.java | 436 ++++++++++ .../springmvc/AffordanceBuilderFactory.java | 151 ++++ .../DefaultDocumentationProvider.java | 75 ++ .../springmvc/DocumentationProvider.java | 59 ++ .../springmvc/SpringActionDescriptor.java | 782 +++++++++++++++++ .../springmvc/SpringActionInputParameter.java | 791 ++++++++++++++++++ .../UrlPrefixDocumentationProvider.java | 87 ++ .../affordance/springmvc/package-info.java | 40 + .../affordance/support/DataTypeUtils.java | 219 +++++ .../hateoas/affordance/support/Path.java | 76 ++ .../affordance/support/PropertyUtils.java | 156 ++++ .../springframework/hateoas/alps/Alps.java | 40 +- .../hateoas/alps/Descriptor.java | 6 +- .../org/springframework/hateoas/alps/Doc.java | 2 +- .../org/springframework/hateoas/alps/Ext.java | 2 +- .../springframework/hateoas/client/Hop.java | 8 +- .../hateoas/client/Traverson.java | 5 +- .../config/EnableHypermediaSupport.java | 56 +- ...ermediaSupportBeanDefinitionRegistrar.java | 98 ++- .../hateoas/core/DummyInvocationUtils.java | 42 +- .../hateoas/core/EncodingUtils.java | 4 +- .../hateoas/core/EvoInflectorRelProvider.java | 1 + .../hateoas/core/JsonPathLinkDiscoverer.java | 2 +- .../hateoas/core/Recorder.java | 157 ++++ .../hateoas/hal/Jackson2HalModule.java | 90 +- .../hateoas/hal/LinkMixin.java | 2 +- .../hal/forms/HalFormsConfiguration.java | 43 + .../hal/forms/HalFormsDeserializers.java | 275 ++++++ .../hateoas/hal/forms/HalFormsDocument.java | 98 +++ .../hal/forms/HalFormsLinkDiscoverer.java | 32 + .../hal/forms/HalFormsMessageConverter.java | 97 +++ .../hal/forms/HalFormsSerializers.java | 362 ++++++++ .../hateoas/hal/forms/HalFormsTemplate.java | 35 + .../hateoas/hal/forms/HalFormsUtils.java | 180 ++++ .../hal/forms/Jackson2HalFormsModule.java | 173 ++++ .../hateoas/hal/forms/Property.java | 58 ++ .../hateoas/hal/forms/SuggestionsMixin.java | 33 + .../hateoas/hal/forms/Template.java | 108 +++ .../hal/forms/ValueSuggestionsMixin.java | 31 + .../AnnotatedParametersParameterAccessor.java | 6 +- .../hateoas/mvc/ControllerLinkBuilder.java | 61 +- .../mvc/ControllerLinkBuilderFactory.java | 4 +- ...cessorHandlerMethodReturnValueHandler.java | 4 +- ...sourceProcessorInvokingHandlerAdapter.java | 6 +- .../hateoas/mvc/UriComponentsSupport.java | 82 ++ ...actJackson2MarshallingIntegrationTest.java | 7 +- ...Jackson2PagedResourcesIntegrationTest.java | 4 +- .../hateoas/TemplateVariablesUnitTest.java | 58 +- .../hateoas/UriTemplateComponentsTest.java | 35 + .../hateoas/UriTemplateUnitTest.java | 288 ++++++- .../AffordanceDocumentationTest.java | 282 +++++++ .../hateoas/affordance/AffordanceTest.java | 90 ++ .../formaction/ActionInputParameterTest.java | 278 ++++++ .../AffordanceBuilderFactoryTest.java | 127 +++ .../springmvc/AffordanceBuilderTest.java | 276 ++++++ .../springmvc/sample/test/CreativeWork.java | 27 + .../sample/test/DummyEventController.java | 165 ++++ .../springmvc/sample/test/Event.java | 73 ++ .../springmvc/sample/test/EventResource.java | 32 + .../sample/test/EventStatusType.java | 18 + .../springmvc/sample/test/Person.java | 27 + .../springmvc/sample/test/Rating.java | 38 + .../springmvc/sample/test/Review.java | 48 ++ .../sample/test/ReviewController.java | 77 ++ .../affordance/support/DataTypeUtilsTest.java | 208 +++++ .../alps/JacksonSerializationTest.java | 36 +- .../hateoas/client/Server.java | 21 +- .../hateoas/client/TraversonTest.java | 19 +- ...nableHypermediaSupportIntegrationTest.java | 130 ++- .../hateoas/core/RecorderUnitTests.java | 83 ++ .../hal/Jackson2HalIntegrationTest.java | 19 + .../forms/HalFormsLinkDiscovererUnitTest.java | 61 ++ .../forms/HalFormsMessageConverterTest.java | 138 +++ .../hal/forms/HalFormsResponseTest.java | 400 +++++++++ .../hal/forms/HalFormsSerializationTest.java | 473 +++++++++++ .../forms/SuggestionSerializationTests.java | 97 +++ .../hal/forms/support/AnotherSubItem.java | 52 ++ .../hateoas/hal/forms/support/BooleanMap.java | 33 + .../hal/forms/support/DummyController.java | 457 ++++++++++ .../hateoas/hal/forms/support/Item.java | 127 +++ .../hateoas/hal/forms/support/ItemParams.java | 36 + .../hal/forms/support/ItemResource.java | 68 ++ .../hateoas/hal/forms/support/ItemType.java | 20 + .../hal/forms/support/ListableItemType.java | 20 + .../hal/forms/support/ListableSubEntity.java | 50 ++ .../hateoas/hal/forms/support/SubEntity.java | 51 ++ .../hateoas/hal/forms/support/SubItem.java | 90 ++ .../hal/forms/support/SubSubEntity.java | 53 ++ .../support/WildCardedListableSubEntity.java | 56 ++ .../mvc/ControllerLinkBuilderUnitTest.java | 16 +- .../mvc/TypeReferencesIntegrationTest.java | 7 +- .../hateoas/support/MappingUtils.java | 60 ++ .../hal/forms/reference-links-only.json | 10 + .../hateoas/hal/forms/reference.json | 28 + .../hateoas/hal/templated-link.json | 11 + template.mf | 1 + 134 files changed, 13023 insertions(+), 501 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/JacksonHelper.java create mode 100644 src/main/java/org/springframework/hateoas/UriTemplateComponents.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/ActionDescriptor.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/ActionInputParameter.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/ActionInputParameterVisitor.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/Affordance.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/ParameterType.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/Select.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SimpleSuggest.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/Suggest.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SuggestImpl.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SuggestObjectWrapper.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SuggestType.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SuggestionVisitor.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/Suggestions.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/SuggestionsProvider.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/TypedResource.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/WrappedValue.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Action.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Cardinality.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/DTOParam.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Input.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Options.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/ResourceHandler.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Select.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/StringOptions.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/Type.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/formaction/package-info.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/package-info.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/ActionDescriptorBuilder.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilder.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderFactory.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/DefaultDocumentationProvider.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/DocumentationProvider.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionDescriptor.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionInputParameter.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/UrlPrefixDocumentationProvider.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/springmvc/package-info.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/support/DataTypeUtils.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/support/Path.java create mode 100644 src/main/java/org/springframework/hateoas/affordance/support/PropertyUtils.java create mode 100644 src/main/java/org/springframework/hateoas/core/Recorder.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/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/HalFormsUtils.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/Property.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/SuggestionsMixin.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/Template.java create mode 100644 src/main/java/org/springframework/hateoas/hal/forms/ValueSuggestionsMixin.java create mode 100644 src/main/java/org/springframework/hateoas/mvc/UriComponentsSupport.java create mode 100644 src/test/java/org/springframework/hateoas/UriTemplateComponentsTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/AffordanceTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/formaction/ActionInputParameterTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderFactoryTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderTest.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/CreativeWork.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/DummyEventController.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/Event.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/EventResource.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/EventStatusType.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/Person.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/Rating.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/Review.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/springmvc/sample/test/ReviewController.java create mode 100644 src/test/java/org/springframework/hateoas/affordance/support/DataTypeUtilsTest.java create mode 100644 src/test/java/org/springframework/hateoas/core/RecorderUnitTests.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/HalFormsResponseTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/HalFormsSerializationTest.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/SuggestionSerializationTests.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/AnotherSubItem.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/BooleanMap.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/DummyController.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/Item.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/ItemParams.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/ItemResource.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/ItemType.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/ListableItemType.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/ListableSubEntity.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/SubEntity.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/SubItem.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/SubSubEntity.java create mode 100644 src/test/java/org/springframework/hateoas/hal/forms/support/WildCardedListableSubEntity.java create mode 100644 src/test/java/org/springframework/hateoas/support/MappingUtils.java create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/reference-links-only.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/forms/reference.json create mode 100644 src/test/resources/org/springframework/hateoas/hal/templated-link.json diff --git a/pom.xml b/pom.xml index 6d3e8172d..1dd46873b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,10 +1,11 @@ - + 4.0.0 org.springframework.hateoas spring-hateoas - 0.24.0.BUILD-SNAPSHOT + 0.24.0.AFFORDANCES-SNAPSHOT Spring HATEOAS http://github.com/SpringSource/spring-hateoas @@ -69,6 +70,7 @@ UTF-8 4.3.5.RELEASE + 1.2.0.RELEASE 1.1.8 0.7.8 ${project.build.directory}/jacoco.exec @@ -87,7 +89,7 @@ spring43-next - 4.3.8.BUILD-SNAPSHOT + 4.3.9.BUILD-SNAPSHOT @@ -100,7 +102,8 @@ spring5 - 5.0.0.M5 + 5.0.0.RC1 + 2.9.0.pr3 @@ -193,7 +196,6 @@ ${project.build.directory}/shared-resources true - true ${basedir} @@ -211,9 +213,8 @@ - + org.apache.maven.plugins @@ -236,9 +237,7 @@ - + org.apache.maven.plugins @@ -254,6 +253,18 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + 2.20 + + + **/*DocumentationTest + + + + org.asciidoctor asciidoctor-maven-plugin @@ -269,12 +280,17 @@ asciidoctorj-epub3 1.5.0-alpha.6 + + org.springframework.restdocs + spring-restdocs-asciidoctor + ${spring-restdocs.version} + html - generate-resources + prepare-package process-asciidoc @@ -292,23 +308,13 @@ - + pdf - generate-resources + prepare-package process-asciidoc @@ -348,13 +354,15 @@ - + - + @@ -373,8 +381,12 @@ process-resources - - + + @@ -574,6 +586,13 @@ test + + org.springframework.restdocs + spring-restdocs-mockmvc + ${spring-restdocs.version} + test + + org.mockito mockito-all @@ -602,7 +621,15 @@ test - + + com.jayway.jsonpath + json-path-assert + 2.2.0 + test + + + javax.servlet diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index d7e5baa7e..cad0095bc 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -436,3 +436,148 @@ Link link = discoverer.findLinkWithRel("foo", content); assertThat(link.getRel(), is("foo")); assertThat(link.getHref(), is("/foo/bar")); ---- + +[[affordances]] +== Affordances + +The *Affordances API* provides the means to mark up your domain objects and controllers such that you can generate +not only links, but additional queries with extra metadata. + +This is done using a much richer version of Spring HATEOAS's `Link` class, the `Affordance` class. Fundamentally, +an `Affordance` IS a `Link`: + +[source,java] +---- +public class Affordance extends Link { + ... +} +---- + +It comes with extra operators that allows assembling not just links, but access to extra metadata that can +be used to serve clients, as you'll see demonstrated in this section. + +For a more detailed description of "affordances" in the realm of hypermedia, checkout the following video by +Mike Amundsen. + +video::W7NRMhZ4MDk[youtube] + +=== Generating metadata about possible flows + +Imagine defining the following domain object: + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee] +---- + +This is like any other domain object with its attributes (with the boilerplate handled by Lombok's +`@Data` annotation). However, buried in the constructor call are some extra annotations: + +* Jackson's `@JsonCreator` annotation flags this constructor as the one to use when Jackson creates a new object. +* Each field has a corresponding Jackson `@JsonProperty` annotation. +* Additionally, the input fields are further marked up with `@Input(required=true)`, indicated they are not +optional fields. + +While these annotations aren't require for Jackson to do its thing, the Affordance API uses this additional data +to fabricate additional hypermedia. + +Create a Spring Web controller like this: + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee-controller] + ... + } +---- + +Add a request handler for fetching all employees: + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-all] +---- + +The return type is `Resources>`. This represents a resource collection, with each element itself +also a resource. To build the collection up, you leverage the controller's `findOne()` method, which returns a +`Resource`. With the content assembled, you can move into defining this resource's *affordances*. + +The first link in any `Affordance` is the *self* link. So you can use the Affordance API's +`linkTo(methodOn(...)` methods (just like `ControllerLinkBuilder`) and create an `AffordanceBuilder` against +this method. + +From there, we can take the `builder` and add more affordance using the `.add(...)` method. In this example, you +are grabbing a hold of the controller's `newEmployee()` method. + +TIP: Certain mediatypes, like HAL-Forms, support _templates_. These are additional operations that work, but +generally against the same URI (the *self* link). Therefore, we are only adding affordances that also map onto +`/employees` in this method. + +Return a `Resources` collection resource, making the `builder` produce a *self* link. + +NOTE: Normally, you might use a Spring Data repository to actually retrieve this list of employees. However, +for simplicity, we are using a plain old Java map. + +Before we test drive this, we need to define `findOne(...)` that we just saw. Add the following to your controller: + +[source,java,indent=0] +---- +include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-one] +---- + +As shown in the comments, you start with an affordance for the "self" link, i.e. this method. Assuming this controller +has support to both *PUT* and *PATCH* single resources, we can create affordances for both. A key difference between +PUT and PATCH is that PUT replaces the entire record, while PATCH often is used to update individual fields. Hence, +we want to flip the parameters on `Employee` that are marked `@Input(required = true)` to false. + +With this in place, we can now inspect the hypermedia generated by Spring HATEOAS. + +To interrogate the collection, we just need to make a request like this: + +include::{snippets}/basic/1/http-request.adoc[] + +As expected, we get back a collection resource with `_embedded` and `_links`. + +include::{snippets}/basic/1/response-body.adoc[] + +This document is chock full of data. But when it comes time to create a new employee, what do we do? + +IMPORTANT: HAL is a popular format for hypermedia. Here is where we get to see its limitations. The self link +at the bottom to `/employees` _only_ shows us the URI. It doesn't communicate ALL the operations _afforded_ +to us at that URI. + +To discover what we can do, we merely need to change the *Accept* header in our request, like this: + +include::{snippets}/basic/2/http-request.adoc[] + +This will give us a HAL-Forms document. + +include::{snippets}/basic/2/response-body.adoc[] + +We can see the *self* link at the top. But below that, is a *templates* section. These are operations we can perform. + +Remember where we had `builder.and(linkTo(methodOn(EmployeeController.class).newEmployee(null)).rel("create"))` in + `all()`? That is what got transformed into the *default* template for *POST*, using the `@JsonCreator` and `@Input` + metadata from our domain object. + +This hypermedia can be used by your website to generate an HTML FORM, hence why it's called HAL-Forms. + +Remember marking marked up `findOne`? To see that, we need to navigate to an individual employee's +HAL-Form: + +include::{snippets}/basic/4/http-request.adoc[] + +NOTE: We skipped navigating to the HAL record for `/employees/0` and jumped straight to the HAL-Form: + +include::{snippets}/basic/4/response-body.adoc[] + +You can see the self link as well as the link back to the collection resource. But focusing on the self like +(which HAL-Forms apply to), there are two templates: *PUT* and *PATCH*. In PUT, both properties are required, +as depicted in your `Employee` definition. But in PATCH, they aren't. + +One last piece of information on this HAL-Form. The PUT template is named *default*, since it's the first. But +the PATCH template is named *partial-update*. By default, Spring HATEOAS will name the template based on the +HTTP verb. But that was overridden using `@Action("partial-update")`, applied to the `.partialUpdate(...)`. + +By using a few extra annotations on the domain object and the controller, and by chaining together some REST +methods, the Affordance API has made it possible to generate a much richer hypermedia that can simplify front +end development. \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/IanaRels.java b/src/main/java/org/springframework/hateoas/IanaRels.java index 8f6292fa6..0b5492bb5 100644 --- a/src/main/java/org/springframework/hateoas/IanaRels.java +++ b/src/main/java/org/springframework/hateoas/IanaRels.java @@ -15,13 +15,13 @@ */ package org.springframework.hateoas; -import lombok.experimental.UtilityClass; - import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; +import lombok.experimental.UtilityClass; + /** * Static class to find out whether a relation type is defined by the IANA. * diff --git a/src/main/java/org/springframework/hateoas/JacksonHelper.java b/src/main/java/org/springframework/hateoas/JacksonHelper.java new file mode 100644 index 000000000..ea6b2f540 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/JacksonHelper.java @@ -0,0 +1,35 @@ +/* + * 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 com.fasterxml.jackson.databind.JavaType; + +/** + * Utilities to help with interacting with Jackson. + * + * @author Greg Turnquist + */ +public final class JacksonHelper { + + public static JavaType findParentType(JavaType contentType) { + + if (contentType.hasGenericTypes()) { + return contentType.containedType(0); + } else { + return contentType; + } + } +} diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java index 11ac811d6..307d722df 100755 --- a/src/main/java/org/springframework/hateoas/Link.java +++ b/src/main/java/org/springframework/hateoas/Link.java @@ -166,6 +166,10 @@ public boolean isTemplated() { return !getUriTemplate().getVariables().isEmpty(); } + public boolean getTemplated() { + return this.isTemplated(); + } + /** * Turns the current template into a {@link Link} by expanding it using the given parameters. * @@ -173,7 +177,7 @@ public boolean isTemplated() { * @return */ public Link expand(Object... arguments) { - return new Link(getUriTemplate().expand(arguments).toString(), getRel()); + return new Link(getUriTemplate().expand(arguments).toUri().toString(), getRel()); } /** diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 6e020bdab..3f39b6c97 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2015 the original author or authors. + * Copyright 2013-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. @@ -22,6 +22,8 @@ * * @author Oliver Gierke * @author Przemek Nowak + * @author Dietrich Schulten + * @author Greg Turnquist */ public class MediaTypes { @@ -34,4 +36,12 @@ public class MediaTypes { * Public constant media type for {@code application/hal+json}. */ public static final MediaType HAL_JSON = MediaType.valueOf(HAL_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 static final MediaType HAL_FORMS_JSON = MediaType.parseMediaType(HAL_FORMS_JSON_VALUE); + } diff --git a/src/main/java/org/springframework/hateoas/TemplateVariable.java b/src/main/java/org/springframework/hateoas/TemplateVariable.java index 2a4ca6d10..2f970210b 100644 --- a/src/main/java/org/springframework/hateoas/TemplateVariable.java +++ b/src/main/java/org/springframework/hateoas/TemplateVariable.java @@ -17,13 +17,14 @@ import static org.springframework.hateoas.TemplateVariable.VariableType.*; -import lombok.EqualsAndHashCode; -import lombok.Value; - import java.io.Serializable; import java.util.Arrays; import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Value; + import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -38,17 +39,30 @@ public final class TemplateVariable implements Serializable { private static final long serialVersionUID = -2731446749851863774L; - String name; - TemplateVariable.VariableType type; - String description; + /** + * The name of the variable. + */ + @NonNull String name; + + /** + * The type of the variable. + */ + @NonNull TemplateVariable.VariableType type; + + /** + * The description of the variable + */ + @NonNull String description; /** * Creates a new {@link TemplateVariable} with the given name and type. * * @param name must not be {@literal null} or empty. * @param type must not be {@literal null}. + * @deprecated use {@link #of(String, VariableType)} instead. */ - public TemplateVariable(String name, TemplateVariable.VariableType type) { + @Deprecated + public TemplateVariable(String name, VariableType type) { this(name, type, ""); } @@ -57,12 +71,14 @@ public TemplateVariable(String name, TemplateVariable.VariableType type) { * * @param name must not be {@literal null} or empty. * @param type must not be {@literal null}. - * @param description must not be {@literal null}. + * @param description can be {@literal null}. + * @deprecated use {@link #of(String, VariableType, String)} instead */ - public TemplateVariable(String name, TemplateVariable.VariableType type, String description) { + @Deprecated + public TemplateVariable(String name, VariableType type, String description) { - Assert.hasText(name, "Variable name must not be null or empty!"); - Assert.notNull(type, "Variable type must not be null!"); + Assert.hasText(name, "Name must not be null or empty!"); + Assert.notNull(type, "Type must not be null!"); Assert.notNull(description, "Description must not be null!"); this.name = name; @@ -70,6 +86,40 @@ public TemplateVariable(String name, TemplateVariable.VariableType type, String this.description = description; } + /** + * Creates a new {@link TemplateVariable} with the given name and type. + * + * @param name must not be {@literal null} or empty. + * @param type must not be {@literal null}. + */ + public static TemplateVariable of(String name, VariableType type) { + return of(name, type, ""); + } + + /** + * Creates a new {@link TemplateVariable} with the given name, type and description. + * + * @param name must not be {@literal null} or empty. + * @param type must not be {@literal null}. + * @param description can be {@literal null}. + */ + public static TemplateVariable of(String name, VariableType type, String description) { + return new TemplateVariable(name, type, description); + } + + /** + * Returns whether the {@link TemplateVariable} has the given name. + * + * @param name must not be {@literal null}. + * @return + */ + public boolean hasName(String name) { + + Assert.notNull(name, "Name must not be null!"); + + return this.name.equals(name); + } + /** * Returns whether the variable has a description. * diff --git a/src/main/java/org/springframework/hateoas/TemplateVariables.java b/src/main/java/org/springframework/hateoas/TemplateVariables.java index 7daeb4f93..fbe540074 100644 --- a/src/main/java/org/springframework/hateoas/TemplateVariables.java +++ b/src/main/java/org/springframework/hateoas/TemplateVariables.java @@ -17,8 +17,6 @@ import static org.springframework.hateoas.TemplateVariable.VariableType.*; -import lombok.EqualsAndHashCode; - import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; @@ -27,6 +25,8 @@ import java.util.Iterator; import java.util.List; +import lombok.EqualsAndHashCode; + import org.springframework.hateoas.TemplateVariable.VariableType; import org.springframework.util.Assert; @@ -48,7 +48,7 @@ public final class TemplateVariables implements Iterable, Seri * * @param variables must not be {@literal null}. */ - public TemplateVariables(TemplateVariable... variables) { + private TemplateVariables(TemplateVariable... variables) { this(Arrays.asList(variables)); } @@ -57,12 +57,30 @@ public TemplateVariables(TemplateVariable... variables) { * * @param variables must not be {@literal null}. */ - public TemplateVariables(List variables) { + private TemplateVariables(List variables) { Assert.notNull(variables, "Template variables must not be null!"); this.variables = Collections.unmodifiableList(variables); } + /** + * Creates a new {@link TemplateVariables} for the given {@link TemplateVariable}s. + * + * @param variables must not be {@literal null}. + */ + public static TemplateVariables of(TemplateVariable... variables) { + return new TemplateVariables(variables); + } + + /** + * Creates a new {@link TemplateVariables} for the given {@link TemplateVariable}s. + * + * @param variables must not be {@literal null}. + */ + public static TemplateVariables of(List variables) { + return new TemplateVariables(variables); + } + /** * Concatenates the given {@link TemplateVariable}s to the current one. * @@ -90,7 +108,7 @@ public TemplateVariables concat(Collection variables) { } } - return new TemplateVariables(result); + return TemplateVariables.of(result); } /** @@ -112,6 +130,49 @@ public List asList() { return this.variables; } + public TemplateVariables getRequiredVariables() { + + if (isEmpty()) { + return this; + } + + List result = new ArrayList(); + + for (TemplateVariable variable : this.variables) { + if (variable.isRequired()) { + result.add(variable); + } + } + + return TemplateVariables.of(result); + } + + public List getNames() { + + List names = new ArrayList(variables.size()); + + for (TemplateVariable variable : variables) { + names.add(variable.getName()); + } + + return Collections.unmodifiableList(names); + } + + public boolean isEmpty() { + return variables.isEmpty(); + } + + public boolean hasVariable(String name) { + + for (TemplateVariable variable : variables) { + if (variable.hasName(name)) { + return true; + } + } + + return false; + } + private boolean containsEquivalentFor(TemplateVariable candidate) { for (TemplateVariable variable : this.variables) { diff --git a/src/main/java/org/springframework/hateoas/UriTemplate.java b/src/main/java/org/springframework/hateoas/UriTemplate.java index c68429c3e..02ffc44e7 100644 --- a/src/main/java/org/springframework/hateoas/UriTemplate.java +++ b/src/main/java/org/springframework/hateoas/UriTemplate.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2015 the original author or authors. + * Copyright 2014-2016 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. @@ -16,88 +16,154 @@ package org.springframework.hateoas; import java.io.Serializable; +import java.io.UnsupportedEncodingException; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.Charset; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; +import lombok.EqualsAndHashCode; + import org.springframework.hateoas.TemplateVariable.VariableType; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.springmvc.AffordanceBuilder; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; /** - * Custom URI template to support qualified URI template variables. - * + * URI template with the ability to be partially expanded, no matter if its variables are required or not. Unsatisfied + * variables are kept as variables. Other implementations either remove all unsatisfied variables or fail when required + * variables are unsatisfied. This behavior is required due to the way an Affordance is created by + * {@link AffordanceBuilder}, see package info for an overview of affordance creation. + * * @author Oliver Gierke + * @author Dietrich Schulten * @see http://tools.ietf.org/html/rfc6570 + * @see org.springframework.hateoas.affordance.springmvc * @since 0.9 */ +@EqualsAndHashCode public class UriTemplate implements Iterable, Serializable { - private static final Pattern VARIABLE_REGEX = Pattern.compile("\\{([\\?\\&#/]?)([\\w\\,]+)\\}"); - private static final long serialVersionUID = -1007874653930162262L; + private static final Pattern VARIABLE_REGEX = Pattern.compile("\\{([\\?\\&#/]?)([\\w\\,\\.]+)(:??.*?)\\}"); + + private static final Object REMOVE_VARIABLE = new Object(); + + private static final long serialVersionUID = 3603502049431337211L; - private final TemplateVariables variables;; - private String baseUri; + private final List urlComponents = new ArrayList(); + + private final List> variableIndices = new ArrayList>(); + + private final TemplateVariables variables; /** * Creates a new {@link UriTemplate} using the given template string. - * + * * @param template must not be {@literal null} or empty. */ public UriTemplate(String template) { + this(template, TemplateVariables.NONE); + } + + public UriTemplate(String template, TemplateVariables additionals) { Assert.hasText(template, "Template must not be null or empty!"); Matcher matcher = VARIABLE_REGEX.matcher(template); - int baseUriEndIndex = template.length(); + // first group is the variable start without leading {: "", "/", "?", "#", + // second group is the comma-separated name list without the trailing } of the variable + int endOfPart = 0; + List variables = new ArrayList(); while (matcher.find()) { - int start = matcher.start(0); + // 0 is the current match, i.e. the entire variable expression + int startOfPart = matcher.start(0); + // add part before current match + if (endOfPart < startOfPart) { - VariableType type = VariableType.from(matcher.group(1)); - String[] names = matcher.group(2).split(","); + String partWithoutVariables = template.substring(endOfPart, startOfPart); + StringTokenizer stringTokenizer = new StringTokenizer(partWithoutVariables, "?", true); + boolean inQuery = false; - for (String name : names) { - TemplateVariable variable = new TemplateVariable(name, type); + while (stringTokenizer.hasMoreTokens()) { + + String token = stringTokenizer.nextToken(); - if (!variable.isRequired() && start < baseUriEndIndex) { - baseUriEndIndex = start; + if ("?".equals(token)) { + inQuery = true; + } else { + urlComponents.add(inQuery ? "?".concat(token) : token); + variableIndices.add(Collections.emptyList()); + } } + } + endOfPart = matcher.end(0); + + // add current match as part + urlComponents.add(template.substring(startOfPart, endOfPart)); + + // collect variablesInPart and track for each part which variables it contains + // group(1) is the variable head without the leading { + VariableType type = TemplateVariable.VariableType.from(matcher.group(1)); + + // group(2) are the variable names + String[] names = matcher.group(2).split(","); + List variablesInPart = new ArrayList(); + + for (String name : names) { + + TemplateVariable variable = TemplateVariable.of(name, type); + variablesInPart.add(variables.size()); variables.add(variable); } + + variableIndices.add(variablesInPart); } - this.variables = variables.isEmpty() ? TemplateVariables.NONE : new TemplateVariables(variables); - this.baseUri = template.substring(0, baseUriEndIndex); - } + // finish off remaining part + if (endOfPart < template.length()) { - /** - * Creates a new {@link UriTemplate} from the given base URI and {@link TemplateVariables}. - * - * @param baseUri must not be {@literal null} or empty. - * @param variables defaults to {@link TemplateVariables#NONE}. - */ - public UriTemplate(String baseUri, TemplateVariables variables) { + urlComponents.add(template.substring(endOfPart)); + variableIndices.add(Collections.emptyList()); + } + + TemplateVariables temp = TemplateVariables.of(variables); - Assert.hasText(baseUri, "Base URI must not be null or empty!"); + for (TemplateVariable additional : additionals) { - this.baseUri = baseUri; - this.variables = variables == null ? TemplateVariables.NONE : variables; + if (!temp.hasVariable(additional.getName())) { + + urlComponents.add(additional.toString()); + variableIndices.add(Collections.emptyList()); + variables.add(additional); + } + } + + this.variables = TemplateVariables.of(variables); + } + + private UriTemplate(UriTemplateComponents components, TemplateVariables additionals) { + this(components.toString(), additionals); } /** * Creates a new {@link UriTemplate} with the current {@link TemplateVariable}s augmented with the given ones. - * + * * @param variables can be {@literal null}. * @return will never be {@literal null}. */ @@ -107,7 +173,7 @@ public UriTemplate with(TemplateVariables variables) { return this; } - UriComponents components = UriComponentsBuilder.fromUriString(baseUri).build(); + UriComponents components = UriComponentsBuilder.fromUriString(urlComponents.get(0)).build(); List result = new ArrayList(); for (TemplateVariable variable : variables) { @@ -126,23 +192,23 @@ public UriTemplate with(TemplateVariables variables) { result.add(variable); } - return new UriTemplate(baseUri, this.variables.concat(result)); + return new UriTemplate(asComponents().toString(), TemplateVariables.of(result)); } /** * Creates a new {@link UriTemplate} with a {@link TemplateVariable} with the given name and type added. - * - * @param variableName must not be {@literal null} or empty. + * + * @param name must not be {@literal null} or empty. * @param type must not be {@literal null}. * @return will never be {@literal null}. */ - public UriTemplate with(String variableName, TemplateVariable.VariableType type) { - return with(new TemplateVariables(new TemplateVariable(variableName, type))); + public UriTemplate with(String name, VariableType type) { + return new UriTemplate(asComponents(), TemplateVariables.of(TemplateVariable.of(name, type))); } /** * Returns whether the given candidate is a URI template. - * + * * @param candidate * @return */ @@ -157,7 +223,7 @@ public static boolean isTemplate(String candidate) { /** * Returns the {@link TemplateVariable}s discovered. - * + * * @return */ public List getVariables() { @@ -166,69 +232,199 @@ public List getVariables() { /** * Returns the names of the variables discovered. - * + * * @return */ public List getVariableNames() { + return this.variables.getNames(); + } - List names = new ArrayList(); + /** + * Returns the template as uri components, without variable expansion. + * + * @return components of the Uri + */ + public UriTemplateComponents asComponents() { + return getUriTemplateComponents(Collections.emptyMap(), Collections.emptyList()); + } - for (TemplateVariable variable : variables) { - names.add(variable.getName()); + /** + * Expands the template using given parameters + * + * @param parameters for expansion in the order of appearance in the template, must not be empty + * @return expanded template + */ + public UriTemplate expand(Object... parameters) { + + List variableNames = getVariableNames(); + Map parameterMap = new LinkedHashMap(); + + /** + * Zip the {@link variableNames} with the {@link parameters}, into a {@link Map}. + */ + for (int i=0; i < parameters.length; i++) { + try { + parameterMap.put(variableNames.get(i), parameters[i]); + } catch (IndexOutOfBoundsException e) { + break; + } } - return names; + return new UriTemplate(getUriTemplateComponents(parameterMap, Collections.emptyList()), + TemplateVariables.NONE); } /** - * Expands the {@link UriTemplate} using the given parameters. The values will be applied in the order of the - * variables discovered. - * - * @param parameters - * @return - * @see #expand(Map) + * Expands the template using given parameters. In case all variables are resolved, the returned {@link UriTemplate} + * can be turned into a URI. + * + * @param parameters must not be {@literal null}. + * @return expanded template */ - public URI expand(Object... parameters) { + public UriTemplate expand(Map parameters) { - if (TemplateVariables.NONE.equals(variables)) { - return URI.create(baseUri); - } + Assert.notNull(parameters, "Parameters must not be null!"); - org.springframework.web.util.UriTemplate baseTemplate = new org.springframework.web.util.UriTemplate(baseUri); - UriComponentsBuilder builder = UriComponentsBuilder.fromUri(baseTemplate.expand(parameters)); - Iterator iterator = Arrays.asList(parameters).iterator(); + return new UriTemplate(getUriTemplateComponents(parameters, Collections.emptyList()), + TemplateVariables.NONE); + } - for (TemplateVariable variable : getOptionalVariables()) { + /** + * Applies parameters to template variables. + * + * @param parameters to apply to variables + * @param requiredArgs if not empty, retains given requiredArgs + * @return uri components + */ + private UriTemplateComponents getUriTemplateComponents(Map parameters, List requiredArgs) { + + Assert.notNull(parameters, "Parameters must not be null!"); + + StringBuilder baseUrl = new StringBuilder(urlComponents.get(0)); + StringBuilder queryHead = new StringBuilder(); + StringBuilder queryTail = new StringBuilder(); + StringBuilder fragmentIdentifier = new StringBuilder(); + + for (int i = 1; i < urlComponents.size(); i++) { + + String part = urlComponents.get(i); + List variablesInPart = variableIndices.get(i); + + if (variablesInPart.isEmpty()) { + + if (part.startsWith("?") || part.startsWith("&")) { + queryHead.append(part); + } else if (part.startsWith("#")) { + fragmentIdentifier.append(part); + } else { + baseUrl.append(part); + } + + } else { + + List variableList = variables.asList(); + + for (Integer variableInPart : variablesInPart) { + + TemplateVariable variable = variableList.get(variableInPart); + + Object value = parameters.get(variable.getName()); + + // Strip variable + if (value == REMOVE_VARIABLE) { + continue; + } + + // Keep variable + if (value == null) { + + switch (variable.getType()) { + + case REQUEST_PARAM: + case REQUEST_PARAM_CONTINUED: + if (requiredArgs.isEmpty() || requiredArgs.contains(variable.getName())) { + // query vars without value always go last (query tail) + if (queryTail.length() > 0) { + queryTail.append(','); + } + queryTail.append(variable.getName()); + } + break; + case FRAGMENT: + fragmentIdentifier.append(variable.toString()); + break; + default: + baseUrl.append(variable.toString()); + } + + continue; + + } else { + + // Replace variable with value + switch (variable.getType()) { + + case REQUEST_PARAM: + case REQUEST_PARAM_CONTINUED: + + if (queryHead.length() == 0) { + queryHead.append('?'); + } else { + queryHead.append('&'); + } + queryHead.append(variable.getName()).append('=').append(urlEncode(value.toString())); + break; + + case SEGMENT: + + baseUrl.append('/'); + // fall through + case PATH_VARIABLE: + + if (queryHead.length() != 0) { + // level 1 variable in query + queryHead.append(urlEncode(value.toString())); + } else { + baseUrl.append(urlEncode(value.toString())); + } + break; + + case FRAGMENT: - Object value = iterator.hasNext() ? iterator.next() : null; - appendToBuilder(builder, variable, value); + fragmentIdentifier.append('#'); + fragmentIdentifier.append(urlEncode(value.toString())); + break; + } + } + } + } } - return builder.build().toUri(); + return new UriTemplateComponents(baseUrl.toString(), queryHead.toString(), queryTail.toString(), + fragmentIdentifier.toString(), variables.getNames()); } /** - * Expands the {@link UriTemplate} using the given parameters. + * Turns the {@link UriTemplate} into a URI by expanding all remaining template variables with empty values. * - * @param parameters must not be {@literal null}. - * @return + * @return the URI represented by the {@link UriTemplate}. + * @throws IllegalStateException in case the template still contains required template variables. */ - public URI expand(Map parameters) { + public URI toUri() { - if (TemplateVariables.NONE.equals(variables)) { - return URI.create(baseUri); - } + TemplateVariables required = variables.getRequiredVariables(); - Assert.notNull(parameters, "Parameters must not be null!"); + if (!required.isEmpty()) { + throw new IllegalStateException("Required variables ".concat(required.toString()).concat(" were not expanded!")); + } - org.springframework.web.util.UriTemplate baseTemplate = new org.springframework.web.util.UriTemplate(baseUri); - UriComponentsBuilder builder = UriComponentsBuilder.fromUri(baseTemplate.expand(parameters)); + Map parameters = new HashMap(); - for (TemplateVariable variable : getOptionalVariables()) { - appendToBuilder(builder, variable, parameters.get(variable.getName())); + for (String name : variables.getNames()) { + parameters.put(name, REMOVE_VARIABLE); } - return builder.build().toUri(); + return URI.create(expand(parameters).asComponents().toString()); } /* @@ -237,66 +433,44 @@ public URI expand(Map parameters) { */ @Override public Iterator iterator() { - return this.variables.iterator(); + return variables.iterator(); } - /* + /* * (non-Javadoc) * @see java.lang.Object#toString() */ - @Override public String toString() { + return asComponents().toString(); + } - UriComponents components = UriComponentsBuilder.fromUriString(baseUri).build(); - boolean hasQueryParameters = !components.getQueryParams().isEmpty(); - - return baseUri + getOptionalVariables().toString(hasQueryParameters); + /** + * Strips all variables which are not required by any of the given action descriptors. If no action descriptors are + * given, nothing will be stripped. + * + * @param actionDescriptors to decide which variables are optional, may be empty + * @return partial uri template components without optional variables, if actionDescriptors was not empty + */ + public UriTemplateComponents stripOptionalVariables(List actionDescriptors) { + return getUriTemplateComponents(Collections.emptyMap(), getRequiredArgNames(actionDescriptors)); } - private TemplateVariables getOptionalVariables() { + private static String urlEncode(String s) { - List result = new ArrayList(); - - for (TemplateVariable variable : this) { - if (!variable.isRequired()) { - result.add(variable); - } + try { + return URLEncoder.encode(s, Charset.forName("UTF-8").toString()); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("failed to urlEncode " + s, e); } - - return new TemplateVariables(result); } - /** - * Appends the value for the given {@link TemplateVariable} to the given {@link UriComponentsBuilder}. - * - * @param builder must not be {@literal null}. - * @param variable must not be {@literal null}. - * @param value can be {@literal null}. - */ - private static void appendToBuilder(UriComponentsBuilder builder, TemplateVariable variable, Object value) { + private static List getRequiredArgNames(List actionDescriptors) { - if (value == null) { + List ret = new ArrayList(); - if (variable.isRequired()) { - throw new IllegalArgumentException(String.format("Template variable %s is required but no value was given!", - variable.getName())); - } - - return; - } - - switch (variable.getType()) { - case REQUEST_PARAM: - case REQUEST_PARAM_CONTINUED: - builder.queryParam(variable.getName(), value); - break; - case PATH_VARIABLE: - case SEGMENT: - builder.pathSegment(value.toString()); - break; - case FRAGMENT: - builder.fragment(value.toString()); - break; + for (ActionDescriptor actionDescriptor : actionDescriptors) { + ret.addAll(actionDescriptor.getRequiredParameters().keySet()); } + return ret; } } diff --git a/src/main/java/org/springframework/hateoas/UriTemplateComponents.java b/src/main/java/org/springframework/hateoas/UriTemplateComponents.java new file mode 100644 index 000000000..8fd8346be --- /dev/null +++ b/src/main/java/org/springframework/hateoas/UriTemplateComponents.java @@ -0,0 +1,106 @@ +/* + * Copyright 2014-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 java.util.List; + +import lombok.Value; + +import org.springframework.util.StringUtils; + +/** + * Represents components of a Uri Template with variables. Created by dschulten on 04.12.2014. + */ +@Value +public class UriTemplateComponents { + + /** + * May be relative or absolute, and may contain {xxx} or {/xxx} style variables. + */ + String baseUri; + + /** + * Start of query containing expanded key-value pairs (no variables), beginning with ?, may be empty. + */ + String queryHead; + + /** + * Comma-separated list of unexpanded query keys, may be empty. + */ + String queryTail; + + /** + * Beginning with #, may contain a fragment variable, may also be empty. + */ + String fragmentIdentifier; + + /** + * Names of template variables + */ + List variableNames; + + /** + * Returns whether the base URI is templated. + * + * @return + */ + public boolean isBaseUriTemplated() { + return baseUri.matches(".*\\{.+\\}.*"); + } + + /** + * Query consisting of expanded parameters and unexpanded parameters. + * + * @return query, may be empty + */ + public String getQuery() { + + StringBuilder query = new StringBuilder(); + + if (queryTail.length() > 0) { + + if (queryHead.length() == 0) { + query.append("{?").append(queryTail).append("}"); + } else if (queryHead.length() > 0) { + query.append(queryHead).append("{&").append(queryTail).append("}"); + } + + } else { + query.append(queryHead); + } + + return query.toString(); + } + + /** + * Returns whether the components contain a variable. + * + * @return + */ + public boolean hasVariables() { + return baseUri.contains("{") || !StringUtils.isEmpty(queryTail) || fragmentIdentifier.contains("{"); + } + + /** + * Concatenates all components to uri String. + * + * @return uri String + */ + public String toString() { + return baseUri + getQuery() + fragmentIdentifier; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/ActionDescriptor.java b/src/main/java/org/springframework/hateoas/affordance/ActionDescriptor.java new file mode 100644 index 000000000..8619747d0 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/ActionDescriptor.java @@ -0,0 +1,153 @@ +/* + * Copyright 2013-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.affordance; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.affordance.formaction.Cardinality; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +/** + * Represents a descriptor for a http method execution. + * + * @author Dietrich Schulten + */ +public interface ActionDescriptor { + + /** + * Gets action name. Could be used as form name. + * + * @return name + */ + String getActionName(); + + /** + * Gets method on uniform interface. + * + * @return method + */ + HttpMethod getHttpMethod(); + + /** + * Gets contentType consumed by the action + * + * TODO: This is Spring MVC specific. + * + * @return + */ + List getConsumes(); + + /** + * Gets contentType produced by the action + * + * TODO: This is Spring MVC specific. + * + * @return + */ + List getProduces(); + + /** + * Gets names of path variables, if URL has variables. + * + * TODO: Possibly Spring MVC specific + * + * @return names or empty collection + */ + Collection getPathVariableNames(); + + /** + * Gets names of expected request headers, if any. + * + * @return names or empty collection + */ + Collection getRequestHeaderNames(); + + /** + * Gets names of expected request parameters, if any. + * + * @return names or empty collection + */ + Collection getRequestParamNames(); + + + /** + * Gets all action input parameter names. + * + * @return + */ + Collection getActionInputParameterNames(); + + /** + * Get all of the {@link ActionInputParameter}s. + * + * @return + */ + Collection getActionInputParameters(); + + /** + * Gets action parameter by name. + * + * @param name + * @return parameter + */ + ActionInputParameter getActionInputParameter(String name); + + /** + * Request body descriptor, if the action expects a complex request body. + * + * @return request body parameter + */ + ActionInputParameter getRequestBody(); + + /** + * Does the action expect a complex request body? + * + * @return true if applicable + */ + boolean hasRequestBody(); + + /** + * Gets well-defined semantic action type, e.g. http://schema.org/Action subtype. + * + * @return semantic action type + */ + String getSemanticActionType(); + + /** + * Gets required parameters. + * + * @return required parameters, may be empty + */ + Map getRequiredParameters(); + + /** + * Hints if the action response is a single object or a collection. + * + * @return cardinality + */ + Cardinality getCardinality(); + + /** + * Visits the body to find parameters + * + * @param visitor + */ + void accept(ActionInputParameterVisitor visitor); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/ActionInputParameter.java b/src/main/java/org/springframework/hateoas/affordance/ActionInputParameter.java new file mode 100644 index 000000000..b07c7953c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/ActionInputParameter.java @@ -0,0 +1,227 @@ +/* + * Copyright 2013-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.affordance; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.affordance.formaction.Type; + +/** + * Interface to represent an input parameter to a resource handler method, independent of a particular ReST framework. + * + * @author Dietrich Schulten + */ +public interface ActionInputParameter { + + String MIN = "min"; + String MAX = "max"; + String STEP = "step"; + String MIN_LENGTH = "minLength"; + String MAX_LENGTH = "maxLength"; + String PATTERN = "pattern"; + String READONLY = "readonly"; + String EDITABLE = "editable"; + String REQUIRED = "required"; + + /** + * Raw field value, without conversion. + * + * @return value + */ + Object getValue(); + + /** + * Formatted field value to be used as preset value (e.g. using ConversionService). + * + * @return formatted value + */ + String getValueFormatted(); + + /** + * Type of parameter when used in html-like contexts (e.g. Siren, Uber, XHtml) + * + * @return type + */ + Type getHtmlInputFieldType(); + + /** + * Set the type of parameter when used in html-like contexts (e.g. Siren, Uber, XHtml) + * + * @return type + */ + void setHtmlInputFieldType(Type type); + + /** + * Parameter is a complex body. + * + * @return true if applicable + */ + boolean isRequestBody(); + + /** + * Parameter is a request header. + * + * @return true if applicable + */ + boolean isRequestHeader(); + + /** + * Parameter is a query parameter. + * + * @return true if applicable + */ + boolean isRequestParam(); + + /** + * Parameter is a path variable. + * + * @return true if applicable + */ + boolean isPathVariable(); + + /** + * Gets request header name. + * + * @return name + */ + String getRequestHeaderName(); + + /** + * Parameter has input constraints (like range, step etc.) + * + * @return true for input constraints + * @see #getInputConstraints() + */ + boolean hasInputConstraints(); + + /** + * If the action input parameter is annotation-based, provide access to annotation + * + * @param annotation to look for + * @param type of annotation + * @return annotation or null + */ + T getAnnotation(Class annotation); + + /** + * Gets possible values for this parameter. + * + * @param actionDescriptor in case that access to the other parameters is necessary to determine the possible values. + * @return possible values or empty array + */ + List> getPossibleValues(ActionDescriptor actionDescriptor); + + Suggestions getSuggestions(); + + /** + * Establish possible values for this parameter + * + * @param possibleValues + */ + void setPossibleValues(List> possibleValues); + + /** + * Retrieve the suggest type + * + * @return + */ + SuggestType getSuggestType(); + + /** + * Sets the suggest type + * + * @param type + */ + void setSuggestType(SuggestType type); + + /** + * Parameter is an array or collection, think {?val*} in uri template. + * + * @return true for collection or array + */ + boolean isArrayOrCollection(); + + /** + * Is this action input parameter required, bTPERased on the presence of a default value, the parameter annotations + * and the kind of input parameter. + * + * @return true if required + */ + boolean isRequired(); + + /** + * If parameter is an array or collection, the default values. + * + * @return values + * @see #isArrayOrCollection() + */ + Object[] getValues(); + + /** + * Does the parameter have a value? + * + * @return true if a value is present + */ + boolean hasValue(); + + /** + * Name of parameter. + * + * @return + */ + String getParameterName(); + + /** + * Type of parameter. + * + * @return + */ + Class getParameterType(); + + /** + * Generic type of parameter. + * + * @return generic type + */ + java.lang.reflect.Type getGenericParameterType(); + + /** + * Gets input constraints. + * + * @return constraints where the key is one of {@link ActionInputParameter#MAX} etc. and the value is a string or + * number, depending on the input constraint. + * @see ActionInputParameter#MAX + * @see ActionInputParameter#MIN + * @see ActionInputParameter#MAX_LENGTH + * @see ActionInputParameter#MIN_LENGTH + * @see ActionInputParameter#STEP + * @see ActionInputParameter#PATTERN + * @see ActionInputParameter#READONLY + */ + Map getInputConstraints(); + + String getName(); + + void setReadOnly(boolean readOnly); + + void setRequired(boolean required); + + ParameterType getType(); + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/ActionInputParameterVisitor.java b/src/main/java/org/springframework/hateoas/affordance/ActionInputParameterVisitor.java new file mode 100644 index 000000000..6445cc29d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/ActionInputParameterVisitor.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013-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.affordance; + +/** + * + * @author Dietrich Schulten + */ +public interface ActionInputParameterVisitor { + + void visit(ActionInputParameter inputParameter); + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/Affordance.java b/src/main/java/org/springframework/hateoas/affordance/Affordance.java new file mode 100644 index 000000000..554f558a0 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/Affordance.java @@ -0,0 +1,562 @@ +/* + * Copyright 2013-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.affordance; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.UriTemplate; +import org.springframework.hateoas.UriTemplateComponents; +import org.springframework.hateoas.affordance.formaction.Cardinality; +import org.springframework.hateoas.affordance.springmvc.AffordanceBuilder; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * Represents an http affordance for purposes of a REST service as described by + * Web Linking RFC-5988. Additionally includes {@link ActionDescriptor} + * s for http methods and expected request bodies. + *

+ * Also supports templated affordances, in which case it is represented as a + * Link-Template Header + *

+ *

+ * This class can be created manually or via one of the {@link AffordanceBuilder#linkTo} + * methods. In the latter case the affordance should be created with pre-expanded variables (using + * {@link UriTemplate#expand} on the given uri template). In the former case one may use {@link #expandPartially} to + * expand the Affordance variables as far as possible, while keeping unsatisified variables. + *

+ * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class Affordance extends Link { + + private static final long serialVersionUID = 3256465945939099914L; + + private final boolean hasSelfRel; + + private final List actionDescriptors; + + private final MultiValueMap linkParams = new LinkedMultiValueMap(); + + private final UriTemplate partialUriTemplate; + + private final Cardinality cardinality; + + private final TypedResource collectionHolder; + + /** + * Creates an affordance. Rels, action descriptors and link header params may be added later. + * + * @param uriTemplate uri or uritemplate of the affordance + */ + public Affordance(String uriTemplate) { + this(uriTemplate, new String[0]); + } + + /** + * Creates an affordance. Action descriptors and link header params may be added later. + * + * @param uriTemplate uri or uritemplate of the affordance + * @param rels describing the link relation type + */ + public Affordance(String uriTemplate, String... rels) { + this(new UriTemplate(uriTemplate), new ArrayList(), null, rels); + } + + /** + * Creates affordance, usually for a pre-expanded uriTemplate. Link header params may be added later. Optional + * variables will be stripped before passing it to the underlying link. Use {@link #getUriTemplateComponents()} to + * access the base uri, query head, query tail with optional variables etc. + * Since AffordanceBuilder creates variables for undefined arguments, + * we would get a link-template where ControllerLinkBuilder only sees a link. + * For compatibility we strip variables deemed to be not required by the actionDescriptors before passing on + * the template to the underlying Link. That way the href of an Affordance stays compatible with a Link that + * has been created with ControllerLinkBuilder. Only serializers that make use of Affordance will see the + * optional variables, too. + * They can access the base uri, query etc. via getUriTemplateComponents. + * + * @param uriTemplate pre-expanded uri or uritemplate of the affordance + * @param actionDescriptors describing the possible http methods on the affordance + * @param rels describing the link relation type + * @see UriTemplate#expand + */ + public Affordance(UriTemplate uriTemplate, List actionDescriptors, TypedResource typedResource, + String... rels) { + + super(uriTemplate.stripOptionalVariables(actionDescriptors).toString()); + + Assert.noNullElements(rels, "null rels are not allowed"); + + this.partialUriTemplate = uriTemplate; + this.actionDescriptors = actionDescriptors; + this.collectionHolder = typedResource; + + for (String rel : rels) { + addRel(rel); + } + + this.hasSelfRel = StringUtils.arrayToCommaDelimitedString(rels).contains(Link.REL_SELF); + + boolean collectionFound = false; + + // if any action refers to a collection resource, make the affordance a collection affordance + for (ActionDescriptor actionDescriptor : actionDescriptors) { + if (Cardinality.COLLECTION == actionDescriptor.getCardinality()) { + collectionFound = true; + break; + } + } + + this.cardinality = collectionFound ? Cardinality.COLLECTION : Cardinality.SINGLE; + + } + + private Affordance(String uriTemplate, MultiValueMap linkParams, + List actionDescriptors) { + + this(new UriTemplate(uriTemplate), actionDescriptors, null); // no rels to pass + this.linkParams.putAll(linkParams); // takes care of rels + } + + /** + * The relation type of the link. + * + * @param rel - name of the link + */ + public void addRel(String rel) { + + Assert.hasLength(rel, "rel must have content!"); + linkParams.add("rel", rel); + } + + /** + * The "type" parameter, when present, is a hint indicating what the media type of the result of dereferencing the + * link should be. + * + * Note that this is only a hint; for example, it does not override the Content-Type header of a HTTP + * response obtained by actually following the link. There MUST NOT be more than one type parameter in a link-value. + * + * @param mediaType to set + */ + public void setType(String mediaType) { + + if (mediaType != null) { + linkParams.set("type", mediaType); + } else { + linkParams.remove("type"); + } + } + + /** + * The "hreflang" parameter, when present, is a hint indicating what the language of the result of dereferencing the + * link should be. + * + * Note that this is only a hint; for example, it does not override the Content-Language header of a + * HTTP response obtained by actually following the link. Multiple "hreflang" parameters on a single link- value + * indicate that multiple languages are available from the indicated resource. + * + * @param hreflang to add + */ + public void addHreflang(String hreflang) { + + Assert.hasLength(hreflang, "hreflang must have content!"); + linkParams.add("hreflang", hreflang); + } + + /** + * The "title" parameter, when present, is used to label the destination of a link such that it can be used as a + * human-readable identifier (e.g., a menu entry) in the language indicated by the Content- Language header (if + * present). The "title" parameter MUST NOT appear more than once in a given link-value; occurrences after the first + * MUST be ignored by parsers. + * + * @param title to set + */ + public void setTitle(String title) { + + if (title != null) { + linkParams.set("title", title); + } else { + linkParams.remove("title"); + } + } + + @JsonIgnore + public boolean isBaseUriTemplated() { + return partialUriTemplate.asComponents().isBaseUriTemplated(); + } + + /** + * Gets the 'title' link parameter + * + * @return title of link + */ + @JsonInclude(Include.NON_NULL) + public String getTitle() { + return linkParams.getFirst("title"); + } + + /** + * The "title*" parameter can be used to encode this label in a different character set, and/or contain language + * information as per [RFC5987]. The "title*" parameter MUST NOT appear more than once in a given link-value; + * occurrences after the first MUST be ignored by parsers. If the parameter does not contain language information, its + * language is indicated by the Content-Language header (when present). + * + * @param titleStar to set + * @see https://tools.ietf.org/html/rfc5987 + */ + public void setTitleStar(String titleStar) { + + if (titleStar != null) { + linkParams.set("title*", titleStar); + } else { + linkParams.remove("title*"); + } + } + + /** + * The "media" parameter, when present, is used to indicate intended destination medium or media for style information + * (see [W3C.REC-html401-19991224], Section 6.13). + * + * Note that this may be updated by [W3C.CR-css3-mediaqueries-20090915]). Its value MUST be quoted if it contains + * a semicolon (";") or comma (","), and there MUST NOT be more than one "media" parameter in a link-value. + * + * @param mediaDesc to set + */ + public void setMedia(String mediaDesc) { + + if (mediaDesc != null) { + linkParams.set("media", mediaDesc); + } else { + linkParams.remove("media"); + } + } + + /** + * The "rev" parameter has been used in the past to indicate that the semantics of the relationship are in the reverse + * direction. That is, a link from A to B with REL="X" expresses the same relationship as a link from B to A with + * REV="X". "rev" is deprecated by this specification because it often confuses authors and readers; in most cases, + * using a separate relation type is preferable. + * + * @param rev to add + */ + public void addRev(String rev) { + + Assert.hasLength(rev, "rev must have content!"); + linkParams.add("rev", rev); + } + + /** + * By default, the context of a link conveyed in the Link header field is the IRI of the requested resource. When + * present, the anchor parameter overrides this with another URI, such as a fragment of this resource, or a third + * resource (i.e., when the anchor value is an absolute URI). If the anchor parameter's value is a relative URI, + * parsers MUST resolve it as per [RFC3986], Section 5. Note that any base URI from the body's content is not applied. + * + * @param anchor base uri to define + * @see https://tools.ietf.org/html/rfc3986 + */ + public void setAnchor(String anchor) { + + if (anchor != null) { + linkParams.set("anchor", anchor); + } else { + linkParams.remove("anchor"); + } + } + + /** + * Adds link-extension params, i.e. custom params which are not described in the web linking rfc. + * + * @param paramName of link-extension + * @param values one or more values to add + */ + public void addLinkParam(String paramName, String... values) { + + Assert.hasLength(paramName, "param must have content!"); + Assert.notEmpty(values, "values must not be empty!"); + for (String value : values) { + Assert.hasLength(value, "value must have content!"); + linkParams.add(paramName, value); + } + } + + /** + * Gets header name of the affordance, either Link or Link-Template depending on the presence of template variables. + * + * @return header name + * @see Web Linking rfc-5988 + * @see Link-Template Header + */ + @JsonIgnore + public String getHeaderName() { + + if (isTemplated()) { + return "Link-Template"; + } else { + return "Link"; + } + } + + /** + * Affordance represented as http link header value. + * + * TODO: With Java 8, this entire function can be rewritten much more elegantly. + * + * @return link header value(s) + */ + public String asHeader() { + + String result = ""; + + for (Map.Entry> linkParamEntry : linkParams.entrySet()) { + + if (result.length() != 0) { + result += "; "; + } + + if ("rel".equals(linkParamEntry.getKey()) || "rev".equals(linkParamEntry.getKey())) { + result += entry(linkParamEntry.getKey(), StringUtils.collectionToDelimitedString(linkParamEntry.getValue(), " ")); + } else { + String linkParams = ""; + for (String value : linkParamEntry.getValue()) { + if (linkParams.length() != 0) { + linkParams += "; "; + } + linkParams += entry(linkParamEntry.getKey(), value); + } + result += linkParams; + } + } + + return "<" + partialUriTemplate.asComponents().toString() + ">; " + result; + } + + /** + * Format a key/value entry into 'header' style. + * + * @param key + * @param value + * @return + */ + private String entry(String key, String value) { + return key + "=\"" + value + "\""; + } + + @Override + public String toString() { + return getHeaderName() + ": " + asHeader(); + } + + @Override + public Affordance withRel(String rel) { + + linkParams.set("rel", rel); + return new Affordance(getHref(), linkParams, actionDescriptors); + } + + @Override + public Affordance withSelfRel() { + + if (!linkParams.get("rel").contains(Link.REL_SELF)) { + linkParams.add("rel", Link.REL_SELF); + } + return new Affordance(getHref(), linkParams, actionDescriptors); + } + + /** + * Expands template variables. Arguments must satisfy all required template variables, optional variables will be + * removed. + * + * @param arguments to expansion in the order they appear in the template + * @return expanded affordance + */ + @Override + public Affordance expand(Object... arguments) { + + UriTemplate template = new UriTemplate(partialUriTemplate.asComponents().toString()); + String expanded = template.expand(arguments).toString(); + return new Affordance(expanded, linkParams, actionDescriptors); + } + + /** + * Expands template variables. Arguments must satisfy all required template variables, unsatisfied optional arguments + * will be removed. + * + * @param arguments to expansion + * @return expanded affordance + */ + @Override + public Affordance expand(Map arguments) { + + UriTemplate template = new UriTemplate(partialUriTemplate.asComponents().toString()); + String expanded = template.expand(arguments).toString(); + return new Affordance(expanded, linkParams, actionDescriptors); + } + + /** + * Expands template variables as far as possible, unsatisfied variables will remain variables. This is primarily for + * manually created affordances. If the Affordance has been created with linkTo-methodOn, it should not be necessary + * to expand the affordance again. + * + * @param arguments for expansion, in the order they appear in the template + * @return partially expanded affordance + */ + public Affordance expandPartially(Object... arguments) { + return new Affordance(partialUriTemplate.expand(arguments).toString(), linkParams, actionDescriptors); + } + + /** + * Expands template variables as far as possible, unsatisfied variables will remain variables. This is primarily for + * manually created affordances. If the Affordance has been created with linkTo-methodOn, it should not be necessary + * to expand the affordance again. + * + * @param arguments for expansion + * @return partially expanded affordance + */ + public Affordance expandPartially(Map arguments) { + return new Affordance(partialUriTemplate.expand(arguments).toString(), linkParams, + actionDescriptors); + } + + /** + * Gets parts of the uri template such as base uri, expanded query part, unexpanded query part etc. + * + * @return template component parts + */ + @JsonIgnore + public UriTemplateComponents getUriTemplateComponents() { + return partialUriTemplate.asComponents(); + } + + /** + * Retrieve all rels defined for this affordance. + * + * @return rels + */ + @JsonIgnore + public List getRels() { + + List rels = linkParams.get("rel"); + return rels == null ? Collections. emptyList() : Collections.unmodifiableList(rels); + } + + /** + * Gets the rel. + * + * @return first defined rel or null + */ + @Override + public String getRel() { + return linkParams.getFirst("rel"); + } + + /** + * Retrieves all revs for this affordance. + * + * @return revs + */ + @JsonIgnore + public List getRevs() { + + List revs = linkParams.get("rev"); + return revs == null ? Collections. emptyList() : Collections.unmodifiableList(revs); + } + + /** + * Gets the rev. + * + * @return first defined rev or null + */ + @JsonIgnore + public String getRev() { + return this.linkParams.getFirst("rev"); + } + + /** + * Gets action descriptors. + * + * @return descriptors, never null + */ + @JsonIgnore + public List getActionDescriptors() { + return Collections.unmodifiableList(this.actionDescriptors); + } + + /** + * Determines if the affordance points to a single or a collection resource. + * + * @return single or collection cardinality, never null + */ + @JsonIgnore + public Cardinality getCardinality() { + return this.cardinality; + } + + /** + * Determines if the affordance is a self rel. + * + * @return true if the affordance is a self rel + */ + @JsonIgnore + public boolean isSelfRel() { + return this.hasSelfRel; + } + + /** + * Determines if the affordance has unsatisfied required variables. This decides if the affordance can also + * be treated as a plain Link without template variables if the caller omits all optional variables. Serializers can + * use this to render it as a resource with optional search features. + * + * @return true if the affordance has unsatisfied required variables + */ + @JsonIgnore + public boolean hasUnsatisfiedRequiredVariables() { + + for (ActionDescriptor actionDescriptor : actionDescriptors) { + for (ActionInputParameter annotatedParameter : actionDescriptor.getRequiredParameters().values()) { + if (!annotatedParameter.hasValue()) { + return true; + } + } + } + return false; + } + + /** + * Gets collection holder. If an affordance points to a collection, there are cases where the resource that has the + * affordance is not semantically holding the collection items, but just has a loose relationship to the + * collection. E.g. a product "has" no orderedItems, but it may have a loose relationship to a collection of ordered + * items where the product can be POSTed to. The thing that semantically holds ordered items is an order, not + * a product. Hence the order would be the collection holder. + * + * @return collection holder + */ + @JsonIgnore + public TypedResource getCollectionHolder() { + return collectionHolder; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/ParameterType.java b/src/main/java/org/springframework/hateoas/affordance/ParameterType.java new file mode 100644 index 000000000..dc04f46b2 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/ParameterType.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013-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.affordance; + +/** + * @author Dietrich Schulten + */ +public enum ParameterType { + + INPUT, SELECT, UNKNOWN + +} \ No newline at end of file diff --git a/src/main/java/org/springframework/hateoas/affordance/Select.java b/src/main/java/org/springframework/hateoas/affordance/Select.java new file mode 100644 index 000000000..d13d890aa --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/Select.java @@ -0,0 +1,50 @@ +/* + * Copyright 2013-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.affordance; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to define the possible values for a field in a type describing payloads or an individual request + * parameter, path variable or header. + * + * @author Oliver Gierke + * @author Dietrich Schulten + */ +@Documented +@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Select { + + /** + * The static values that can be selected from. + * + * @return + */ + String[] value() default {}; + + /** + * An implementation class of {@link SuggestionsProvider} to dynamically calculate suggestions. + * + * @return + */ + Class provider() default SuggestionsProvider.class; +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SimpleSuggest.java b/src/main/java/org/springframework/hateoas/affordance/SimpleSuggest.java new file mode 100644 index 000000000..f07a01970 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SimpleSuggest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-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.affordance; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Dietrich Schulten + * @author Greg Turnquist + * @param + */ +public class SimpleSuggest extends SuggestImpl> { + + private SimpleSuggest(SuggestObjectWrapper wrapper) { + super(wrapper, SuggestObjectWrapper.ID, SuggestObjectWrapper.TEXT); + } + + /** + * Transfrom an array of objects into a collection of {@link Suggest}ions. + * + * @param values + * @param + * @return + */ + public static List>> wrap(T[] values) { + + List>> suggests = new ArrayList>>(values.length); + + for (T value : values) { + suggests.add(new SimpleSuggest( + new SuggestObjectWrapper(String.valueOf(value), String.valueOf(value), value))); + } + + return suggests; + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/Suggest.java b/src/main/java/org/springframework/hateoas/affordance/Suggest.java new file mode 100644 index 000000000..8b9847ab0 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/Suggest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2013-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.affordance; + +/** + * Define the "value" and "text" fields of an object + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public interface Suggest { + + /** + * Value to show, it may be a wrapped value of the original object in which we have a valid textField and valueField + * + * @return + */ + T getValue(); + + /** + * Returns the original value, it may be equals to getValue() or not (i.e. Enums), in this case the real value is + * returned + * + * @return + */ + U getUnwrappedValue(); + + /** + * String representation of the valueField inside value object + * + * @return + */ + String getValueAsString(); + + /** + * Value field name + * + * @return + */ + String getValueField(); + + /** + * Text field name + * + * @return + */ + String getTextField(); + + /** + * String representation of the textField inside value object + * + * @return + */ + String getText(); + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SuggestImpl.java b/src/main/java/org/springframework/hateoas/affordance/SuggestImpl.java new file mode 100644 index 000000000..40a504486 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SuggestImpl.java @@ -0,0 +1,116 @@ +/* + * Copyright 2013-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.affordance; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.ReflectionUtils; + +/** + * @author Dietrich Schulten + * @author Greg Turnquist + * @param + */ +public class SuggestImpl implements Suggest { + + private final T value; + private final String valueField; + private final String textField; + + public SuggestImpl(T value, String valueField, String textField) { + + this.value = value; + this.valueField = valueField; + this.textField = textField; + } + + @Override + public T getValue() { + return value; + } + + @Override + public String getValueField() { + return valueField; + } + + @Override + public String getTextField() { + return textField; + } + + @Override + public String getText() { + + if (value != null) { + try { + return getField(textField); + } catch (Exception e) { + throw new IllegalArgumentException("Textfield could not be serialized", e); + } + } + return null; + } + + @Override + public String getValueAsString() { + + if (value != null) { + try { + if (valueField != null) { + return getField(valueField); + } else { + return value.toString(); + } + } catch (Exception e) { + throw new IllegalArgumentException("Valuefield could not be serialized", e); + } + } + return null; + } + + public static List> wrap(List list, String valueField, String textField) { + + List> suggests = new ArrayList>(list.size()); + + for (T value : list) { + suggests.add(new SuggestImpl(value, valueField, textField)); + } + + return suggests; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Override + public U getUnwrappedValue() { + + if (value instanceof WrappedValue) { + return (U) ((WrappedValue) value).getValue(); + } + return (U) value; + } + + private String getField(String name) throws IllegalAccessException { + + Field field = ReflectionUtils.findField(value.getClass(), name); + field.setAccessible(true); + return String.valueOf(field.get(value)); + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SuggestObjectWrapper.java b/src/main/java/org/springframework/hateoas/affordance/SuggestObjectWrapper.java new file mode 100644 index 000000000..67f7301ea --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SuggestObjectWrapper.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-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.affordance; + +import static org.springframework.hateoas.affordance.support.Path.*; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author Dietrich Schulten + * @author Greg Turnquist + * @param + */ +@Data +@EqualsAndHashCode(of = {"text", "suggestionValue"}) +public class SuggestObjectWrapper implements WrappedValue { + + public static final String ID = path(on(SuggestObjectWrapper.class).getSuggestionValue()); + public static final String TEXT = path(on(SuggestObjectWrapper.class).getText()); + + private final String text; + private final String suggestionValue; + private final T original; + + public SuggestObjectWrapper(String text, String suggestionValue, T original) { + + this.text = text; + this.suggestionValue = suggestionValue; + this.original = original; + } + + @Override + public T getValue() { + return original; + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SuggestType.java b/src/main/java/org/springframework/hateoas/affordance/SuggestType.java new file mode 100644 index 000000000..a91ca9c8b --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SuggestType.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-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.affordance; + +/** + * @author Dietrich Schulten + */ +public enum SuggestType { + + /** + * Values are serialized as a list + */ + INTERNAL, + + /** + * Values are known in the client because they were previously sent somehow + */ + EXTERNAL, + + /** + * Values show be retrieved from a remote URL + */ + REMOTE +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SuggestionVisitor.java b/src/main/java/org/springframework/hateoas/affordance/SuggestionVisitor.java new file mode 100644 index 000000000..5b860052a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SuggestionVisitor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-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.affordance; + +import org.springframework.hateoas.affordance.Suggestions.ExternalSuggestions; +import org.springframework.hateoas.affordance.Suggestions.RemoteSuggestions; +import org.springframework.hateoas.affordance.Suggestions.ValueSuggestions; + +/** + * @author Oliver Gierke + * @author Greg Turnquist + */ +public interface SuggestionVisitor { + + T visit(ValueSuggestions options); + + T visit(ExternalSuggestions options); + + T visit(RemoteSuggestions options); + + T visit(Suggestions options); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/Suggestions.java b/src/main/java/org/springframework/hateoas/affordance/Suggestions.java new file mode 100644 index 000000000..1407920ee --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/Suggestions.java @@ -0,0 +1,207 @@ +/* + * Copyright 2013-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.affordance; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Wither; + +import org.springframework.hateoas.Link; +import org.springframework.hateoas.UriTemplate; + +/** + * @author Oliver Gierke + */ +public abstract class Suggestions { + + public static Suggestions NONE = new EmptySuggestions(); + + /** + * Returns the name of the field that is supposed to be used as human-readable value to symbolize the suggestion. + * + * @return the field name. Can be {@literal null}. + */ + public abstract String getPromptField(); + + /** + * Returns the name of the field that us supposed to be sent back to the server to identify the suggestion. + * + * @return the field name. Can be {@literal null}. + */ + public abstract String getValueField(); + + /** + * Invokes the given {@link SuggestionVisitor} for the {@link Suggestions}. Allows to act on the different suggestion + * types explicitly. + * + * @param handler must not be {@literal null}. + * @return + */ + public abstract T accept(SuggestionVisitor handler); + + /** + * Returns {@link ValueSuggestions} for the given values. + * + * @param values + * @return + */ + public static ValueSuggestions values(S... values) { + return ValueSuggestions.of(values); + } + + public static ValueSuggestions values(Collection values) { + return new ValueSuggestions(values, null, null); + } + + public static CustomizableSuggestions external(String reference) { + return ExternalSuggestions.of(reference); + } + + public static CustomizableSuggestions remote(UriTemplate template) { + return RemoteSuggestions.of(template); + } + + public static CustomizableSuggestions remote(Link link) { + return remote(link.getHref()); + } + + public static CustomizableSuggestions remote(String template) { + return remote(new UriTemplate(template)); + } + + public static abstract class CustomizableSuggestions extends Suggestions { + + public abstract CustomizableSuggestions withPromptField(String field); + + public abstract CustomizableSuggestions withValueField(String field); + } + + /** + * Suggestions that consist of a static set of values. + * + * @author Oliver Gierke + */ + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @EqualsAndHashCode + public static class ValueSuggestions extends CustomizableSuggestions implements Iterable { + + private final Collection suggestions; + private final @Wither String promptField; + private final @Wither String valueField; + + static ValueSuggestions of(T... values) { + return new ValueSuggestions(Arrays.asList(values), null, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.affordance.Options#accept(org.springframework.hateoas.affordance.SuggestHandler) + */ + @Override + public S accept(SuggestionVisitor handler) { + return handler.visit(this); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return suggestions.iterator(); + } + } + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @EqualsAndHashCode + public static class ExternalSuggestions extends CustomizableSuggestions { + + private final String reference; + private final @Wither String promptField; + private final @Wither String valueField; + + static ExternalSuggestions of(String reference) { + return new ExternalSuggestions(reference, null, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.affordance.Options#accept(org.springframework.hateoas.affordance.SuggestHandler) + */ + @Override + public T accept(SuggestionVisitor handler) { + return handler.visit(this); + } + } + + @Getter + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @EqualsAndHashCode + public static class RemoteSuggestions extends CustomizableSuggestions { + + private final UriTemplate template; + private final @Wither String promptField; + private final @Wither String valueField; + + static RemoteSuggestions of(UriTemplate template) { + return new RemoteSuggestions(template, null, null); + } + + /** + * Invokes the given {@link SuggestionVisitor} for the {@link Suggestions}. Allows to act on the different suggestion + * types explicitly. + * + * @param handler must not be {@literal null}. + * @return + */ + @Override + public T accept(SuggestionVisitor handler) { + return handler.visit(this); + } + } + + private static class EmptySuggestions extends Suggestions { + + public String getPromptField() { + return null; + } + + public String getValueField() { + return null; + } + + /** + * Invokes the given {@link SuggestionVisitor} for the {@link Suggestions}. Allows to act on the different suggestion + * types explicitly. + * + * @param handler must not be {@literal null}. + * @return + */ + @Override + public T accept(SuggestionVisitor handler) { + return null; + } + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/SuggestionsProvider.java b/src/main/java/org/springframework/hateoas/affordance/SuggestionsProvider.java new file mode 100644 index 000000000..03e9875f1 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/SuggestionsProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright 2013-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.affordance; + +/** + * SPI to provide {@link Suggestions}. + * + * @author Oliver Gierke + */ +public interface SuggestionsProvider { + + /** + * Returns all suggestions to be presented. + * + * @return + */ + Suggestions getSuggestions(); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/TypedResource.java b/src/main/java/org/springframework/hateoas/affordance/TypedResource.java new file mode 100644 index 000000000..b938cc947 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/TypedResource.java @@ -0,0 +1,65 @@ +/* + * Copyright 2013-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.affordance; + +import org.springframework.util.Assert; + +/** + * Resource of a certain semantic type which may or may not be identifiable. + * + * @author Dietrich Schulten + */ +public class TypedResource { + + private String semanticType; + private String identifyingUri; + + /** + * Creates a resource whose semantic type is known, but which cannot be identified as an individual. + * + * @param semanticType semantic type of the resource as string, either as Uri or Curie or as type name within the default vocabulary. Example: Order in a context where the default vocabulary is http://schema.org/ + * @see Curie + */ + public TypedResource(String semanticType) { + + Assert.notNull(semanticType, "semanticType must be given"); + this.semanticType = semanticType; + } + + /** + * Creates identified resource of a semantic type. + * + * @param semanticType semantic type of the resource as string, either as Uri or Curie + * @param identifyingUri identifying an individual of the typed resource + * @see Curie + */ + public TypedResource(String semanticType, String identifyingUri) { + + Assert.notNull(semanticType, "semanticType must be given"); + Assert.notNull(identifyingUri, "identifyingUri must be given"); + this.semanticType = semanticType; + this.identifyingUri = identifyingUri; + } + + public String getSemanticType() { + return semanticType; + } + + public String getIdentifyingUri() { + return identifyingUri; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/WrappedValue.java b/src/main/java/org/springframework/hateoas/affordance/WrappedValue.java new file mode 100644 index 000000000..cf2b07f71 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/WrappedValue.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013-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.affordance; + +/** + * @author Dietrich Schulten + * @param + */ +public interface WrappedValue { + + T getValue(); + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Action.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Action.java new file mode 100644 index 000000000..4b23d970c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Action.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Allows to assign a semantic type such as a hydra Operation or an http://schema.org/Action + * subtype to a method. + * + * @author Dietrich Schulten + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Action { + + /** + * @return action type + */ + String value(); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Cardinality.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Cardinality.java new file mode 100644 index 000000000..492e8bb79 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Cardinality.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-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.affordance.formaction; + +/** + * Specifies cardinality. + * + * @author Dietrich Schulten + * + * @see ResourceHandler + */ +public enum Cardinality { + + /** + * Denotes a collection + */ + COLLECTION, + + /** + * Denotes a single item + */ + SINGLE +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/DTOParam.java b/src/main/java/org/springframework/hateoas/affordance/formaction/DTOParam.java new file mode 100644 index 000000000..d40fc9dec --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/DTOParam.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author Dietrich Schulten + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface DTOParam { + + String WILDCARD_LIST_MASK = "[*]"; + + /** + * Set the behavior of the object as wildcard. Its properties will be checked as editable values. + * + * @return + */ + boolean wildcard() default false; +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Input.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Input.java new file mode 100644 index 000000000..6094548b1 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Input.java @@ -0,0 +1,141 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Allows to define input characteristics of an input value. + * + * This is useful to specify possible value ranges as in + * @Input(min=0), and it can also be used to mark a method parameter as + * @Input(Type.HIDDEN) when used as a GET parameter for a form. + *

+ * Can also be used to specify input characteristics for bean properties if the input value is an object. + *

+ * + * @author Dietrich Schulten + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Input { + + /** + * Input type, to set the input type, e.g. hidden, password. With the default type FROM_JAVA the type will be number + * or text for scalar values (depending on the parameter type), and null for arrays, collections or beans. + * + * @return input type + */ + Type value() default Type.FROM_JAVA; + + int max() default Integer.MAX_VALUE; + + int min() default Integer.MIN_VALUE; + + int minLength() default Integer.MIN_VALUE; + + int maxLength() default Integer.MAX_VALUE; + + String pattern() default ""; + + int step() default 0; + + boolean required() default false; + + /** + * Entire parameter is not editable, refers both to single values and to all properties of a bean parameter. + * + * @return + */ + boolean editable() default true; + + /** + * Property names or dot-separated property paths of read-only properties on input bean. Allows to define expected + * input bean attributes with read-only values, so that a media type can render them as read-only attribute. This + * allows to use the same bean for input and output in different contexts. E.g. all product attributes should be + * editable when a new product is added, but not when an order is created which contains that product. Thus, if a POST + * expects an object Product with certain fixed values, you can annotate the POST handler: + * + *
+	 *     public void makeOrder(@Input(readOnly={"productID"}) Product orderedProduct} {...}
+	 * 
+ * + * Typically, a readOnly attribute should have a predefined value. Defining a readOnly property effectively makes that + * property an {@link #include} property, i.e. other attributes are ignored by default. + * + * @return property paths which should be shown as read-only + * @see #include + * @see #exclude + */ + String[] readOnly() default {}; + + /** + * Property names or dot-separated property paths of hidden properties on input bean, as opposed to + * setting @Input(Type.HIDDEN) on a single value input parameter. Allows to define expected input bean attributes with + * hidden values, so that a media type can render them as hidden attribute. This allows to use the same bean for input + * and output in different contexts. E.g. all product attributes should be editable when a new product is added, but + * not when an order is created which contains that product. Thus, if a POST expects an object Product with certain + * fixed values, you can annotate the POST handler: + * + *
+	 *     public void makeOrder(@Input(hidden={"productID"}) Product orderedProduct} {...}
+	 * 
+ * + * Typically, a hidden attribute should have a predefined value. Defining a hidden property effectively makes that + * property an {@link #include} property, i.e. other attributes are ignored by default. + * + * @return property paths which should be shown as read-only + * @see #include + * @see #exclude + * @see #value + */ + String[] hidden() default {}; + + /** + * Property names or dot-separated property paths of properties that should be ignored on input bean. This allows to + * use the same bean for input and output in different contexts. If a POST expects an object Product without certain + * values, you can annotate the POST handler: + * + *
+	 *     public void makeOrder(@Input(exclude={"name"}) Product orderedProduct} {...}
+	 * 
+ * + * If excluded attributes are present, the assumption is that all other attributes should be considered expected + * inputs. + * + * @return property paths which should be ignored + */ + String[] exclude() default {}; + + /** + * Property names or dot-separated property paths of properties that are expected on input bean. If a POST expects an + * object Review having only certain attributes, you can annotate the POST handler: + * + *
+	 *     public void addReview(include={"rating.ratingValue", "reviewBody"}) Review review} {...}
+	 * 
+ * + * If included attributes are present, the assumption is that all other attributes should be considered ignored + * inputs. + * + * @return property paths which should be expected + */ + String[] include() default {}; +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Options.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Options.java new file mode 100644 index 000000000..9843e028f --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Options.java @@ -0,0 +1,68 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.util.List; + +import org.springframework.hateoas.affordance.Suggest; + +/** + * Allows to determine possible values for an argument annotated with {@link Select}. + * + * @author Dietrich Schulten + */ +public interface Options { + + /** + * Gets possible values for an argument annotated with {@link Select}. The default implementation + * {@link StringOptions} just passes on a given String array as possible values. Sometimes the possible values are + * more dynamic and depend on the context. Therefore, an Options implementation can also determine possible values + * based on another argument to the same call. The example below shows how to get possible options from a custom + * DetailOptions implementation which needs a personId for that: + * + *
+	 * @RequestMapping("/customer/{personId}/details")
+	 * public HttpEntity<Resource<List<String>> showDetails(
+	 *     @PathVariable Long personId,
+	 *     @RequestParam("detail")
+	 *     @Select(options = DetailOptions.class, args = "personId")
+	 *     List<String> details) {
+	 *    ...
+	 * }
+	 * 
+ *

+ * The @Select annotation above says that the possible detail values come from a DetailOptions class + * which determines those values based on the personId. Note how the personId is passed to showDetails as + * argument to the same call, alongside the details argument. This allows us to resolve the + * "personId" arg defined for DetailOptions to an actual value. + *

+ *

+ * Within the call to {@link Options#get} the args array contains the values specified by the args annotation + * attribute in the given order. In the example above, DetailOptions receives the personId and can read possible + * options for that particular person. + *

+ * + * @param value parameters to be used by the implementation. Could be literal values as used by {@link StringOptions} + * or some argument to a custom implementation of Options, such as an SQL string. + * @param args from the same method call, as defined by {@link Select#args()}. The possible values for a parameter + * might depend on the context. In that case, you can use {@link Select#args()} to pass other argument values + * received in the same method call to an implementation of {@link Options}. See above for an example. + * @return possible values + * @see StringOptions + */ + List> get(String[] value, Object... args); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/ResourceHandler.java b/src/main/java/org/springframework/hateoas/affordance/formaction/ResourceHandler.java new file mode 100644 index 000000000..d0a597fc6 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/ResourceHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Allows to explicitly qualify a method handler as resource with defined cardinality. + * Normally a Collection or a Resources return type (optionally wrapped into an HttpEntity) + * or the presence of a POST method implicitly qualifies a resource a collection. + * + * @author Dietrich Schulten + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ResourceHandler { + + /** + * Allows to disambiguate if the annotated method handler manages a single or a collection resource. + * This can be helpful when there is a return type which doesn't allow to decide the cardinality + * of a resource, or when the default recognition comes to the wrong result. + * E.g. one can annotate a POST handler so that renderers can render the related resource as a single resource. + *
+	 * @ResourceHandler(Cardinality.SINGLE)
+	 * @RequestMapping(method=RequestMethod.POST)
+	 * public ResponseEntity<String> myPostHandler() {}
+	 * 
+ * + * @return cardinality + */ + Cardinality value(); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Select.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Select.java new file mode 100644 index 000000000..880d42e60 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Select.java @@ -0,0 +1,80 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.hateoas.affordance.SuggestType; + +/** + * Specifies possible values for an argument on a controller method. + * + * @author Dietrich Schulten + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Select { + + /** + * Allows to pass String arguments to the Options implementation. By default, a String array can be used to define + * possible values, since the default Options implementation is {@link StringOptions} + * + * @return arguments to the Options implementation. For the default {@link StringOptions}, an array of possible + * values. + */ + String[] value() default {}; + + /** + * Specifies an implementation of the {@link Options} interface which provides possible values. + * + * @return implementation class of {@link Options} + */ + Class> options() default StringOptions.class; + + /** + * When getting possible values using {@link Options#get}, pass the arguments having these names. + * + * @return names of the arguments whose value should be passed to {@link Options#get} + */ + String[] args() default {}; + + /** + * Marks the type of select, in case of {@link SuggestType#EXTERNAL} the data may be outside the select, for example + * as a variable in HAL response rather than in HAL-FORMS document + * + * @return + */ + SuggestType type() default SuggestType.INTERNAL; + + /** + * Establish the property as required + * + * @return + */ + boolean required() default false; + + /** + * Entire parameter is not editable, refers both to single values and to all properties of a bean parameter. + * + * @return + */ + boolean editable() default true; + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/StringOptions.java b/src/main/java/org/springframework/hateoas/affordance/formaction/StringOptions.java new file mode 100644 index 000000000..29d1b6965 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/StringOptions.java @@ -0,0 +1,48 @@ +/* + * Copyright 2013-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.affordance.formaction; + +import java.util.List; + +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.SimpleSuggest; +import org.springframework.hateoas.affordance.Suggest; +import org.springframework.hateoas.affordance.SuggestObjectWrapper; + +/** + * @author Dietrich Schulten + */ +public class StringOptions implements Options> { + + /** + * Allows an {@link ActionDescriptor} to determine possible values for an action argument. + * The example below defines four possible values for the mood parameter. + * + *
+	 * @RequestMapping(value = "/customer", method = RequestMethod.GET, params = { "mood" })
+	 * public HttpEntity<SamplePersonResourcegt; showPersonByMood(
+	 *     @RequestParam @Select({ "angry", "happy", "grumpy", "bored" })
+	 *     String mood) {
+	 *     ...
+	 * }
+	 * 
+ */ + @Override + public List>> get(String[] value, Object... args) { + return SimpleSuggest.wrap(value); + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/Type.java b/src/main/java/org/springframework/hateoas/affordance/formaction/Type.java new file mode 100644 index 000000000..2361760b7 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/Type.java @@ -0,0 +1,155 @@ +/* + * Copyright 2013-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.affordance.formaction; + +/** + * Allows to specify explicit HTML5 input types. + * + * @author Dietrich Schulten + */ +public enum Type { + + /** + * Determine input type text or number automatically, depending on the annotated parameter + */ + FROM_JAVA(null), + + /** + * input type text + */ + TEXT("text"), + + /** + * input type hidden + */ + HIDDEN("hidden"), + + /** + * input type password + */ + PASSWORD("password"), + + /** + * Color chooser + */ + COLOR("color"), + + /** + * Should contain a date, client may use date picker + */ + DATE("date"), + + /** + * Datetime widget, with timezone. + */ + DATETIME("datetime"), + + /** + * Datetime widget, no timezone. + */ + DATETIME_LOCAL("datetime-local"), + + /** + * Email address, may validate and improve touch entry. + */ + EMAIL("email"), + + /** + * Month/year selector. + */ + MONTH("month"), + + /** + * Numeric value, normally determined automatically. You can set restrictions on the numbers with {@link Input#max}, {@link Input#min} and {@link Input#step}. + */ + NUMBER("number"), + + /** + * Allowed range of values, use with {@link Input#max} and {@link Input#min}. Client may use slider. + */ + RANGE("range"), + + /** + * Search field, may add search entry support, e.g. a delete term widget. + */ + SEARCH("search"), + + /** + * Phone number + */ + TEL("tel"), + + /** + * Select time, may use time picker. + */ + TIME("time"), + + /** + * Field is a URL + */ + URL("url"), + + /** + * Week/Year selector + */ + WEEK("week"), + + /** + * Input type checkbox + */ + CHECKBOX("checkbox"), + + /** + * Input type radio + */ + RADIO("radio"), + + /** + * A form submit button + */ + SUBMIT("submit"); + + private String value; + + Type(String value) { + this.value = value; + } + + /** + * Returns the correct html input type string value, or null if type should be determined from Java type. + */ + public String toString() { + return value; + } + + /** + * Convert a string-based representation into an enum. + * + * @param inputType + * @return + */ + public static Type parseInputType(String inputType) { + + for (Type type : Type.values()) { + if (inputType.equals(type.value)) { + return type; + } + } + return null; + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/formaction/package-info.java b/src/main/java/org/springframework/hateoas/affordance/formaction/package-info.java new file mode 100644 index 000000000..cb130254a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/formaction/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2013-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. + */ + +/** + * Classes supporting the creation of forms-like resources. + */ +package org.springframework.hateoas.affordance.formaction; + diff --git a/src/main/java/org/springframework/hateoas/affordance/package-info.java b/src/main/java/org/springframework/hateoas/affordance/package-info.java new file mode 100644 index 000000000..f8a1c7097 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/package-info.java @@ -0,0 +1,5 @@ +/** + * A more robust, extensive way to mark up controllers, allower richer hypermedia to get generated. + */ +package org.springframework.hateoas.affordance; + diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/ActionDescriptorBuilder.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/ActionDescriptorBuilder.java new file mode 100644 index 000000000..1cf124705 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/ActionDescriptorBuilder.java @@ -0,0 +1,195 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.ActionInputParameter; +import org.springframework.hateoas.affordance.ActionInputParameterVisitor; +import org.springframework.hateoas.affordance.formaction.Action; +import org.springframework.hateoas.affordance.formaction.DTOParam; +import org.springframework.hateoas.core.MethodParameters; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * @author Dietrich Schulten + * @author Greg Turnquist + */ +class ActionDescriptorBuilder { + + /** + * Use the details about the {@link Method} and it's Spring Web annotations to construct an {@link ActionDescriptor}. + * + * @param invokedMethod + * @param values + * @param arguments + * @return + */ + static ActionDescriptor createActionDescriptor(Method invokedMethod, Map values, Object... arguments) { + + SpringActionDescriptor actionDescriptor = new SpringActionDescriptor(invokedMethod); + + Action actionAnnotation = AnnotationUtils.getAnnotation(invokedMethod, Action.class); + if (actionAnnotation != null) { + actionDescriptor.setSemanticActionType(actionAnnotation.value()); + } + + Map requestParamMap = getRequestParamsAndDtoParams(invokedMethod, arguments); + for (Map.Entry actionInputParameter : requestParamMap.entrySet()) { + + if (actionInputParameter.getValue() != null) { + + actionDescriptor.addRequestParam(actionInputParameter.getKey(), actionInputParameter.getValue()); + + if (!actionInputParameter.getValue().isRequestBody()) { + values.put(actionInputParameter.getKey(), actionInputParameter.getValue().getValueFormatted()); + } + } + } + + Map pathVariableMap = getActionInputParameters(PathVariable.class, invokedMethod, + arguments); + for (Map.Entry actionInputParameter : pathVariableMap.entrySet()) { + + if (actionInputParameter.getValue() != null) { + + actionDescriptor.addPathVariable(actionInputParameter.getKey(), actionInputParameter.getValue()); + + if (!actionInputParameter.getValue().isRequestBody()) { + values.put(actionInputParameter.getKey(), actionInputParameter.getValue().getValueFormatted()); + } + } + } + + Map requestHeadersMap = getActionInputParameters(RequestHeader.class, invokedMethod, + arguments); + for (Map.Entry actionInputParameter : requestHeadersMap.entrySet()) { + + if (actionInputParameter.getValue() != null) { + + actionDescriptor.addRequestHeader(actionInputParameter.getKey(), actionInputParameter.getValue()); + + if (!actionInputParameter.getValue().isRequestBody()) { + values.put(actionInputParameter.getKey(), actionInputParameter.getValue().getValueFormatted()); + } + } + } + + Map requestBodyMap = getActionInputParameters(RequestBody.class, invokedMethod, + arguments); + Assert.state(requestBodyMap.size() < 2, "found more than one request body on " + invokedMethod.getName()); + for (ActionInputParameter actionInputParameter : requestBodyMap.values()) { + actionDescriptor.setRequestBody(actionInputParameter); + } + + return actionDescriptor; + } + + /** + * Look up the {@link ActionDescriptor}s for a given {@link Method}'s {@link RequestParam}s + {@link DTOParam}s + * and transform them into a {@link Map}. + * + * @param invokedMethod + * @param arguments + * @return + */ + static Map getRequestParamsAndDtoParams(Method invokedMethod, Object[] arguments) { + + Map parameterMap = new HashMap(); + + parameterMap.putAll(getActionInputParameters(RequestParam.class, invokedMethod, arguments)); + parameterMap.putAll(getDtoActionInputParameters(invokedMethod, arguments)); + + return parameterMap; + } + + /** + * Return the {@link ActionInputParameter}s based on the {@link Method} and associated {@link Annotation}. + * + * @param annotation to inspect + * @param method must not be {@literal null}. + * @param arguments to the method link + * @return maps parameter names to parameter info + */ + private static Map getActionInputParameters(Class annotation, + Method method, Object... arguments) { + + Assert.notNull(method, "MethodInvocation must not be null!"); + + MethodParameters parameters = new MethodParameters(method); + Map result = new LinkedHashMap(); + + for (MethodParameter parameter : parameters.getParametersWith(annotation)) { + + int parameterIndex = parameter.getParameterIndex(); + Object argument = parameterIndex < arguments.length ? arguments[parameterIndex] : null; + + result.put(parameter.getParameterName(), + new SpringActionInputParameter(parameter, argument, parameter.getParameterName())); + } + + return result; + } + + /** + * Returns {@link ActionInputParameter}s contained in the method link. + * + * @param method must not be {@literal null}. + * @param arguments to the method link + * @return maps parameter names to parameter info + */ + private static Map getDtoActionInputParameters(Method method, Object... arguments) { + + Assert.notNull(method, "MethodInvocation must not be null!"); + + final Map result = new HashMap(); + + for (MethodParameter parameter : new MethodParameters(method).getParametersWith(DTOParam.class)) { + + int parameterIndex = parameter.getParameterIndex(); + Object argument = parameterIndex < arguments.length ? arguments[parameterIndex] : null; + + if (argument == null) { + continue; + } + + SpringActionDescriptor.recurseBeanCreationParams(argument.getClass(), null, argument, "", new HashSet(), + new ActionInputParameterVisitor() { + + @Override + public void visit(ActionInputParameter inputParameter) { + result.put(inputParameter.getParameterName(), inputParameter); + } + }, new ArrayList()); + + } + + return result; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilder.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilder.java new file mode 100644 index 000000000..8bc9e2cef --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilder.java @@ -0,0 +1,436 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.hateoas.Identifiable; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkBuilder; +import org.springframework.hateoas.UriTemplate; +import org.springframework.hateoas.UriTemplateComponents; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.Affordance; +import org.springframework.hateoas.affordance.TypedResource; +import org.springframework.hateoas.core.DummyInvocationUtils; +import org.springframework.hateoas.mvc.UriComponentsSupport; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Builder for hypermedia affordances, usable as RFC-5988 web links and optionally holding information about request + * body requirements. + * + * @author Dietrich Schulten + * @author Oliver Gierke + */ +public class AffordanceBuilder implements LinkBuilder { + + private static final AffordanceBuilderFactory FACTORY = new AffordanceBuilderFactory(); + + private final UriTemplateComponents partialUriTemplateComponents; + private final List actionDescriptors = new ArrayList(); + private final MultiValueMap linkParams = new LinkedMultiValueMap(); + private final List rels = new ArrayList(); + private final List reverseRels = new ArrayList(); + + private TypedResource collectionHolder; + + /** + * Creates a new {@link AffordanceBuilder} with a base of the mapping annotated to the given controller class. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @return builder + */ + public static AffordanceBuilder linkTo(Class controller) { + return FACTORY.linkTo(controller, new Object[0]); + } + + /** + * Creates a new {@link AffordanceBuilder} with a base of the mapping annotated to the given controller class. The + * additional parameters are used to fill up potentially available path variables in the class scope request mapping. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be + * {@literal null}. + * @return builder + */ + public static AffordanceBuilder linkTo(Class controller, Object... parameters) { + return FACTORY.linkTo(controller, parameters); + } + + /** + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Method, Object...) + */ + public static AffordanceBuilder linkTo(Method method, Object... parameters) { + return FACTORY.linkTo(method.getDeclaringClass(), method, parameters); + } + + /** + * Creates a new {@link AffordanceBuilder} with a base of the mapping annotated to the given controller class. The + * additional parameters are used to fill up potentially available path variables in the class scop request mapping. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be + * {@literal null}. + * @return builder + */ + public static AffordanceBuilder linkTo(Class controller, Map parameters) { + return FACTORY.linkTo(controller, parameters); + } + + /** + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Class, Method, Object...) + */ + public static AffordanceBuilder linkTo(Class controller, Method method, Object... parameters) { + return FACTORY.linkTo(controller, method, parameters); + } + + /** + * Creates an {@link AffordanceBuilder} pointing to a controller method. Hand in a dummy method invocation result you + * can create via {@link #methodOn(Class, Object...)} or {@link DummyInvocationUtils#methodOn(Class, Object...)}. + * + *
+	 * @RequestMapping("/customers")
+	 * class CustomerController {
+	 *   @RequestMapping("/{id}/addresses")
+	 *   HttpEntity<Addresses> showAddresses(@PathVariable Long id) { � }
+	 * }
+	 * Link link = linkTo(methodOn(CustomerController.class).showAddresses(2L)).withRel("addresses");
+	 * 
+ * + * The resulting {@link Link} instance will point to {@code /customers/2/addresses} and have a rel of + * {@code addresses}. For more details on the method invocation constraints, see + * {@link DummyInvocationUtils#methodOn(Class, Object...)}. + * + * @param methodInvocation to use for link building + * @return builder + */ + public static AffordanceBuilder linkTo(Object methodInvocation) { + return FACTORY.linkTo(methodInvocation); + } + + /** + * Creates a new {@link AffordanceBuilder} pointing to this server, but without ActionDescriptor. + */ + AffordanceBuilder() { + this(new UriTemplate(UriComponentsSupport.getBuilder().build().toString()).expand(Collections.emptyMap()), + Collections.emptyList()); + } + + /** + * Creates a new {@link AffordanceBuilder} using the given {@link ActionDescriptor}. + * + * @param partialUriTemplate must not be {@literal null} + * @param actionDescriptors must not be {@literal null} + */ + AffordanceBuilder(UriTemplate partialUriTemplate, List actionDescriptors) { + + Assert.notNull(partialUriTemplate, "partialUritemplate must not be null!"); + Assert.notNull(actionDescriptors, "actionDescriptors must not be null!"); + + this.partialUriTemplateComponents = partialUriTemplate.asComponents(); + + for (ActionDescriptor actionDescriptor : actionDescriptors) { + this.actionDescriptors.add(actionDescriptor); + } + } + + public static T methodOn(Class clazz, Object... parameters) { + return DummyInvocationUtils.methodOn(clazz, parameters); + } + + /** + * Builds affordance with one or multiple rels which must have been defined previously using {@link #rel(String)} or + * {@link #reverseRel(String, String)}. + *

+ * The motivation for multiple rels is this statement in the web linking rfc-5988: "Note that link-values can + * convey multiple links between the same target and context IRIs; for example: + *

+ * + *
+	 * Link: <http://example.org/>
+	 *       rel="start http://example.net/relation/other"
+	 * 
+ * + * Here, the link to 'http://example.org/' has the registered relation type 'start' and the extension relation type + * 'http://example.net/relation/other'." + * + * @return affordance + * @see Web Linking Examples + */ + public Affordance build() { + + Assert.state(!(rels.isEmpty() && reverseRels.isEmpty()), + "no rels or reverse rels found, call rel() or rev() before building the affordance"); + + Affordance affordance = new Affordance(new UriTemplate(toString()), actionDescriptors, collectionHolder, + rels.toArray(new String[rels.size()])); + + for (Entry> linkParamEntry : linkParams.entrySet()) { + + List values = linkParamEntry.getValue(); + + for (String value : values) { + affordance.addLinkParam(linkParamEntry.getKey(), value); + } + } + + for (String reverseRel : reverseRels) { + affordance.addRev(reverseRel); + } + + return affordance; + } + + /** + * Allows to define one or more reverse link relations (a "rev" in terms of rfc-5988), where the resource that has the + * affordance will be considered the object in a subject-predicate-object statement. + *

+ * E.g. if you had a rel ex:parent which connects a child to its father, you could also use ex:parent on + * the father to point to the child by reverting the direction of ex:parent. This is mainly useful when you have no + * other way to express in your context that the direction of a relationship is inverted. + *

+ * + * @param rev to be used as reverse relationship + * @param revertedRel to be used in contexts which have no notion of reverse relationships. E.g. for a reverse rel + * ex:parent you can use a made-up rel name ex:child which will be used as rel when + * rendering HAL. + * @return builder + */ + public AffordanceBuilder reverseRel(String rev, String revertedRel) { + + rels.add(revertedRel); + reverseRels.add(rev); + return this; + } + + /** + * Allows to define one or more reverse link relations (a "rev" in terms of rfc-5988) to collections in cases where + * the resource that has the affordance is not the object in a subject-predicate-object statement about each + * collection item. See {@link #rel(TypedResource, String)} for explanation. + * + * @param rev to be used as reverse relationship + * @param revertedRel to be used in contexts which have no notion of reverse relationships, e.g. HAL + * @param object describing the object + * @return builder + */ + public AffordanceBuilder reverseRel(String rev, String revertedRel, TypedResource object) { + + collectionHolder = object; + rels.add(0, revertedRel); + reverseRels.add(rev); + return this; + } + + /** + * Allows to define one or more link relations for the affordance. + * + * @param rel to be used as link relation + * @return builder + */ + public AffordanceBuilder rel(String rel) { + + rels.add(rel); + return this; + } + + /** + * Allows to define one or more link relations for affordances that point to collections in cases where the resource + * that has the affordance is not the subject in a subject-predicate-object statement about each collection item. E.g. + * a product might have a loose relationship to ordered items where it can be POSTed, but the ordered items do not + * belong to the product, but to an order. You can express that by saying: + * + *
+	 * TypedResource order = new TypedResource("http://schema.org/Order"); // holds the ordered items
+	 * Resource<Product> product = new Resource<>(); // has a loose relationship to ordered items
+	 * product.add(linkTo(methodOn(OrderController.class).postOrderedItem()
+	 *    .rel(order, "orderedItem")); // order has ordered items, not product has ordered items
+	 * 
+ * + * If the order doesn't exist yet, it cannot be identified. In that case use a TypedResource without identifying URI. + * + * @param rel to be used as link relation + * @param subject describing the subject + * @return builder + */ + public AffordanceBuilder rel(TypedResource subject, String rel) { + + collectionHolder = subject; + rels.add(rel); + return this; + } + + public AffordanceBuilder withTitle(String title) { + + linkParams.set("title", title); + return this; + } + + public AffordanceBuilder withTitleStar(String titleStar) { + + linkParams.set("title*", titleStar); + return this; + } + + /** + * Allows to define link header params (not UriTemplate variables). + * + * @param name of the link header param + * @param value of the link header param + * @return builder + */ + public AffordanceBuilder withLinkParam(String name, String value) { + + linkParams.add(name, value); + return this; + } + + public AffordanceBuilder withAnchor(String anchor) { + + this.linkParams.set("anchor", anchor); + return this; + } + + public AffordanceBuilder withHreflang(String hreflang) { + + this.linkParams.add("hreflang", hreflang); + return this; + } + + public AffordanceBuilder withMedia(String media) { + + this.linkParams.set("media", media); + return this; + } + + public AffordanceBuilder withType(String type) { + + this.linkParams.set("type", type); + return this; + } + + @Override + public AffordanceBuilder slash(Object object) { + + if (object == null) { + return this; + } + + if (object instanceof Identifiable) { + return slash((Identifiable) object); + } + + String urlPart = object.toString(); + + // make sure one cannot delete the fragment + if (urlPart.endsWith("#")) { + urlPart = urlPart.substring(0, urlPart.length() - 1); + } + + if (!StringUtils.hasText(urlPart)) { + return this; + } + + UriTemplateComponents urlPartComponents = new UriTemplate(urlPart).expand().asComponents(); + UriTemplateComponents affordanceComponents = this.partialUriTemplateComponents; + + String path = !affordanceComponents.getBaseUri().endsWith("/") && !urlPartComponents.getBaseUri().startsWith("/") + ? affordanceComponents.getBaseUri() + "/" + urlPartComponents.getBaseUri() + : affordanceComponents.getBaseUri() + urlPartComponents.getBaseUri(); + String queryHead = affordanceComponents.getQueryHead() + (StringUtils.hasText(urlPartComponents.getQueryHead()) + ? "&" + urlPartComponents.getQueryHead().substring(1) : ""); + String queryTail = affordanceComponents.getQueryTail() + + (StringUtils.hasText(urlPartComponents.getQueryTail()) ? "," + urlPartComponents.getQueryTail() : ""); + String fragmentIdentifier = StringUtils.hasText(urlPartComponents.getFragmentIdentifier()) + ? urlPartComponents.getFragmentIdentifier() : affordanceComponents.getFragmentIdentifier(); + + List variableNames = new ArrayList(); + variableNames.addAll(affordanceComponents.getVariableNames()); + variableNames.addAll(urlPartComponents.getVariableNames()); + + UriTemplateComponents mergedUriComponents = new UriTemplateComponents(path, queryHead, queryTail, + fragmentIdentifier, variableNames); + + return new AffordanceBuilder(new UriTemplate(mergedUriComponents.toString()), this.actionDescriptors); + } + + @Override + public AffordanceBuilder slash(Identifiable identifiable) { + + if (identifiable == null) { + return this; + } + + return slash(identifiable.getId()); + } + + @Override + public URI toUri() { + + UriTemplate partialUriTemplate = new UriTemplate(partialUriTemplateComponents.toString()); + + String actionLink = partialUriTemplate.stripOptionalVariables(this.actionDescriptors).toString(); + + if (actionLink == null || actionLink.contains("{")) { + throw new IllegalStateException("cannot convert template to URI"); + } + return UriComponentsBuilder.fromUriString(actionLink).build().toUri(); + } + + @Override + public Affordance withRel(String rel) { + return rel(rel).build(); + } + + @Override + public Affordance withSelfRel() { + return rel(Link.REL_SELF).build(); + } + + @Override + public String toString() { + return this.partialUriTemplateComponents.toString(); + } + + /** + * Adds actionDescriptors and linkParams of the given AffordanceBuilder to this affordanceBuilder. + * + * @param affordanceBuilder whose action descriptors should be added to this one + * @return builder + */ + public AffordanceBuilder and(AffordanceBuilder affordanceBuilder) { + + this.actionDescriptors.addAll(affordanceBuilder.getActionDescriptors()); + this.linkParams.putAll(affordanceBuilder.linkParams); + return this; + } + + public List getActionDescriptors() { + return this.actionDescriptors; + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderFactory.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderFactory.java new file mode 100644 index 000000000..5f60d00ae --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/AffordanceBuilderFactory.java @@ -0,0 +1,151 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import org.springframework.hateoas.MethodLinkBuilderFactory; +import org.springframework.hateoas.UriTemplate; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.core.AnnotationMappingDiscoverer; +import org.springframework.hateoas.core.DummyInvocationUtils; +import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.mvc.UriComponentsSupport; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Factory for {@link AffordanceBuilder}s in a Spring MVC rest service. Normally one should use the static methods of + * AffordanceBuilder to get an AffordanceBuilder. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class AffordanceBuilderFactory implements MethodLinkBuilderFactory { + + private static final MappingDiscoverer MAPPING_DISCOVERER = new AnnotationMappingDiscoverer(RequestMapping.class); + + @Override + public AffordanceBuilder linkTo(Class controller, Map parameters) { + + String mapping = MAPPING_DISCOVERER.getMapping(controller); + + UriTemplate partialUriTemplate = new UriTemplate(mapping == null ? "/" : mapping); + + return new AffordanceBuilder().slash(partialUriTemplate.expand(parameters)); + } + + @Override + public AffordanceBuilder linkTo(Class controller, Method method, Object... parameters) { + + String pathMapping = MAPPING_DISCOVERER.getMapping(controller, method); + + Set params = ActionDescriptorBuilder.getRequestParamsAndDtoParams(method, parameters).keySet(); + + String query = StringUtils.collectionToCommaDelimitedString(params); + String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + "{?" + query + "}"; + + UriTemplate partialUriTemplate = new UriTemplate(UriComponentsSupport.getBuilder().build().toString() + mapping); + + Map values = new HashMap(); + + Iterator names = partialUriTemplate.getVariableNames().iterator(); + // there may be more or less mapping variables than arguments + for (Object parameter : parameters) { + if (!names.hasNext()) { + break; + } + values.put(names.next(), parameter); + } + + ActionDescriptor actionDescriptor = ActionDescriptorBuilder.createActionDescriptor(method, values, parameters); + + return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor)); + } + + @Override + public AffordanceBuilder linkTo(Object invocationValue) { + + Assert.isInstanceOf(DummyInvocationUtils.LastInvocationAware.class, invocationValue); + DummyInvocationUtils.LastInvocationAware invocations = (DummyInvocationUtils.LastInvocationAware) invocationValue; + + String pathMapping = MAPPING_DISCOVERER.getMapping(invocations.getLastInvocation().getMethod()); + + Set params = ActionDescriptorBuilder.getRequestParamsAndDtoParams(invocations.getLastInvocation().getMethod(), invocations.getLastInvocation().getArguments()).keySet(); + + String query = StringUtils.collectionToCommaDelimitedString(params); + String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + "{?" + query + "}"; + + UriTemplate partialUriTemplate = new UriTemplate(UriComponentsSupport.getBuilder().build().toString() + mapping); + + Iterator classMappingParameters = invocations.getObjectParameters(); + + Map values = new HashMap(); + Iterator names = partialUriTemplate.getVariableNames().iterator(); + while (classMappingParameters.hasNext()) { + values.put(names.next(), classMappingParameters.next()); + } + + for (Object argument : invocations.getLastInvocation().getArguments()) { + if (names.hasNext()) { + values.put(names.next(), argument); + } + } + + ActionDescriptor actionDescriptor = ActionDescriptorBuilder.createActionDescriptor(invocations.getLastInvocation().getMethod(), values, + invocations.getLastInvocation().getArguments()); + + return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor)); + } + + @Override + public AffordanceBuilder linkTo(Method method, Object... parameters) { + return linkTo(method.getDeclaringClass(), method, parameters); + } + + @Override + public AffordanceBuilder linkTo(Class controller, Object... parameters) { + + Assert.notNull(controller, "controller must not be null!"); + + String mapping = MAPPING_DISCOVERER.getMapping(controller); + + UriTemplate partialUriTemplate = new UriTemplate(mapping == null ? "/" : mapping); + + Map values = new HashMap(); + Iterator names = partialUriTemplate.getVariableNames().iterator(); + // there may be more or less mapping variables than arguments + for (Object parameter : parameters) { + if (!names.hasNext()) { + break; + } + values.put(names.next(), parameter); + } + return new AffordanceBuilder().slash(partialUriTemplate.expand(values)); + } + + @Override + public AffordanceBuilder linkTo(Class target) { + return linkTo(target, new Object[0]); + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/DefaultDocumentationProvider.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/DefaultDocumentationProvider.java new file mode 100644 index 000000000..24dcf1170 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/DefaultDocumentationProvider.java @@ -0,0 +1,75 @@ +/* + * 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.affordance.springmvc; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.springframework.hateoas.affordance.ActionInputParameter; + +/** + * Default documentation provider, always returns null as documentation url. + * + * @author Dietrich Schulten + * @author Oliver Gierke + */ +public class DefaultDocumentationProvider implements DocumentationProvider { + + /* + * (non-Javadoc) + * @see DocumentationProvider#getDocumentationUrl(ActionInputParameter, java.lang.Object) + */ + @Override + public String getDocumentationUrl(ActionInputParameter annotatedParameter, Object content) { + return null; + } + + /* + * (non-Javadoc) + * @see DocumentationProvider#getDocumentationUrl(java.lang.reflect.Field, java.lang.Object) + */ + @Override + public String getDocumentationUrl(Field field, Object content) { + return null; + } + + /* + * (non-Javadoc) + * @see DocumentationProvider#getDocumentationUrl(java.lang.reflect.Method, java.lang.Object) + */ + @Override + public String getDocumentationUrl(Method method, Object content) { + return null; + } + + /* + * (non-Javadoc) + * @see DocumentationProvider#getDocumentationUrl(java.lang.Class, java.lang.Object) + */ + @Override + public String getDocumentationUrl(Class clazz, Object content) { + return null; + } + + /* + * (non-Javadoc) + * @see DocumentationProvider#getDocumentationUrl(java.lang.String, java.lang.Object) + */ + @Override + public String getDocumentationUrl(String name, Object content) { + return null; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/DocumentationProvider.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/DocumentationProvider.java new file mode 100644 index 000000000..48a1a41d8 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/DocumentationProvider.java @@ -0,0 +1,59 @@ +package org.springframework.hateoas.affordance.springmvc; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.springframework.hateoas.affordance.ActionInputParameter; + +/** + * Provides documentation urls for the given elements. + * + * @author Dietrich Schulten + */ +public interface DocumentationProvider { + + /** + * Gets documentationUrl for given parameter. + * + * @param actionInputParameter to document + * @param content current value + * @return url or null + */ + String getDocumentationUrl(ActionInputParameter actionInputParameter, Object content); + + /** + * Gets documentationUrl for given field. + * + * @param field to document + * @param content current value + * @return url or null + */ + String getDocumentationUrl(Field field, Object content); + + /** + * Gets documentationUrl for given method. + * + * @param method to document + * @param content current value + * @return url or null + */ + String getDocumentationUrl(Method method, Object content); + + /** + * Gets documentationUrl for given class. + * + * @param clazz to document + * @param content current value + * @return url or null + */ + String getDocumentationUrl(Class clazz, Object content); + + /** + * Gets documentationUrl for given attribute name. + * + * @param name to document + * @param content current value + * @return url or null + */ + String getDocumentationUrl(String name, Object content); +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionDescriptor.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionDescriptor.java new file mode 100644 index 000000000..beb7faecd --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionDescriptor.java @@ -0,0 +1,782 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.Getter; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.PropertyAccessorUtils; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.ActionInputParameter; +import org.springframework.hateoas.affordance.ActionInputParameterVisitor; +import org.springframework.hateoas.affordance.formaction.Action; +import org.springframework.hateoas.affordance.formaction.Cardinality; +import org.springframework.hateoas.affordance.formaction.DTOParam; +import org.springframework.hateoas.affordance.formaction.ResourceHandler; +import org.springframework.hateoas.affordance.formaction.Select; +import org.springframework.hateoas.affordance.support.DataTypeUtils; +import org.springframework.hateoas.affordance.support.PropertyUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Describes an HTTP method independently of a specific rest framework. Has knowledge about possible request data, i.e. + * which types and values are suitable for an action. For example, an action descriptor can be used to create a form + * with select options and typed input fields that calls a POST handler. It has {@link ActionInputParameter}s which + * represent method handler arguments. Supported method handler arguments are: + *
    + *
  • path variables
  • + *
  • request params (url query params)
  • + *
  • request headers
  • + *
  • request body
  • + *
+ * + * @author Dietrich Schulten + * @author Oliver Gierke + */ +public class SpringActionDescriptor implements ActionDescriptor { + + private @Getter HttpMethod httpMethod; + private String actionName; + private @Getter List consumes, produces; + private Map requestParams = new LinkedHashMap(); + private Map pathVariables = new LinkedHashMap(); + private Map requestHeaders = new LinkedHashMap(); + private Map bodyInputParameters = new LinkedHashMap(); + + private String semanticActionType; + private ActionInputParameter requestBody; + private Cardinality cardinality = Cardinality.SINGLE; + + /** + * Creates an {@link ActionDescriptor}. + * + * @param actionName name of the action, e.g. the method name of the handler method. Can be used by an action + * representation, e.g. to identify the action using a form name. + * @param httpMethod used during submit + */ + public SpringActionDescriptor(String actionName, HttpMethod httpMethod) { + this(actionName, httpMethod, Collections.emptyList(), Collections.emptyList()); + } + + public SpringActionDescriptor(String actionName, HttpMethod httpMethod, List consumes, + List produces) { + + Assert.notNull(actionName, "actionName must not be null!"); + Assert.notNull(httpMethod, "httpMethod must not be null!"); + + this.httpMethod = httpMethod; + this.actionName = actionName; + this.consumes = consumes; + this.produces = produces; + } + + public SpringActionDescriptor(Method method) { + + this.httpMethod = getHttpMethod(method); + this.actionName = method.getName(); + this.consumes = getConsumes(method); + this.produces = getProduces(method); + this.cardinality = getCardinality(method, this.httpMethod, method.getReturnType()); + } + + /** + * The name of the action, for use as form name, usually the method name of the handler method. + * + * @return action name, never null + */ + @Override + public String getActionName() { + return this.actionName; + } + + /** + * Gets the path variable names. + * + * @return names or empty collection, never null + */ + @Override + public Collection getPathVariableNames() { + return this.pathVariables.keySet(); + } + + /** + * Gets the request header names. + * + * @return names or empty collection, never null + */ + @Override + public Collection getRequestHeaderNames() { + return this.requestHeaders.keySet(); + } + + /** + * Gets the request parameter (query param) names. + * + * @return names or empty collection, never null + */ + @Override + public Collection getRequestParamNames() { + return this.requestParams.keySet(); + } + + /** + * Adds descriptor for request param. + * + * @param key name of request param + * @param actionInputParameter descriptor + */ + public void addRequestParam(String key, ActionInputParameter actionInputParameter) { + this.requestParams.put(key, actionInputParameter); + } + + /** + * Adds descriptor for path variable. + * + * @param key name of path variable + * @param actionInputParameter descriptorg+ann#2 + */ + + public void addPathVariable(String key, ActionInputParameter actionInputParameter) { + this.pathVariables.put(key, actionInputParameter); + } + + /** + * Adds descriptor for request header. + * + * @param key name of request header + * @param actionInputParameter descriptor + */ + public void addRequestHeader(String key, ActionInputParameter actionInputParameter) { + this.requestHeaders.put(key, actionInputParameter); + } + + /** + * Gets all action input parameter names. + * + * @return + */ + @Override + public Collection getActionInputParameterNames() { + + Collection actionInputParameterNames = new ArrayList(); + + actionInputParameterNames.addAll(this.requestParams.keySet()); + actionInputParameterNames.addAll(this.pathVariables.keySet()); + actionInputParameterNames.addAll(this.bodyInputParameters.keySet()); + + return actionInputParameterNames; + } + + /** + * Get all of the {@link ActionInputParameter}s. + * + * @return + */ + @Override + public Collection getActionInputParameters() { + + Collection actionInputParameters = new ArrayList(); + + actionInputParameters.addAll(this.requestParams.values()); + actionInputParameters.addAll(this.pathVariables.values()); + actionInputParameters.addAll(this.bodyInputParameters.values()); + + return actionInputParameters; + } + + /** + * Gets input parameter info which is part of the URL mapping, be it request parameters, path variables or request + * body attributes. + * + * @param name to retrieve + * @return parameter descriptor or null + */ + @Override + public ActionInputParameter getActionInputParameter(String name) { + + ActionInputParameter results = this.requestParams.get(name); + + if (results == null) { + results = this.pathVariables.get(name); + } + if (results == null) { + results = this.bodyInputParameters.get(name); + } + + return results; + } + + /** + * Recursively navigate to return a BeanWrapper for the nested property path. + * + * @param propertyPath property property path, which may be nested + * @return a BeanWrapper for the target bean + */ + PropertyDescriptor getPropertyDescriptorForPropertyPath(String propertyPath, Class propertyType) { + + int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath); + + // Handle nested properties recursively. + if (pos > -1) { + String nestedProperty = propertyPath.substring(0, pos); + String nestedPath = propertyPath.substring(pos + 1); + PropertyDescriptor propertyDescriptor = BeanUtils.getPropertyDescriptor(propertyType, nestedProperty); + return getPropertyDescriptorForPropertyPath(nestedPath, propertyDescriptor.getPropertyType()); + } else { + return BeanUtils.getPropertyDescriptor(propertyType, propertyPath); + } + } + + /** + * Gets request header info. + * + * @param name of the request header + * @return request header descriptor or null + */ + public ActionInputParameter getRequestHeader(String name) { + return this.requestHeaders.get(name); + } + + /** + * Gets request body info. + * + * @return request body descriptor or null + */ + @Override + public ActionInputParameter getRequestBody() { + return this.requestBody; + } + + /** + * Determines if this descriptor has a request body. + * + * @return true if request body is present + */ + @Override + public boolean hasRequestBody() { + return this.requestBody != null; + } + + /** + * Allows to set request body descriptor. + * + * @param newRequestBody descriptor to set + */ + public void setRequestBody(ActionInputParameter newRequestBody) { + + this.requestBody = newRequestBody; + + if (newRequestBody != null) { + + List bodyInputParameters = new ArrayList(); + + recurseBeanCreationParams(getRequestBody().getParameterType(), (SpringActionInputParameter) getRequestBody(), + getRequestBody().getValue(), "", Collections.emptySet(), new ActionInputParameterVisitor() { + @Override + public void visit(ActionInputParameter inputParameter) {} + }, bodyInputParameters); + + for (ActionInputParameter actionInputParameter : bodyInputParameters) { + this.bodyInputParameters.put(actionInputParameter.getName(), actionInputParameter); + } + } + } + + /** + * Gets semantic type of action, e.g. a subtype of hydra:Operation or schema:Action. Use {@link Action} on a method + * handler to define the semantic type of an action. + * + * @return URL identifying the type + */ + @Override + public String getSemanticActionType() { + return this.semanticActionType; + } + + /** + * Sets semantic type of action, e.g. a subtype of hydra:Operation or schema:Action. + * + * @param semanticActionType URL identifying the type + */ + public void setSemanticActionType(String semanticActionType) { + this.semanticActionType = semanticActionType; + } + + /** + * Determines action input parameters for required url variables. + * + * @return required url variables + */ + @Override + public Map getRequiredParameters() { + + Map ret = new HashMap(); + + for (Map.Entry entry : this.requestParams.entrySet()) { + ActionInputParameter annotatedParameter = entry.getValue(); + if (annotatedParameter.isRequired()) { + ret.put(entry.getKey(), annotatedParameter); + } + } + + for (Map.Entry entry : this.pathVariables.entrySet()) { + ActionInputParameter annotatedParameter = entry.getValue(); + ret.put(entry.getKey(), annotatedParameter); + } + + // requestBody not supported, would have to use exploded modifier + return ret; + } + + /** + * Allows to set the cardinality, i.e. specify if the action refers to a collection or a single resource. Default is + * {@link Cardinality#SINGLE} + * + * @param cardinality to set + */ + public void setCardinality(Cardinality cardinality) { + this.cardinality = cardinality; + } + + /** + * Allows to decide whether or not the action refers to a collection resource. + * + * @return cardinality + */ + @Override + public Cardinality getCardinality() { + return this.cardinality; + } + + @Override + public void accept(ActionInputParameterVisitor visitor) { + + if (hasRequestBody()) { + for (ActionInputParameter inputParameter : this.bodyInputParameters.values()) { + visitor.visit(inputParameter); + } + } else { + Collection paramNames = getRequestParamNames(); + for (String paramName : paramNames) { + ActionInputParameter inputParameter = getActionInputParameter(paramName); + visitor.visit(inputParameter); + } + } + } + + /** + * Renders input fields for bean properties of bean to add or update or patch. + * + * @param beanType to render + * @param annotatedParameter which requires the bean + * @param currentCallValue sample call value + * @param parentParamName + * @param knownFields + * @param methodHandler + * @param bodyInputParameters + */ + static void recurseBeanCreationParams(Class beanType, SpringActionInputParameter annotatedParameter, + Object currentCallValue, String parentParamName, Set knownFields, + ActionInputParameterVisitor methodHandler, List bodyInputParameters) { + + // TODO collection, map and object node creation are only describable by an annotation, not via type reflection + if (ObjectNode.class.isAssignableFrom(beanType) || Map.class.isAssignableFrom(beanType) + || Collection.class.isAssignableFrom(beanType) || beanType.isArray()) { + return; // use @Input(include) to list parameter names, at least? Or mix with hdiv's form builder? + } + try { + // find default ctor + Constructor constructor = PropertyUtils.findConstructorByAnnotation(beanType, JsonCreator.class); + + // find ctor with JsonCreator ann + if (constructor == null) { + constructor = PropertyUtils.findDefaultConstructor(beanType); + } + + int parameterCount = constructor == null ? 0 : constructor.getParameterTypes().length; + + Set knownConstructorFields = new HashSet(); + + if (constructor != null && parameterCount > 0) { + + Class[] parameters = constructor.getParameterTypes(); + int paramIndex = 0; + for (Annotation[] annotationsOnParameter : constructor.getParameterAnnotations()) { + for (Annotation annotation : annotationsOnParameter) { + if (JsonProperty.class == annotation.annotationType()) { + JsonProperty jsonProperty = (JsonProperty) annotation; + + // TODO use required attribute of JsonProperty for required fields -> + String paramName = jsonProperty.value(); + Class parameterType = parameters[paramIndex]; + Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, paramName); + MethodParameter methodParameter = new MethodParameter(constructor, paramIndex); + + String fieldName = invokeHandlerOrFollowRecurse(methodParameter, annotatedParameter, parentParamName, + paramName, parameterType, propertyValue, knownConstructorFields, methodHandler, bodyInputParameters); + + if (fieldName != null) { + knownConstructorFields.add(fieldName); + } + + paramIndex++; // increase for each @JsonProperty + } + } + } + + Assert.isTrue(parameters.length == paramIndex, "not all constructor arguments of @JsonCreator " + + constructor.getName() + " are annotated with @JsonProperty"); + } + + // TODO support Option provider by other method args? + // add input field for every setter + for (PropertyDescriptor propertyDescriptor : Introspector.getBeanInfo(beanType).getPropertyDescriptors()) { + Method writeMethod = propertyDescriptor.getWriteMethod(); + String propertyName = propertyDescriptor.getName(); + + if (writeMethod == null || knownFields.contains(parentParamName + propertyName)) { + continue; + } + Class propertyType = propertyDescriptor.getPropertyType(); + + Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, propertyName); + MethodParameter methodParameter = new MethodParameter(propertyDescriptor.getWriteMethod(), 0); + + invokeHandlerOrFollowRecurse(methodParameter, annotatedParameter, parentParamName, propertyName, propertyType, + propertyValue, knownConstructorFields, methodHandler, bodyInputParameters); + + } + } catch (Exception e) { + throw new RuntimeException("Failed to write input fields for constructor", e); + } + } + + private static String invokeHandlerOrFollowRecurse(MethodParameter methodParameter, + SpringActionInputParameter annotatedParameter, String parentParamName, String paramName, Class parameterType, + Object propertyValue, Set knownFields, ActionInputParameterVisitor handler, + List bodyInputParameters) { + + Annotation[] annotations = methodParameter.getParameterAnnotations(); + + String paramPath = parentParamName + paramName; + + if (DataTypeUtils.isSingleValueType(parameterType) || + DataTypeUtils.isArrayOrIterable(parameterType) || + getParameterAnnotation(annotations, Select.class) != null) { + + /** + * TODO This is a temporal patch, to be reviewed... + */ + if (annotatedParameter == null) { + + ActionInputParameter inputParameter = new SpringActionInputParameter(methodParameter, propertyValue, + parentParamName + paramName); + bodyInputParameters.add(inputParameter); + handler.visit(inputParameter); + return inputParameter.getName(); + + } else if (annotatedParameter.isIncluded(paramPath) && !knownFields.contains(parentParamName + paramName)) { + + DTOParam dtoAnnotation = getParameterAnnotation(annotations, DTOParam.class); + + StringBuilder sb = new StringBuilder(64); + + if (DataTypeUtils.isArrayOrIterable(parameterType) && dtoAnnotation != null) { + Object wildCardValue = null; + if (propertyValue != null) { + // if the element is wildcard dto type element we need to get the first value + if (parameterType.isArray()) { + Object[] array = (Object[]) propertyValue; + if (!dtoAnnotation.wildcard()) { + for (int i = 0; i < array.length; i++) { + if (array[i] != null) { + sb.setLength(0); + recurseBeanCreationParams(array[i].getClass(), annotatedParameter, array[i], + sb.append(parentParamName).append(paramName).append('[').append(i).append("].").toString(), + knownFields, handler, bodyInputParameters); + } + } + } else if (array.length > 0) { + wildCardValue = array[0]; + } + } else { + int i = 0; + if (!dtoAnnotation.wildcard()) { + for (Object value : (Collection) propertyValue) { + if (value != null) { + sb.setLength(0); + recurseBeanCreationParams(value.getClass(), annotatedParameter, value, + sb.append(parentParamName).append(paramName).append('[').append(i++).append("].").toString(), + knownFields, handler, bodyInputParameters); + } + } + } else if (!((Collection) propertyValue).isEmpty()) { + wildCardValue = ((Collection) propertyValue).iterator().next(); + } + } + } + if (dtoAnnotation.wildcard()) { + Class willCardClass = null; + if (wildCardValue != null) { + willCardClass = wildCardValue.getClass(); + } else { + Type type = methodParameter.getGenericParameterType(); + if (type != null && type instanceof ParameterizedType) { + willCardClass = (Class) ((ParameterizedType) type).getActualTypeArguments()[0]; + } + } + if (willCardClass != null) { + recurseBeanCreationParams(willCardClass, + annotatedParameter, wildCardValue, sb.append(parentParamName).append(paramName) + .append(DTOParam.WILDCARD_LIST_MASK).append('.').toString(), + knownFields, handler, bodyInputParameters); + } + } + return parentParamName + paramName; + } else { + SpringActionInputParameter inputParameter = new SpringActionInputParameter(methodParameter, propertyValue, + parentParamName + paramName); + // TODO We need to find a better solution for this + inputParameter.possibleValues = annotatedParameter.possibleValues; + bodyInputParameters.add(inputParameter); + handler.visit(inputParameter); + if (annotatedParameter.isReadOnly(paramPath)) { + inputParameter.setReadOnly(true); + } + if (annotatedParameter.isHidden(paramPath)) { + inputParameter.setHtmlInputFieldType(org.springframework.hateoas.affordance.formaction.Type.HIDDEN); + } + return inputParameter.getName(); + } + } + + } else { + Object callValueBean; + if (propertyValue instanceof Resource) { + callValueBean = ((Resource) propertyValue).getContent(); + } else { + callValueBean = propertyValue; + } + recurseBeanCreationParams(parameterType, annotatedParameter, callValueBean, parentParamName + paramName + ".", + knownFields, handler, bodyInputParameters); + } + + return null; + } + + /** + * Look up the "method" of a @RequestMapping. + * + * @param method + * @return + */ + private static HttpMethod getHttpMethod(Method method) { + + RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class); + RequestMethod requestMethod; + + if (methodRequestMapping != null) { + + RequestMethod[] methods = methodRequestMapping.method(); + requestMethod = methods.length == 0 ? RequestMethod.GET : methods[0]; + + } else { + requestMethod = RequestMethod.GET; // default + } + + return HttpMethod.valueOf(requestMethod.name()); + } + + /** + * Look up the "consumes" from a @RequestMapping. + * + * @param method + * @return + */ + private static List getConsumes(Method method) { + + RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class); + + if (methodRequestMapping == null) { + return Collections.emptyList(); + } + + // TODO: With Java 8, use a Stream operation + + List result = new ArrayList(); + + for (String type : methodRequestMapping.consumes()) { + result.add(MediaType.parseMediaType(type)); + } + + return result; + } + + /** + * Look up the "produces" from a @RequestMapping. + * + * @param method + * @return + */ + private static List getProduces(Method method) { + + RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class); + + if (methodRequestMapping == null) { + return Collections.emptyList(); + } + + // TODO: With Java 8, use a Stream operation + + List result = new ArrayList(); + + for (String type : methodRequestMapping.produces()) { + result.add(MediaType.parseMediaType(type)); + } + + return result; + } + + /** + * Look at the REST method and decide if this is a {@literal COLLECTION} or a {@literal SINGLE} item. + * + * @param invokedMethod + * @param httpMethod + * @param genericReturnType + * @return + */ + private Cardinality getCardinality(Method invokedMethod, HttpMethod httpMethod, Type genericReturnType) { + + ResourceHandler resourceAnn = AnnotationUtils.findAnnotation(invokedMethod, ResourceHandler.class); + + if (resourceAnn != null) { + return resourceAnn.value(); + } else { + if (HttpMethod.POST == httpMethod || containsCollection(genericReturnType)) { + return Cardinality.COLLECTION; + } else { + return Cardinality.SINGLE; + } + } + } + + /** + * Look at the return type of a method, and glean if it's a container or not. + * + * @param genericReturnType + * @return + */ + private boolean containsCollection(Type genericReturnType) { + + if (genericReturnType instanceof ParameterizedType) { + + ParameterizedType parameterizedType = (ParameterizedType) genericReturnType; + Type rawType = parameterizedType.getRawType(); + + Assert.state(rawType instanceof Class, "raw type is not a Class: " + rawType.toString()); + + Class cls = (Class) rawType; + + if (HttpEntity.class.isAssignableFrom(cls)) { + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + return containsCollection(typeArguments[0]); + } else if (Iterable.class.isAssignableFrom(cls)) { + return true; + } else { + return false; + } + + } else if (genericReturnType instanceof GenericArrayType) { + return true; + } else if (genericReturnType instanceof WildcardType) { + + WildcardType wildcardType = (WildcardType) genericReturnType; + return containsCollection(getBound(wildcardType.getLowerBounds())) + || + containsCollection(getBound(wildcardType.getUpperBounds())); + + } else if (genericReturnType instanceof TypeVariable) { + return false; + } else if (genericReturnType instanceof Class) { + return Iterable.class.isAssignableFrom((Class) genericReturnType); + } else { + return false; + } + } + + /** + * Fetch the first type in a generic wildcard boundary + * + * @param bounds + * @return + */ + private Type getBound(Type[] bounds) { + + if (bounds != null && bounds.length > 0) { + return bounds[0]; + } else { + return null; + } + } + + @Override + public String toString() { + return "SpringActionDescriptor [httpMethod=" + httpMethod + ", actionName=" + actionName + "]"; + } + + @SuppressWarnings("unchecked") + private static T getParameterAnnotation(Annotation[] anns, Class annotationType) { + + for (Annotation ann : anns) { + if (annotationType.isInstance(ann)) { + return (T) ann; + } + } + return null; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionInputParameter.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionInputParameter.java new file mode 100644 index 000000000..f26aa2b7c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/SpringActionInputParameter.java @@ -0,0 +1,791 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.beans.BeanUtils; +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.hateoas.affordance.ActionDescriptor; +import org.springframework.hateoas.affordance.ActionInputParameter; +import org.springframework.hateoas.affordance.ParameterType; +import org.springframework.hateoas.affordance.SimpleSuggest; +import org.springframework.hateoas.affordance.Suggest; +import org.springframework.hateoas.affordance.SuggestType; +import org.springframework.hateoas.affordance.Suggestions; +import org.springframework.hateoas.affordance.SuggestionsProvider; +import org.springframework.hateoas.affordance.formaction.Input; +import org.springframework.hateoas.affordance.formaction.Options; +import org.springframework.hateoas.affordance.formaction.Select; +import org.springframework.hateoas.affordance.formaction.StringOptions; +import org.springframework.hateoas.affordance.formaction.Type; +import org.springframework.hateoas.affordance.support.DataTypeUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.support.WebApplicationContextUtils; + +/** + * Describes a Spring MVC rest services method parameter value with recorded sample call value and input constraints. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class SpringActionInputParameter implements ActionInputParameter { + + private static final String[] EMPTY = new String[0]; + private static final List> EMPTY_SUGGEST = Collections.emptyList(); + + private final TypeDescriptor typeDescriptor; + + private RequestBody requestBody; + private RequestParam requestParam; + private PathVariable pathVariable; + private RequestHeader requestHeader; + + private final MethodParameter methodParameter; + private final Object value; + + private Boolean arrayOrCollection = null; + + private final Map inputConstraints = new HashMap(); + + Suggest[] possibleValues; + + private String[] excluded = EMPTY; + private String[] readOnly = EMPTY; + private String[] hidden = EMPTY; + private String[] include = EMPTY; + + private boolean editable = true; + + private ParameterType type = ParameterType.UNKNOWN; + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private PossibleValuesResolver resolver = new FixedPossibleValuesResolver(EMPTY_SUGGEST, SuggestType.INTERNAL); + + private static final ConversionService DEFAULT_CONVERSION_SERVICE = new DefaultFormattingConversionService(); + + private final ConversionService conversionService; + + private Type fieldType; + + private final String name; + + /** + * Creates action input parameter. + * + * @param methodParameter to describe + * @param value used during sample invocation + * @param conversionService to apply to value + */ + public SpringActionInputParameter(MethodParameter methodParameter, Object value, ConversionService conversionService, + String name) { + + this.methodParameter = methodParameter; + this.value = value; + this.name = name; + this.conversionService = conversionService; + + Annotation[] annotations = methodParameter.getParameterAnnotations(); + Input inputAnnotation = null; + Select select = null; + + for (Annotation annotation : annotations) { + if (RequestBody.class.isInstance(annotation)) { + this.requestBody = (RequestBody) annotation; + } else if (RequestParam.class.isInstance(annotation)) { + this.requestParam = (RequestParam) annotation; + } else if (PathVariable.class.isInstance(annotation)) { + this.pathVariable = (PathVariable) annotation; + } else if (RequestHeader.class.isInstance(annotation)) { + this.requestHeader = (RequestHeader) annotation; + } else if (Input.class.isInstance(annotation)) { + inputAnnotation = (Input) annotation; + } else if (Select.class.isInstance(annotation)) { + select = (Select) annotation; + } + } + + /** + * Check if annotations indicate that is required, for now only for request params & headers + */ + boolean requiredByAnnotations = + (this.requestParam != null && this.requestParam.required()) + || + (this.requestHeader != null && this.requestHeader.required()); + + if (inputAnnotation != null) { + putInputConstraint(ActionInputParameter.MIN, Integer.MIN_VALUE, inputAnnotation.min()); + putInputConstraint(ActionInputParameter.MAX, Integer.MAX_VALUE, inputAnnotation.max()); + putInputConstraint(ActionInputParameter.MIN_LENGTH, Integer.MIN_VALUE, inputAnnotation.minLength()); + putInputConstraint(ActionInputParameter.MAX_LENGTH, Integer.MAX_VALUE, inputAnnotation.maxLength()); + putInputConstraint(ActionInputParameter.STEP, 0, inputAnnotation.step()); + putInputConstraint(ActionInputParameter.PATTERN, "", inputAnnotation.pattern()); + setReadOnly(!inputAnnotation.editable()); + + /** + * Check if annotations indicate that is required + */ + setRequired(inputAnnotation.required() || requiredByAnnotations); + + this.excluded = inputAnnotation.exclude(); + this.readOnly = inputAnnotation.readOnly(); + this.hidden = inputAnnotation.hidden(); + this.include = inputAnnotation.include(); + this.type = ParameterType.INPUT; + } else { + setReadOnly(select != null ? !select.editable() : !editable); + putInputConstraint(ActionInputParameter.REQUIRED, "", requiredByAnnotations); + } + if (inputAnnotation == null || inputAnnotation.value() == Type.FROM_JAVA) { + if (isArrayOrCollection() || isRequestBody()) { + this.fieldType = null; + } else if (DataTypeUtils.isNumber(getParameterType())) { + this.fieldType = Type.NUMBER; + } else { + this.fieldType = Type.TEXT; + } + } else { + this.fieldType = inputAnnotation.value(); + } + createResolver(methodParameter, select); + this.typeDescriptor = TypeDescriptor.nested(methodParameter, 0); + } + + public SpringActionInputParameter(MethodParameter methodParameter, Object value, String name) { + this(methodParameter, value, DEFAULT_CONVERSION_SERVICE, name); + } + + /** + * Creates new ActionInputParameter with default formatting conversion service. + * + * @param methodParameter holding metadata about the parameter + * @param value during sample method invocation + */ + + public SpringActionInputParameter(MethodParameter methodParameter, Object value) { + this(methodParameter, value, null); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void createResolver(MethodParameter methodParameter, Select select) { + + Class parameterType = methodParameter.getNestedParameterType(); + Class nested; + SuggestType type = SuggestType.INTERNAL; + if (select != null && select.required()) { + type = select.type(); + putInputConstraint(ActionInputParameter.REQUIRED, "", true); + } + + if (select != null && (select.options() != StringOptions.class || !isEnumType(parameterType))) { + this.resolver = new OptionsPossibleValuesResolver(select); + this.type = ParameterType.SELECT; + } else if (Enum[].class.isAssignableFrom(parameterType)) { + this.resolver = new FixedPossibleValuesResolver( + SimpleSuggest.wrap(parameterType.getComponentType().getEnumConstants()), type); + this.type = ParameterType.SELECT; + } else if (Enum.class.isAssignableFrom(parameterType)) { + this.resolver = new FixedPossibleValuesResolver(SimpleSuggest.wrap(parameterType.getEnumConstants()), type); + this.type = ParameterType.SELECT; + } else if (Collection.class.isAssignableFrom(parameterType)) { + TypeDescriptor descriptor = TypeDescriptor.nested(methodParameter, 1); + if (descriptor != null) { + nested = descriptor.getType(); + if (Enum.class.isAssignableFrom(nested)) { + this.resolver = new FixedPossibleValuesResolver(SimpleSuggest.wrap(nested.getEnumConstants()), type); + this.type = ParameterType.SELECT; + } + } + } + + } + + private boolean isEnumType(Class parameterType) { + + return Enum[].class.isAssignableFrom(parameterType) + || Enum.class.isAssignableFrom(parameterType) + || Collection.class.isAssignableFrom(parameterType) + && Enum.class.isAssignableFrom(TypeDescriptor.nested(this.methodParameter, 1).getType()); + } + + private void putInputConstraint(String key, Object defaultValue, Object value) { + + if (!value.equals(defaultValue)) { + this.inputConstraints.put(key, value); + } + } + + /** + * The value of the parameter at sample invocation time. + * + * @return value, may be null + */ + @Override + public Object getValue() { + return this.value; + } + + /** + * The value of the parameter at sample invocation time, formatted according to conversion configuration. + * + * @return value, may be null + */ + @Override + public String getValueFormatted() { + + if (this.value == null) { + return null; + } else { + return (String) conversionService.convert(this.value, this.typeDescriptor, TypeDescriptor.valueOf(String.class)); + } + } + + /** + * Gets HTML5 parameter type for input field according to {@link Type} annotation. + * + * @return the type + */ + @Override + public Type getHtmlInputFieldType() { + return this.fieldType; + } + + @Override + public void setHtmlInputFieldType(Type type) { + this.fieldType = type; + } + + @Override + public boolean isRequestBody() { + return this.requestBody != null; + } + + @Override + public boolean isRequestParam() { + return this.requestParam != null; + } + + @Override + public boolean isPathVariable() { + return this.pathVariable != null; + } + + @Override + public boolean isRequestHeader() { + return this.requestHeader != null; + } + + public boolean isInputParameter() { + + return this.type == ParameterType.INPUT + && this.requestBody == null + && this.pathVariable == null + && this.requestHeader == null + && this.requestParam == null; + } + + @Override + public String getRequestHeaderName() { + return isRequestHeader() ? this.requestHeader.value() : null; + } + + /** + * Has constraints defined via @Input annotation. Note that there might also be other kinds of + * constraints, e.g. @Select may define values for {@link #getPossibleValues}. + * + * @return true if parameter is constrained + */ + @Override + public boolean hasInputConstraints() { + return !this.inputConstraints.isEmpty(); + } + + @Override + public T getAnnotation(Class annotation) { + return this.methodParameter.getParameterAnnotation(annotation); + } + + /** + * Determines if request body input parameter has a hidden input property. + * + * @param property name or property path + * @return true if hidden + */ + boolean isHidden(String property) { + return arrayContains(this.hidden, property); + } + + boolean isReadOnly(String property) { + return (!this.editable || arrayContains(this.readOnly, property)); + } + + @Override + public void setReadOnly(boolean readOnly) { + + this.editable = !readOnly; + putInputConstraint(ActionInputParameter.EDITABLE, "", this.editable); + } + + @Override + public void setRequired(boolean required) { + putInputConstraint(ActionInputParameter.REQUIRED, "", required); + } + + boolean isIncluded(String property) { + + if (isExcluded(property)) { + return false; + } + + if (this.include == null || this.include.length == 0) { + return true; + } + + return containsPropertyIncludeValue(property); + } + + /** + * Find out if property is included by searching through all annotations. + * + * @param property + * @return + */ + private boolean containsPropertyIncludeValue(String property) { + return arrayContains(this.readOnly, property) + || arrayContains(this.hidden, property) + || arrayContains(this.include, property); + } + + /** + * Determines if request body input parameter should be excluded, considering {@link Input#exclude}. + * + * @param property name or property path + * @return true if excluded, false if no include statement found or not excluded + */ + private boolean isExcluded(String property) { + return this.excluded != null && arrayContains(this.excluded, property); + } + + private boolean arrayContains(String[] array, String toFind) { + + if (array == null || array.length == 0) { + return false; + } + + for (String item : array) { + if (toFind.equals(item)) { + return true; + } + } + + return false; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public List> getPossibleValues(ActionDescriptor actionDescriptor) { + + List from = new ArrayList(); + + for (String paramName : resolver.getParams()) { + + ActionInputParameter parameterValue = actionDescriptor.getActionInputParameter(paramName); + + if (parameterValue != null) { + from.add(parameterValue.getValue()); + } + } + + return (List) resolver.getValues(from); + + } + + /* + * (non-Javadoc) + * @see ActionInputParameter#getOptions() + */ + @Override + public Suggestions getSuggestions() { + + org.springframework.hateoas.affordance.Select select = this.methodParameter + .getParameterAnnotation(org.springframework.hateoas.affordance.Select.class); + + if (select == null) { + return getDefaultSuggestions(getParameterType()); + } + + String[] strings = select.value(); + + if (strings.length > 0) { + return org.springframework.hateoas.affordance.Suggestions.values(strings); + } + + Class options = select.provider(); + + if (!options.equals(SuggestionsProvider.class)) { + + SuggestionsProvider instance = getBean(options); + + instance = instance == null ? BeanUtils.instantiateClass(options) : instance; + + return instance.getSuggestions(); + } + + return getDefaultSuggestions(getParameterType()); + } + + private static Suggestions getDefaultSuggestions(Class type) { + + if (type.isEnum()) { + return org.springframework.hateoas.affordance.Suggestions.values(type.getEnumConstants()); + } + + return org.springframework.hateoas.affordance.Suggestions.NONE; + } + + @Override + public void setPossibleValues(List> possibleValues) { + this.resolver = new FixedPossibleValuesResolver(possibleValues, this.resolver.getType()); + } + + @Override + public SuggestType getSuggestType() { + return this.resolver.getType(); + } + + @Override + public void setSuggestType(SuggestType type) { + this.resolver.setType(type); + } + + /** + * Determines if action input parameter is an array or collection. + * + * @return true if array or collection + */ + @Override + public boolean isArrayOrCollection() { + + if (this.arrayOrCollection == null) { + this.arrayOrCollection = DataTypeUtils.isArrayOrIterable(getParameterType()); + } + return this.arrayOrCollection; + } + + /** + * Is this action input parameter required, based on the presence of a default value, the parameter annotations and + * the kind of input parameter. + * + * @return true if required + */ + @Override + public boolean isRequired() { + + if (isRequestBody()) { + return this.requestBody.required(); + } else if (isRequestParam()) { + return !(isDefined(this.requestParam.defaultValue()) || !this.requestParam.required()); + } else if (isRequestHeader()) { + return !(isDefined(this.requestHeader.defaultValue()) || !this.requestHeader.required()); + } else { + return true; + } + } + + private boolean isDefined(String defaultValue) { + return !ValueConstants.DEFAULT_NONE.equals(defaultValue); + } + + /** + * Determines default value of request param or request header, if available. + * + * @return value or null + */ + public String getDefaultValue() { + + if (isRequestParam()) { + return isDefined(this.requestParam.defaultValue()) ? this.requestParam.defaultValue() : null; + } else if (isRequestHeader()) { + return !(ValueConstants.DEFAULT_NONE.equals(this.requestHeader.defaultValue())) ? this.requestHeader.defaultValue() : null; + } else { + return null; + } + } + + /** + * Allows convenient access to multiple call values in case that this input parameter is an array or collection. Make + * sure to check {@link #isArrayOrCollection()} before calling this method. + * + * @return call values or empty array + * @throws UnsupportedOperationException if this input parameter is not an array or collection + */ + @Override + public Object[] getValues() { + + if (!isArrayOrCollection()) { + throw new UnsupportedOperationException("parameter is not an array or collection"); + } + + if (getValue() == null) { + return new Object[0]; + } else { + if (getParameterType().isArray()) { + return (Object[]) getValue(); + } else { + return ((Collection) getValue()).toArray(); + } + } + } + + /** + * Was a sample call value recorded for this parameter? + * + * @return if call value is present + */ + @Override + public boolean hasValue() { + return this.value != null; + } + + /** + * Gets parameter name of this action input parameter. + * + * @return name + */ + @Override + public String getParameterName() { + + String parameterName = this.methodParameter.getParameterName(); + + if (parameterName == null) { + this.methodParameter.initParameterNameDiscovery(new LocalVariableTableParameterNameDiscoverer()); + return this.methodParameter.getParameterName(); + } else { + return parameterName; + } + } + + /** + * Class which declares the method to which this input parameter belongs. + * + * @return class + */ + public Class getDeclaringClass() { + return methodParameter.getDeclaringClass(); + } + + /** + * Type of parameter. + * + * @return type + */ + @Override + public Class getParameterType() { + return methodParameter.getParameterType(); + } + + /** + * Generic type of parameter. + * + * @return generic type + */ + @Override + public java.lang.reflect.Type getGenericParameterType() { + return methodParameter.getGenericParameterType(); + } + + /** + * Gets the input constraints defined for this action input parameter. + * + * @return constraints + */ + @Override + public Map getInputConstraints() { + return inputConstraints; + } + + @Override + public String toString() { + + String kind; + + if (isRequestBody()) { + kind = "RequestBody"; + } else if (isPathVariable()) { + kind = "PathVariable"; + } else if (isRequestParam()) { + kind = "RequestParam"; + } else if (isRequestHeader()) { + kind = "RequestHeader"; + } else { + kind = "nested bean property"; + } + + return kind + (getParameterName() != null ? " " + getParameterName() : "") + ": " + + (this.value != null ? this.value.toString() : "no value"); + } + + private static , V> Options getOptions(Class> beanType) { + + Options options = getBean(beanType); + if (options == null) { + try { + options = BeanUtils.instantiateClass(beanType); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return options; + } + + private static T getBean(Class beanType) { + + try { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + HttpServletRequest servletRequest = ((ServletRequestAttributes) requestAttributes).getRequest(); + + WebApplicationContext context = WebApplicationContextUtils + .getWebApplicationContext(servletRequest.getSession().getServletContext()); + + Map beans = context.getBeansOfType(beanType); + + if (!beans.isEmpty()) { + return beans.values().iterator().next(); + } + } catch (Exception e) {} + return null; + } + + public void setExcluded(String[] excluded) { + this.excluded = excluded; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public ParameterType getType() { + return this.type; + } + + public void setType(ParameterType type) { + this.type = type; + } + + interface PossibleValuesResolver { + + String[] getParams(); + + List> getValues(List value); + + SuggestType getType(); + + void setType(SuggestType type); + } + + class FixedPossibleValuesResolver implements PossibleValuesResolver { + + private final List> values; + private SuggestType type; + + public FixedPossibleValuesResolver(List> values, SuggestType type) { + + this.values = values; + this.type = type; + } + + @Override + public String[] getParams() { + return EMPTY; + } + + @Override + public List> getValues(List value) { + return this.values; + } + + @Override + public SuggestType getType() { + return this.type; + } + + @Override + public void setType(SuggestType type) { + this.type = type; + } + + } + + class OptionsPossibleValuesResolver implements PossibleValuesResolver { + + private final Options options; + private final Select select; + + private SuggestType type; + + @SuppressWarnings("unchecked") + public OptionsPossibleValuesResolver(Select select) { + + this.select = select; + this.type = select.type(); + this.options = getOptions((Class>) select.options()); + } + + @Override + public String[] getParams() { + return this.select.args(); + } + + @Override + public List> getValues(List args) { + return this.options.get(select.value(), args.toArray()); + } + + @Override + public SuggestType getType() { + return this.type; + } + + @Override + public void setType(SuggestType type) { + this.type = type; + } + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/UrlPrefixDocumentationProvider.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/UrlPrefixDocumentationProvider.java new file mode 100644 index 000000000..898743eed --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/UrlPrefixDocumentationProvider.java @@ -0,0 +1,87 @@ +/* + * Copyright 2014-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.affordance.springmvc; + +import java.beans.Introspector; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.springframework.hateoas.affordance.ActionInputParameter; +import org.springframework.util.Assert; + +/** + * Provides documentation URLs by applying an URL prefix. + * + * @author Dietrich Schulten + */ +public class UrlPrefixDocumentationProvider implements DocumentationProvider { + + private String defaultUrlPrefix; + + public UrlPrefixDocumentationProvider(String defaultUrlPrefix) { + + Assert.isTrue(defaultUrlPrefix.endsWith("/") || defaultUrlPrefix.endsWith("#"), + "URL prefix should end with separator / or #"); + this.defaultUrlPrefix = defaultUrlPrefix; + } + + public UrlPrefixDocumentationProvider() { + this.defaultUrlPrefix = ""; + } + + /* + * @see DocumentationProvider#getDocumentationUrl + */ + @Override + public String getDocumentationUrl(ActionInputParameter annotatedParameter, Object content) { + return this.defaultUrlPrefix + annotatedParameter.getParameterName(); + } + + /* + * @see DocumentationProvider#getDocumentationUrl + */ + @Override + public String getDocumentationUrl(Field field, Object content) { + return this.defaultUrlPrefix + field.getName(); + } + + /* + * @see DocumentationProvider#getDocumentationUrl + */ + @Override + public String getDocumentationUrl(Method getter, Object content) { + + String methodName = getter.getName(); + String propertyName = Introspector.decapitalize(methodName.substring(methodName.startsWith("is") ? 2 : 3)); + return this.defaultUrlPrefix + propertyName; + } + + /* + * @see DocumentationProvider#getDocumentationUrl + */ + @Override + public String getDocumentationUrl(Class clazz, Object content) { + return this.defaultUrlPrefix + clazz.getSimpleName(); + } + + /* + * @see DocumentationProvider#getDocumentationUrl + */ + @Override + public String getDocumentationUrl(String name, Object content) { + return this.defaultUrlPrefix + name; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/springmvc/package-info.java b/src/main/java/org/springframework/hateoas/affordance/springmvc/package-info.java new file mode 100644 index 000000000..664ba4775 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/springmvc/package-info.java @@ -0,0 +1,40 @@ +/** + * Collaborators which are needed to collect information about affordances. The {@link + * org.springframework.hateoas.affordance.springmvc.AffordanceBuilder} serves as starting point for affordance creation. Affordance creation + * works like this: + *
    + *
  1. + * Dummy call to {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilder#linkTo} methods passes + * sample arguments for the URI template. + *
  2. + *
  3. + * The {@code AffordanceBuilder} doesn't create an instance of itself, rather it delegates to + * {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilderFactory} for creation. + * The {@code AffordanceBuilderFactory} analyzes the request mapping, the sample arguments and the + * target handler method to create an {@code AffordanceBuilder}. The new {@code AffordanceBuilder} receives: + *
      + *
    • + * a {@link org.springframework.hateoas.UriTemplate} created from request mapping information with applied sample + * arguments. Template variables which could not be satisfied are kept as variables, no matter if they are required or + * optional + *
    • + *
    • + * an {@link org.springframework.hateoas.affordance.ActionDescriptor} which represents the method that + * handles requests to the URI template resource + *
    • + *
    + *
  4. The affordance builder has methods to supply information + * which is necessary to create a link from it, such as {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilder#rel + * (java.lang.String, + * java.lang.String...)} and other rfc-5988 link parameters + *
  5. + *
  6. + * Finally, {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilder#build} creates the affordance. As a convenience one can also use + * {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilder#withRel} or {@link org.springframework.hateoas.affordance.springmvc.AffordanceBuilder#withSelfRel} + * to create a link with a single rel. + *
  7. + * + *
+ */ +package org.springframework.hateoas.affordance.springmvc; + diff --git a/src/main/java/org/springframework/hateoas/affordance/support/DataTypeUtils.java b/src/main/java/org/springframework/hateoas/affordance/support/DataTypeUtils.java new file mode 100644 index 000000000..22351758b --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/support/DataTypeUtils.java @@ -0,0 +1,219 @@ +/* + * Copyright 2013-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.affordance.support; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Calendar; +import java.util.Currency; +import java.util.Date; + +import javax.xml.bind.DatatypeConverter; + +/** + * Collection of utility methods to handle various data types. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public final class DataTypeUtils { + + /** + * Determines if the given class holds only one data item. Can be useful to determine if a value should be rendered as + * scalar. + * + * @param clazz to check + * @return true if class is scalar + */ + public static boolean isSingleValueType(Class clazz) { + + return isNumber(clazz) || + isBoolean(clazz) || + isString(clazz) || + isEnum(clazz) || + isDate(clazz) || + isCalendar(clazz) || + isCurrency(clazz); + } + + /** + * Is this class a container of multiple values? + * + * @param parameterType + * @return + */ + public static boolean isArrayOrIterable(Class parameterType) { + return (parameterType.isArray() || Iterable.class.isAssignableFrom(parameterType)); + } + + /** + * Determine if the given class is of a numeric type. + * + * @param clazz + * @return + */ + public static boolean isNumber(Class clazz) { + + return Number.class.isAssignableFrom(clazz) || + isInteger(clazz) || + isLong(clazz) || + isFloat(clazz) || + isDouble(clazz) || + isByte(clazz) || + isShort(clazz); + } + + /* + * Check various numeric types, boxed, and scalar. + */ + + public static boolean isInteger(Class clazz) { + return (Integer.class.isAssignableFrom(clazz) || int.class == clazz); + } + + public static boolean isLong(Class clazz) { + return (Long.class.isAssignableFrom(clazz) || long.class == clazz); + } + + public static boolean isFloat(Class clazz) { + return (Float.class.isAssignableFrom(clazz) || float.class == clazz); + } + + public static boolean isDouble(Class clazz) { + return (Double.class.isAssignableFrom(clazz) || double.class == clazz); + } + + public static boolean isByte(Class clazz) { + return (Byte.class.isAssignableFrom(clazz) || byte.class == clazz); + } + + public static boolean isShort(Class clazz) { + return (Short.class.isAssignableFrom(clazz) || short.class == clazz); + } + + public static boolean isBigInteger(Class clazz) { + return BigInteger.class.isAssignableFrom(clazz); + } + + public static boolean isBigDecimal(Class clazz) { + return BigDecimal.class.isAssignableFrom(clazz); + } + + /* + * Check other Java types + */ + + public static boolean isBoolean(Class clazz) { + return (Boolean.class.isAssignableFrom(clazz) || boolean.class == clazz); + } + + public static boolean isEnum(Class clazz) { + return Enum.class.isAssignableFrom(clazz); + } + + public static boolean isString(Class parameterType) { + return String.class.isAssignableFrom(parameterType); + } + + public static boolean isDate(Class clazz) { + return Date.class.isAssignableFrom(clazz); + } + + public static boolean isCalendar(Class clazz) { + return Calendar.class.isAssignableFrom(clazz); + } + + public static boolean isCurrency(Class clazz) { + return Currency.class.isAssignableFrom(clazz); + } + + /** + * Convert a string-based value into it's proper type. + * + * @param type + * @param value + * @return + */ + public static Object asType(Class type, String value) { + + if (isBoolean(type)) { + return Boolean.parseBoolean(value); + } else if (isInteger(type)) { + return Integer.parseInt(value); + } else if (isLong(type)) { + return Long.parseLong(value); + } else if (isDouble(type)) { + return Double.parseDouble(value); + } else if (isFloat(type)) { + return Float.parseFloat(value); + } else if (isByte(type)) { + return Byte.parseByte(value); + } else if (isShort(type)) { + return Short.parseShort(value); + } else if (isBigInteger(type)) { + return new BigInteger(value); + } else if (isBigDecimal(type)) { + return new BigDecimal(value); + } else if (isCalendar(type)) { + return DatatypeConverter.parseDateTime(value); + } else if (isDate(type)) { + if (isIsoLatin1Number(value)) { + return new Date(Long.parseLong(value)); + } else { + return DatatypeConverter.parseDateTime(value).getTime(); + } + } else if (isCurrency(type)) { + return Currency.getInstance(value); + } else if (type.isEnum()) { + return Enum.valueOf((Class) type, value); + } else { + return value; + } + } + + /** + * Determines if the given string contains only 0-9 [ISO-LATIN-1] or an optional leading +/- sign. + * + * @param value to check + * @return true if condition holds, false otherwise + * @see Comparison of regex and char array performance + * @see Character#isDigit Examples for non-ISO-Latin-1-Digits + */ + public static boolean isIsoLatin1Number(String value) { + + // For null or empty strings, no match + if (value == null || value.isEmpty()) { + return false; + } + + // Start from the beginning + int index = 0; + + // If it starts with + or -, skip it + if (value.startsWith("-") || value.startsWith("+")) { + index = 1; + } + + // Iterate over remaining digits, and break out when the first one fails to be a digit + for (char[] data = value.toCharArray(); index < data.length; index++) { + if (data[index] < '0' || data[index] > '9') { + return false; + } + } + return true; + } +} diff --git a/src/main/java/org/springframework/hateoas/affordance/support/Path.java b/src/main/java/org/springframework/hateoas/affordance/support/Path.java new file mode 100644 index 000000000..a3de6d23a --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/support/Path.java @@ -0,0 +1,76 @@ +/* + * Copyright 2013-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.affordance.support; + +import static org.springframework.hateoas.core.DummyInvocationUtils.*; + +import java.util.Collection; + +import org.springframework.core.ResolvableType; +import org.springframework.hateoas.core.DummyInvocationUtils.*; +import org.springframework.util.Assert; + +/** + * Callbacks to build up path to various attributes of an affordance definition. + * + * @see {@link org.springframework.hateoas.core.DummyInvocationUtils} + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public class Path { + + static ThreadLocal interceptorThreadLocal = new ThreadLocal(); + + public static T on(Class type) { + return on(type, true); + } + + public static T on(Class type, boolean init) { + + if (init) { + interceptorThreadLocal.remove(); + interceptorThreadLocal.set(new InvocationRecordingMethodInterceptor(type)); + } + return getProxyWithInterceptor(type, interceptorThreadLocal.get(), type.getClassLoader()); + } + + public static T on(Class type, InvocationRecordingMethodInterceptor rmi) { + return getProxyWithInterceptor(type, rmi, type.getClassLoader()); + } + + public static String path(Object obj) { + + InvocationRecordingMethodInterceptor interceptor = interceptorThreadLocal.get(); + Assert.notNull(interceptor, "Path.on(Class) should be called first"); + + interceptorThreadLocal.remove(); + return interceptor.getLastInvocation().toString(); + } + + @SuppressWarnings("unchecked") + public static T collection(Collection collection) { + + Assert.isInstanceOf(LastInvocationAware.class, collection); + + ResolvableType resolvable = ResolvableType + .forMethodReturnType(((LastInvocationAware) collection).getLastInvocation().getMethod()); + + return on((Class) resolvable.getGeneric(0).getRawClass(), false); + } + +} diff --git a/src/main/java/org/springframework/hateoas/affordance/support/PropertyUtils.java b/src/main/java/org/springframework/hateoas/affordance/support/PropertyUtils.java new file mode 100644 index 000000000..10c7c3f4d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/affordance/support/PropertyUtils.java @@ -0,0 +1,156 @@ +/* + * Copyright 2014-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.affordance.support; + +import static org.springframework.core.annotation.AnnotationUtils.*; + +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.ReflectionUtils; + +/** + * Convenience methods to work with Java bean properties. + * + * @author Dietrich Schulten + * @author Greg Turnquist + */ +public final class PropertyUtils { + + /** + * Find all the {@link PropertyDescriptor}s and collect them into a math based on property name. + * + * @param bean + * @return {@link Map} of properties + */ + public static Map getPropertyDescriptors(Object bean) { + + try { + Map results = new HashMap(); + + for (PropertyDescriptor propertyDescriptor : Introspector.getBeanInfo(bean.getClass()).getPropertyDescriptors()) { + results.put(propertyDescriptor.getName(), propertyDescriptor); + } + + return results; + } catch (IntrospectionException e) { + throw new RuntimeException("failed to get property descriptors of bean " + bean, e); + } + } + + /** + * Find the constructor that has no arguments. + * + * @param clazz - class to use for searching constructors + * @return + */ + public static Constructor findDefaultConstructor(Class clazz) { + + for (Constructor candidate : clazz.getConstructors()) { + if (candidate.getParameterTypes().length == 0) { + return candidate; + } + } + + return null; + } + + /** + * Find a {@link Constructor} that has a specific {@link Annotation} applied. + * + * @param clazz + * @param markerAnnotation + * @return + */ + public static Constructor findConstructorByAnnotation(Class clazz, + Class markerAnnotation) { + + for (Constructor candidate : clazz.getConstructors()) { + if (getAnnotation(candidate, markerAnnotation) != null) { + return candidate; + } + } + + return null; + } + + /** + * With a given object, look up either a property value or a field name's value. + * + * TODO: Remove current method for propertyDescriptors, cache search results + * + * @param currentCallValue + * @param propertyOrFieldName + * @return + */ + public static Object getPropertyOrFieldValue(Object currentCallValue, String propertyOrFieldName) { + + if (currentCallValue == null) { + return null; + } + + Object propertyValue = getBeanPropertyValue(currentCallValue, propertyOrFieldName); + + if (propertyValue == null) { + Field field = ReflectionUtils.findField(currentCallValue.getClass(), propertyOrFieldName); + ReflectionUtils.makeAccessible(field); + try { + propertyValue = field.get(currentCallValue); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to read field " + propertyOrFieldName + " from " + currentCallValue.toString(), e); + } + } + + return propertyValue; + } + + /** + * Look up {@link java.beans.BeanInfo} for an object, and then find the getter. + * + * @param currentCallValue + * @param paramName + * @return results of {@literal paramName}'s getter or {@link null} + */ + private static Object getBeanPropertyValue(Object currentCallValue, String paramName) { + + if (currentCallValue == null) { + return null; + } + + try { + for (PropertyDescriptor pd : Introspector.getBeanInfo(currentCallValue.getClass()).getPropertyDescriptors()) { + if (paramName.equals(pd.getName())) { + Method readMethod = pd.getReadMethod(); + if (readMethod != null) { + ReflectionUtils.makeAccessible(readMethod); + return readMethod.invoke(currentCallValue); + } + } + } + return null; + } catch (Exception e) { + throw new RuntimeException("Failed to read property " + paramName + " from " + currentCallValue.toString(), e); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/alps/Alps.java b/src/main/java/org/springframework/hateoas/alps/Alps.java index 5e6e7acc7..aaf92e4d3 100644 --- a/src/main/java/org/springframework/hateoas/alps/Alps.java +++ b/src/main/java/org/springframework/hateoas/alps/Alps.java @@ -15,19 +15,17 @@ */ package org.springframework.hateoas.alps; -import lombok.Builder; -import lombok.Value; - import java.util.List; -import org.springframework.hateoas.alps.Descriptor.DescriptorBuilder; -import org.springframework.hateoas.alps.Doc.DocBuilder; -import org.springframework.hateoas.alps.Ext.ExtBuilder; +import lombok.Builder; +import lombok.Singular; +import lombok.Value; /** * An ALPS document. * * @author Oliver Gierke + * @author Greg Turnquist * @since 0.15 * @see http://alps.io * @see http://alps.io/spec/#prop-alps @@ -37,33 +35,11 @@ public class Alps { private final String version = "1.0"; - private final Doc doc; - private final List descriptors; - /** - * Returns a new {@link DescriptorBuilder}. - * - * @return - */ - public static DescriptorBuilder descriptor() { - return Descriptor.builder(); - } + private final Doc doc; - /** - * Returns a new {@link DocBuilder}. - * - * @return - */ - public static DocBuilder doc() { - return Doc.builder(); - } + @Singular + private final List descriptors; - /** - * Retruns a new {@link ExtBuilder}. - * - * @return - */ - public static ExtBuilder ext() { - return Ext.builder(); - } + private final Ext ext; } diff --git a/src/main/java/org/springframework/hateoas/alps/Descriptor.java b/src/main/java/org/springframework/hateoas/alps/Descriptor.java index 753109765..c9a5b8e72 100644 --- a/src/main/java/org/springframework/hateoas/alps/Descriptor.java +++ b/src/main/java/org/springframework/hateoas/alps/Descriptor.java @@ -15,11 +15,11 @@ */ package org.springframework.hateoas.alps; +import java.util.List; + import lombok.Builder; import lombok.Value; -import java.util.List; - /** * A value object for an ALPS descriptor. * @@ -28,7 +28,7 @@ * @see http://alps.io/spec/#prop-descriptor */ @Value -@Builder +@Builder(builderMethodName = "descriptor") public class Descriptor { private final String id, href, name; diff --git a/src/main/java/org/springframework/hateoas/alps/Doc.java b/src/main/java/org/springframework/hateoas/alps/Doc.java index 7e9425874..4f9ec8d65 100644 --- a/src/main/java/org/springframework/hateoas/alps/Doc.java +++ b/src/main/java/org/springframework/hateoas/alps/Doc.java @@ -29,7 +29,7 @@ * @see http://alps.io/spec/#prop-doc */ @Value -@Builder +@Builder(builderMethodName = "doc") @AllArgsConstructor public class Doc { diff --git a/src/main/java/org/springframework/hateoas/alps/Ext.java b/src/main/java/org/springframework/hateoas/alps/Ext.java index ed31ad27f..3bf813e46 100644 --- a/src/main/java/org/springframework/hateoas/alps/Ext.java +++ b/src/main/java/org/springframework/hateoas/alps/Ext.java @@ -26,7 +26,7 @@ * @see http://alps.io/spec/#prop-ext */ @Value -@Builder +@Builder(builderMethodName = "ext") public class Ext { private final String id; diff --git a/src/main/java/org/springframework/hateoas/client/Hop.java b/src/main/java/org/springframework/hateoas/client/Hop.java index 49b020aa6..19a7ce375 100644 --- a/src/main/java/org/springframework/hateoas/client/Hop.java +++ b/src/main/java/org/springframework/hateoas/client/Hop.java @@ -15,16 +15,16 @@ */ package org.springframework.hateoas.client; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Value; import lombok.experimental.Wither; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - import org.springframework.util.Assert; /** diff --git a/src/main/java/org/springframework/hateoas/client/Traverson.java b/src/main/java/org/springframework/hateoas/client/Traverson.java index 9f9d22578..e02dc9d5d 100644 --- a/src/main/java/org/springframework/hateoas/client/Traverson.java +++ b/src/main/java/org/springframework/hateoas/client/Traverson.java @@ -388,7 +388,7 @@ private String traverseToFinalUrl() { private URI traverseToExpandedFinalUrl() { String uri = getAndFindLinkWithRel(baseUri.toString(), rels.iterator()); - return new UriTemplate(uri).expand(templateParameters); + return new UriTemplate(uri).expand(templateParameters).toUri(); } private String getAndFindLinkWithRel(String uri, Iterator rels) { @@ -400,7 +400,8 @@ private String getAndFindLinkWithRel(String uri, Iterator rels) { HttpEntity request = prepareRequest(headers); UriTemplate template = new UriTemplate(uri); - ResponseEntity responseEntity = operations.exchange(template.expand(), GET, request, String.class); + ResponseEntity responseEntity = operations.exchange(template.expand().toUri(), GET, request, + String.class); MediaType contentType = responseEntity.getHeaders().getContentType(); String responseBody = responseEntity.getBody(); diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index d5feb4a09..5e4156a28 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -20,11 +20,22 @@ 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.Collection; +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.config.EnableHypermediaSupport.HypermediaConfigurationImportSelector; +import org.springframework.hateoas.hal.forms.HalFormsConfiguration; /** * Activates hypermedia support in the {@link ApplicationContext}. Will register infrastructure beans available for @@ -43,7 +54,8 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) @Documented -@Import({ HypermediaSupportBeanDefinitionRegistrar.class, HateoasConfiguration.class }) +@Import({ HypermediaSupportBeanDefinitionRegistrar.class, HateoasConfiguration.class, + HypermediaConfigurationImportSelector.class }) public @interface EnableHypermediaSupport { /** @@ -51,14 +63,14 @@ * * @return */ - HypermediaType[] type(); + HypermediaType[] type() default {}; /** * Hypermedia representation types supported. * * @author Oliver Gierke */ - static enum HypermediaType { + enum HypermediaType { /** * HAL - Hypermedia Application Language. @@ -66,6 +78,42 @@ static enum HypermediaType { * @see http://stateless.co/hal_specification.html * @see http://tools.ietf.org/html/draft-kelly-json-hal-05 */ - HAL; + HAL, + + HAL_FORMS(HalFormsConfiguration.class.getName()); + + private List configurations; + + HypermediaType(String... configurations) { + this.configurations = Arrays.asList(configurations); + } + } + + @Slf4j + class HypermediaConfigurationImportSelector implements ImportSelector { + + /* + * (non-Javadoc) + * @see org.springframework.context.annotation.ImportSelector#selectImports(org.springframework.core.type.AnnotationMetadata) + */ + @Override + public String[] selectImports(AnnotationMetadata metadata) { + + Map attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName()); + + Collection types = Arrays.asList((HypermediaType[]) attributes.get("type")); + types = types.isEmpty() ? Arrays.asList(HypermediaType.values()) : types; + + log.debug("Registering support for hypermedia types {} according configuration on {}.", types, + metadata.getClassName()); + + List configurations = new ArrayList(); + + for (HypermediaType type : types) { + configurations.addAll(type.configurations); + } + + return configurations.toArray(new String[configurations.size()]); + } } } diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java index 6268de0f3..f441c8e75 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaSupportBeanDefinitionRegistrar.java @@ -55,6 +55,8 @@ import org.springframework.hateoas.hal.CurieProvider; import org.springframework.hateoas.hal.HalLinkDiscoverer; import org.springframework.hateoas.hal.Jackson2HalModule; +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; @@ -75,12 +77,14 @@ * activated as well). * * @author Oliver Gierke + * @author Greg Turnquist */ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { private static final String DELEGATING_REL_PROVIDER_BEAN_NAME = "_relProvider"; private static final String LINK_DISCOVERER_REGISTRY_BEAN_NAME = "_linkDiscovererRegistry"; 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", @@ -107,24 +111,20 @@ public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionR if (JSONPATH_PRESENT) { AbstractBeanDefinition linkDiscovererBeanDefinition = getLinkDiscovererBeanDefinition(type); - registerBeanDefinition(new BeanDefinitionHolder(linkDiscovererBeanDefinition, - BeanDefinitionReaderUtils.generateBeanName(linkDiscovererBeanDefinition, registry)), registry); + + if (linkDiscovererBeanDefinition != null) { + registerBeanDefinition(new BeanDefinitionHolder(linkDiscovererBeanDefinition, + BeanDefinitionReaderUtils.generateBeanName(linkDiscovererBeanDefinition, registry)), registry); + } } } 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 (types.contains(HypermediaType.HAL_FORMS)) { + registerHypermediaComponents(metadata, registry, HAL_FORMS_OBJECT_MAPPER_BEAN_NAME); } if (!types.isEmpty()) { @@ -143,6 +143,21 @@ public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionR 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); + } + } + /** * 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. @@ -188,8 +203,11 @@ private AbstractBeanDefinition getLinkDiscovererBeanDefinition(HypermediaType ty 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)); + return null; } definition.setSource(this); @@ -283,21 +301,43 @@ private List> potentiallyRegisterModule(List> result = new ArrayList>(converters.size()); + + 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()); - halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, + halObjectMapper.registerModule(new Jackson2HalModule()); + halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider, linkRelationMessageSource, beanFactory)); - MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( + MappingJackson2HttpMessageConverter halConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( ResourceSupport.class); - halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON)); - halConverter.setObjectMapper(halObjectMapper); + halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON)); + halConverter.setObjectMapper(halObjectMapper); + result.add(halConverter); + } - List> result = new ArrayList>(converters.size()); - 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()); + halFormsObjectMapper.setHandlerInstantiator(new Jackson2HalFormsModule.HalFormsHandlerInstantiator(relProvider, curieProvider, + linkRelationMessageSource, true)); + + MappingJackson2HttpMessageConverter halFormsConverter = new TypeConstrainedMappingJackson2HttpMessageConverter( + ResourceSupport.class); + halFormsConverter.setSupportedMediaTypes(Arrays.asList(HAL_FORMS_JSON)); + halFormsConverter.setObjectMapper(halFormsObjectMapper); + result.add(halFormsConverter); + } + result.addAll(converters); return result; } @@ -317,6 +357,7 @@ private static CurieProvider getCurieProvider(BeanFactory factory) { * the methods to do that on {@link Jackson2ObjectMapperFactoryBean} were introduced in Spring 4.1 only. * * @author Oliver Gierke + * @author Greg Turnquist */ private static class DefaultObjectMapperCustomizer implements BeanPostProcessor { @@ -327,14 +368,13 @@ private static class DefaultObjectMapperCustomizer implements BeanPostProcessor @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/DummyInvocationUtils.java b/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java index 7a899579a..fabdd433c 100644 --- a/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java +++ b/src/main/java/org/springframework/hateoas/core/DummyInvocationUtils.java @@ -15,15 +15,17 @@ */ package org.springframework.hateoas.core; -import lombok.NonNull; -import lombok.Value; - +import java.beans.Introspector; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.Arrays; import java.util.Iterator; import java.util.Map; +import lombok.NonNull; +import lombok.Value; import org.aopalliance.intercept.MethodInterceptor; + import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.target.EmptyTargetSource; import org.springframework.cglib.proxy.Callback; @@ -60,7 +62,7 @@ public interface LastInvocationAware { * * @author Oliver Gierke */ - private static class InvocationRecordingMethodInterceptor + public static class InvocationRecordingMethodInterceptor implements MethodInterceptor, LastInvocationAware, org.springframework.cglib.proxy.MethodInterceptor { private static final Method GET_INVOCATIONS; @@ -82,7 +84,7 @@ private static class InvocationRecordingMethodInterceptor * @param targetType must not be {@literal null}. * @param parameters must not be {@literal null}. */ - InvocationRecordingMethodInterceptor(Class targetType, Object... parameters) { + public InvocationRecordingMethodInterceptor(Class targetType, Object... parameters) { Assert.notNull(targetType, "Target type must not be null!"); Assert.notNull(parameters, "Parameters must not be null!"); @@ -98,6 +100,10 @@ private static class InvocationRecordingMethodInterceptor @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) { +// if (method.getName().equals("toString")) { +// return "Debugging?"; +// } + if (GET_INVOCATIONS.equals(method)) { return getLastInvocation(); } else if (GET_OBJECT_PARAMETERS.equals(method)) { @@ -106,10 +112,14 @@ public Object intercept(Object obj, Method method, Object[] args, MethodProxy pr return ReflectionUtils.invokeMethod(method, obj, args); } - this.invocation = new SimpleMethodInvocation(targetType, method, args); + this.invocation = new SimpleMethodInvocation(targetType, method, args, getLastInvocation()); Class returnType = method.getReturnType(); - return returnType.cast(getProxyWithInterceptor(returnType, this, obj.getClass().getClassLoader())); + if (Modifier.isFinal(returnType.getModifiers())) { + return null; + } else { + return returnType.cast(getProxyWithInterceptor(returnType, this, obj.getClass().getClassLoader())); + } } /* @@ -161,7 +171,7 @@ public static T methodOn(Class type, Object... parameters) { } @SuppressWarnings("unchecked") - private static T getProxyWithInterceptor(Class type, InvocationRecordingMethodInterceptor interceptor, + public static T getProxyWithInterceptor(Class type, InvocationRecordingMethodInterceptor interceptor, ClassLoader classLoader) { if (type.isInterface()) { @@ -174,12 +184,6 @@ private static T getProxyWithInterceptor(Class type, InvocationRecordingM return (T) factory.getProxy(); } - Enhancer enhancer = new Enhancer(); - enhancer.setSuperclass(type); - enhancer.setInterfaces(new Class[] { LastInvocationAware.class }); - enhancer.setCallbackType(org.springframework.cglib.proxy.MethodInterceptor.class); - enhancer.setClassLoader(classLoader); - Factory factory = (Factory) OBJENESIS.newInstance(getOrCreateEnhancedClass(type, classLoader)); factory.setCallbacks(new Callback[] { interceptor }); return (T) factory; @@ -231,5 +235,15 @@ static class SimpleMethodInvocation implements MethodInvocation { @NonNull Class targetType; @NonNull Method method; @NonNull Object[] arguments; + MethodInvocation invocation; + + @Override + public String toString() { + return (invocation != null ? invocation.toString() + "." : "") + getPropertyFromMethod(method); + } + + private String getPropertyFromMethod(Method method) { + return Introspector.decapitalize(method.getName().substring(method.getName().startsWith("is") ? 2 : 3)); + } } } diff --git a/src/main/java/org/springframework/hateoas/core/EncodingUtils.java b/src/main/java/org/springframework/hateoas/core/EncodingUtils.java index 953f584df..a9b4480e4 100644 --- a/src/main/java/org/springframework/hateoas/core/EncodingUtils.java +++ b/src/main/java/org/springframework/hateoas/core/EncodingUtils.java @@ -15,10 +15,10 @@ */ package org.springframework.hateoas.core; -import lombok.experimental.UtilityClass; - import java.io.UnsupportedEncodingException; +import lombok.experimental.UtilityClass; + import org.springframework.util.Assert; import org.springframework.web.util.UriUtils; diff --git a/src/main/java/org/springframework/hateoas/core/EvoInflectorRelProvider.java b/src/main/java/org/springframework/hateoas/core/EvoInflectorRelProvider.java index f9b380db0..021929da7 100644 --- a/src/main/java/org/springframework/hateoas/core/EvoInflectorRelProvider.java +++ b/src/main/java/org/springframework/hateoas/core/EvoInflectorRelProvider.java @@ -16,6 +16,7 @@ package org.springframework.hateoas.core; import org.atteo.evo.inflector.English; + import org.springframework.hateoas.RelProvider; /** diff --git a/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java b/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java index 1c340dd8e..67b5def88 100644 --- a/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java +++ b/src/main/java/org/springframework/hateoas/core/JsonPathLinkDiscoverer.java @@ -155,7 +155,7 @@ private JsonPath getExpression(String rel) { * @param rel the relation type that was parsed for. * @return */ - private List createLinksFrom(Object parseResult, String rel) { + protected List createLinksFrom(Object parseResult, String rel) { if (parseResult instanceof JSONArray) { diff --git a/src/main/java/org/springframework/hateoas/core/Recorder.java b/src/main/java/org/springframework/hateoas/core/Recorder.java new file mode 100644 index 000000000..1bdd8bfaa --- /dev/null +++ b/src/main/java/org/springframework/hateoas/core/Recorder.java @@ -0,0 +1,157 @@ +package org.springframework.hateoas.core; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import org.springframework.cglib.proxy.Callback; +import org.springframework.cglib.proxy.Enhancer; +import org.springframework.cglib.proxy.Factory; +import org.springframework.cglib.proxy.MethodInterceptor; +import org.springframework.cglib.proxy.MethodProxy; +import org.springframework.core.convert.converter.Converter; +import org.springframework.objenesis.ObjenesisStd; +import org.springframework.util.StringUtils; + +@AllArgsConstructor +public class Recorder implements MethodInterceptor { + + private static ObjenesisStd OBJENESIS = new ObjenesisStd(); + + private Recorded currentMock = null; + private Method invokedMethod; + private final List strategies; + + public Recorder() { + this(null, null, Arrays.asList(DefaultPropertyNameDetectionStrategy.INSTANCE)); + } + + public Recorder register(PropertyNameDetectionStrategy strategy) { + + List strategies = new ArrayList( + this.strategies.size() + 1); + strategies.add(strategy); + strategies.addAll(this.strategies); + + return new Recorder(currentMock, invokedMethod, strategies); + } + + /* + * (non-Javadoc) + * @see org.springframework.cglib.proxy.MethodInterceptor#intercept(java.lang.Object, java.lang.reflect.Method, java.lang.Object[], org.springframework.cglib.proxy.MethodProxy) + */ + public Object intercept(Object o, Method method, Object[] os, MethodProxy mp) throws Throwable { + + if (method.getName().equals("getCurrentPropertyName")) { + return getCurrentPropertyName(); + } + + this.invokedMethod = method; + this.currentMock = forType(method.getReturnType()); + + return currentMock.getObject(); + } + + String getPropertyName(Method method) { + + for (PropertyNameDetectionStrategy strategy : strategies) { + + String propertyName = strategy.getPropertyName(method); + + if (propertyName != null) { + return propertyName; + } + } + + return null; + } + + public String getCurrentPropertyName() { + + String propertyName = getPropertyName(invokedMethod); + + if (currentMock == null) { + return propertyName; + } + + String next = currentMock.getCurrentPropertyName(); + + return StringUtils.hasText(next) ? propertyName.concat(".").concat(next) : propertyName; + } + + @SuppressWarnings("unchecked") + public static Recorded forType(Class type) { + + Recorder recordingObject = new Recorder(); + + Enhancer enhancer = new Enhancer(); + enhancer.setSuperclass(type); + enhancer.setCallbackType(Recorder.class); + + Factory factory = (Factory) OBJENESIS.newInstance(enhancer.createClass()); + factory.setCallbacks(new Callback[] { recordingObject }); + + return new Recorded((T) factory, recordingObject); + } + + public interface PropertyNameDetectionStrategy { + String getPropertyName(Method method); + } + + enum DefaultPropertyNameDetectionStrategy implements PropertyNameDetectionStrategy { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.core.Recorder.PropertyNameDetectionStrategy#getPropertyName(java.lang.reflect.Method) + */ + @Override + public String getPropertyName(Method method) { + return getPropertyName(method.getReturnType(), method.getName()); + } + + static String getPropertyName(Class type, String methodName) { + + String pattern = getPatternFor(type); + String replaced = methodName.replaceFirst(pattern, ""); + + return StringUtils.uncapitalize(replaced); + } + + private static String getPatternFor(Class type) { + return "^(get|set".concat(type.equals(boolean.class) ? "|is)" : ")"); + } + } + + @ToString + @RequiredArgsConstructor + public static class Recorded { + + private final T t; + private final Recorder recorder; + + public String getCurrentPropertyName() { + return recorder.getCurrentPropertyName(); + } + + /** + * Applies the given Converter to the recorded value and remembers the property accessed. + * + * @param converter must not be {@literal null}. + * @return + */ + public Recorded apply(Converter converter) { + return new Recorded(converter.convert(t), recorder); + } + + private T getObject() { + return t; + } + } +} diff --git a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java index 729933c1f..5b7776c16 100644 --- a/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java +++ b/src/main/java/org/springframework/hateoas/hal/Jackson2HalModule.java @@ -19,12 +19,15 @@ import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import lombok.Data; + import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.NoSuchMessageException; @@ -47,18 +50,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.core.Version; -import com.fasterxml.jackson.databind.BeanProperty; -import com.fasterxml.jackson.databind.DeserializationConfig; -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.JsonNode; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.KeyDeserializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationConfig; -import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.cfg.HandlerInstantiator; import com.fasterxml.jackson.databind.cfg.MapperConfig; import com.fasterxml.jackson.databind.deser.ContextualDeserializer; @@ -139,6 +131,10 @@ public HalLinkListSerializer(BeanProperty property, CurieProvider curieProvider, this.accessor = accessor; } + public HalLinkListSerializer() { + this(null, null, null); + } + /* * (non-Javadoc) * @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider) @@ -193,11 +189,11 @@ public void serialize(List value, JsonGenerator jgen, SerializerProvider p } TypeFactory typeFactory = provider.getConfig().getTypeFactory(); - JavaType keyType = typeFactory.uncheckedSimpleType(String.class); + JavaType keyType = typeFactory.constructSimpleType(String.class, new JavaType[0]); JavaType valueType = typeFactory.constructCollectionType(ArrayList.class, Object.class); JavaType mapType = typeFactory.constructMapType(HashMap.class, keyType, valueType); - MapSerializer serializer = MapSerializer.construct(new String[] {}, mapType, true, null, + MapSerializer serializer = MapSerializer.construct(Collections. emptySet(), mapType, true, null, provider.findKeySerializer(keyType, null), new OptionalListJackson2Serializer(property), null); serializer.serialize(sortedLinks, jgen, provider); @@ -267,15 +263,7 @@ public JsonSerializer getContentSerializer() { return null; } - /* - * (non-Javadoc) - * @see com.fasterxml.jackson.databind.ser.ContainerSerializer#isEmpty(java.lang.Object) - */ - public boolean isEmpty(List value) { - return isEmpty(null, value); - } - - /* + /* * (non-Javadoc) * @see com.fasterxml.jackson.databind.JsonSerializer#isEmpty(com.fasterxml.jackson.databind.SerializerProvider, java.lang.Object) */ @@ -368,10 +356,6 @@ public JsonSerializer getContentSerializer() { return null; } - public boolean isEmpty(Collection value) { - return isEmpty(null, value); - } - public boolean isEmpty(SerializerProvider provider, Collection value) { return value.isEmpty(); } @@ -506,15 +490,7 @@ public boolean hasSingleElement(Object arg0) { return false; } - /* - * (non-Javadoc) - * @see com.fasterxml.jackson.databind.ser.ContainerSerializer#isEmpty(java.lang.Object) - */ - public boolean isEmpty(Object value) { - return isEmpty(null, value); - } - - /* + /* * (non-Javadoc) * @see com.fasterxml.jackson.databind.JsonSerializer#isEmpty(com.fasterxml.jackson.databind.SerializerProvider, java.lang.Object) */ @@ -567,7 +543,7 @@ public JsonDeserializer getContentDeserializer() { */ @Override public List deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JsonProcessingException { + throws IOException { List result = new ArrayList(); String relation; @@ -577,7 +553,7 @@ public List deserialize(JsonParser jp, DeserializationContext ctxt) while (!JsonToken.END_OBJECT.equals(jp.nextToken())) { if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) { - throw new JsonParseException("Expected relation name", jp.getCurrentLocation()); + throw new JsonParseException(jp, "Expected relation name", jp.getCurrentLocation()); } // save the relation in case the link does not contain it @@ -585,17 +561,30 @@ public List deserialize(JsonParser jp, DeserializationContext ctxt) if (JsonToken.START_ARRAY.equals(jp.nextToken())) { while (!JsonToken.END_ARRAY.equals(jp.nextToken())) { - link = jp.readValueAs(Link.class); + link = jp.readValueAs(ExtendedLink.class); result.add(new Link(link.getHref(), relation)); } } else { - link = jp.readValueAs(Link.class); + link = jp.readValueAs(ExtendedLink.class); result.add(new Link(link.getHref(), relation)); } } return result; } + + /** + * DTO to parse potentially templated links. + */ + @Data + static class ExtendedLink extends Link { + + private static final long serialVersionUID = -2697601490376254527L; + + private String name; + private boolean templated; + } + } public static class HalResourcesDeserializer extends ContainerDeserializerBase> @@ -606,9 +595,9 @@ public static class HalResourcesDeserializer extends ContainerDeserializerBase deserialize(JsonParser jp, DeserializationContext ctxt) while (!JsonToken.END_OBJECT.equals(jp.nextToken())) { if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) { - throw new JsonParseException("Expected relation name", jp.getCurrentLocation()); + throw new JsonParseException(jp, "Expected relation name", jp.getCurrentLocation()); } if (JsonToken.START_ARRAY.equals(jp.nextToken())) { @@ -673,10 +662,7 @@ public List deserialize(JsonParser jp, DeserializationContext ctxt) @Override public JsonDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException { - - JavaType vc = property.getType().getContentType(); - HalResourcesDeserializer des = new HalResourcesDeserializer(vc); - return des; + return new HalResourcesDeserializer(property.getType().getContentType()); } } @@ -820,14 +806,6 @@ public TrueOnlyBooleanSerializer() { } /* - * (non-Javadoc) - * @see com.fasterxml.jackson.databind.JsonSerializer#isEmpty(java.lang.Object) - */ - public boolean isEmpty(Boolean value) { - return isEmpty(null, value); - } - - /* * (non-Javadoc) * @see com.fasterxml.jackson.databind.JsonSerializer#isEmpty(com.fasterxml.jackson.databind.SerializerProvider, java.lang.Object) */ @@ -872,7 +850,7 @@ public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType t * * @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 53f796eb9..8968479b3 100644 --- a/src/main/java/org/springframework/hateoas/hal/LinkMixin.java +++ b/src/main/java/org/springframework/hateoas/hal/LinkMixin.java @@ -30,7 +30,7 @@ * @author Oliver Gierke */ @JsonIgnoreProperties(value = "rel") -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/forms/HalFormsConfiguration.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.java new file mode 100644 index 000000000..d8410a96d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsConfiguration.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 HalFormsConfiguration 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/HalFormsDeserializers.java b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java new file mode 100644 index 000000000..01e5716c4 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/hal/forms/HalFormsDeserializers.java @@ -0,0 +1,275 @@ +/* + * 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.springframework.hateoas.affordance.Suggestions.*; +import static org.springframework.hateoas.hal.Jackson2HalModule.*; +import static org.springframework.hateoas.hal.forms.HalFormsDocument.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.hateoas.affordance.Suggestions; +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.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase; +import com.fasterxml.jackson.databind.type.TypeFactory; + +/** + * @author Greg Turnquist + */ +public class HalFormsDeserializers { + + /** + * Deserialize an entire HAL-Forms document. + */ + static class HalFormsDocumentDeserializer extends JsonDeserializer { + + private final HalLinkListDeserializer linkDeser = new HalLinkListDeserializer(); + private final HalFormsTemplateListDeserializer templateDeser = new HalFormsTemplateListDeserializer(); + + @Override + public HalFormsDocument deserialize(JsonParser jp, DeserializationContext ctxt) + throws IOException { + + HalFormsDocument.HalFormsDocumentBuilder halFormsDocumentBuilder = halFormsDocument(); + + while (!JsonToken.END_OBJECT.equals(jp.nextToken())) { + + if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) { + throw new JsonParseException(jp, "Expected property ", jp.getCurrentLocation()); + } + + jp.nextToken(); + + if ("_links".equals(jp.getCurrentName())) { + halFormsDocumentBuilder.links(this.linkDeser.deserialize(jp, ctxt)); + } else if ("_templates".equals(jp.getCurrentName())) { + halFormsDocumentBuilder.templates(this.templateDeser.deserialize(jp, ctxt)); + } + } + + return halFormsDocumentBuilder.build(); + } + } + + /** + * Deserialize an object of HAL-Forms {@link Template}s into a {@link List} of {@link Template}s. + */ + static class HalFormsTemplateListDeserializer extends ContainerDeserializerBase> { + + public HalFormsTemplateListDeserializer() { + super(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, Template.class)); + } + + /** + * Accessor for declared type of contained value elements; either exact + * type, or one of its supertypes. + */ + @Override + public JavaType getContentType() { + return null; + } + + /** + * Accesor for deserializer use for deserializing content values. + */ + @Override + public JsonDeserializer getContentDeserializer() { + return null; + } + + /** + * Method that can be called to ask implementation to deserialize + * JSON content into the value type this serializer handles. + * Returned instance is to be constructed by method itself. + *

+ * Pre-condition for this method is that the parser points to the + * first event that is part of value to deserializer (and which + * is never JSON 'null' literal, more on this below): for simple + * types it may be the only value; and for structured types the + * Object start marker or a FIELD_NAME. + *

+ * The two possible input conditions for structured types result + * from polymorphism via fields. In the ordinary case, Jackson + * calls this method when it has encountered an OBJECT_START, + * and the method implementation must advance to the next token to + * see the first field name. If the application configures + * polymorphism via a field, then the object looks like the following. + *
+		 *      {
+		 *          "@class": "class name",
+		 *          ...
+		 *      }
+		 *  
+ * Jackson consumes the two tokens (the @class field name + * and its value) in order to learn the class and select the deserializer. + * Thus, the stream is pointing to the FIELD_NAME for the first field + * after the @class. Thus, if you want your method to work correctly + * both with and without polymorphism, you must begin your method with: + *
+		 *       if (jp.getCurrentToken() == JsonToken.START_OBJECT) {
+		 *         jp.nextToken();
+		 *       }
+		 *  
+ * This results in the stream pointing to the field name, so that + * the two conditions align. + * Post-condition is that the parser will point to the last + * event that is part of deserialized value (or in case deserialization + * fails, event that was not recognized or usable, which may be + * the same event as the one it pointed to upon call). + * Note that this method is never called for JSON null literal, + * and thus deserializers need (and should) not check for it. + * + * @param jp Parsed used for reading JSON content + * @param ctxt Context that can be used to access information about + * this deserialization activity. + * @return Deserialized value + */ + @Override + public List