diff --git a/pom.xml b/pom.xml
index ce3aa11c..6b9d9aaa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -798,6 +798,13 @@
true
+
+ javax.validation
+ validation-api
+ 2.0.1.Final
+ true
+
+
org.projectlombok
lombok
@@ -993,6 +1000,7 @@
${source.level}
${source.level}
+ true
diff --git a/src/docs/java/org/springframework/hateoas/AffordancesSample.java b/src/docs/java/org/springframework/hateoas/AffordancesSample.java
new file mode 100644
index 00000000..a8982bcd
--- /dev/null
+++ b/src/docs/java/org/springframework/hateoas/AffordancesSample.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas;
+
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
+
+import lombok.var;
+
+import org.springframework.hateoas.SimpleRepresentationModelAssemblerTest.Employee;
+import org.springframework.hateoas.mediatype.Affordances;
+import org.springframework.http.HttpMethod;
+
+/**
+ * Manual usage of {@link Affordances}.
+ *
+ * @author Oliver Drotbohm
+ */
+public class AffordancesSample {
+
+ void manualAffordance() {
+
+ // tag::affordances[]
+ var methodInvocation = methodOn(EmployeeController.class).all();
+
+ var link = Affordances.of(linkTo(methodInvocation).withSelfRel()) // <1>
+
+ .afford(HttpMethod.POST) // <2>
+ .withInputAndOutput(Employee.class) //
+ .withName("createEmployee") //
+
+ .andAfford(HttpMethod.GET) // <3>
+ .withOutput(Employee.class) //
+ .addParameters(//
+ QueryParameter.optional("name"), //
+ QueryParameter.optional("role")) //
+ .withName("search") //
+
+ .toLink();
+ // end::affordances[]
+ }
+}
diff --git a/src/docs/java/org/springframework/hateoas/EmployeeModel.java b/src/docs/java/org/springframework/hateoas/EmployeeModel.java
new file mode 100644
index 00000000..dbb3bb4c
--- /dev/null
+++ b/src/docs/java/org/springframework/hateoas/EmployeeModel.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+/**
+ * @author Oliver Drotbohm
+ */
+// tag:hal-forms-model[]
+@Data
+public class EmployeeModel extends RepresentationModel {
+
+ @NotNull //
+ private String name;
+
+ @Pattern(regexp = "[A-Z_]") //
+ private String role;
+}
+// end:hal-forms-model[]
diff --git a/src/docs/resources/org/springframework/hateoas/docs/mediatype/hal/forms/hal-forms-sample-with-notes.json b/src/docs/resources/org/springframework/hateoas/docs/mediatype/hal/forms/hal-forms-sample-with-notes.json
index a288d069..a797d0d2 100644
--- a/src/docs/resources/org/springframework/hateoas/docs/mediatype/hal/forms/hal-forms-sample-with-notes.json
+++ b/src/docs/resources/org/springframework/hateoas/docs/mediatype/hal/forms/hal-forms-sample-with-notes.json
@@ -7,35 +7,24 @@
"href" : "http://localhost:8080/employees/1"
}
},
- "_templates" : { // <1>
+ "_templates" : {
"default" : {
- "title" : null,
- "method" : "put", // <2>
- "contentType" : "",
- "properties" : [ { // <3>
- "name" : "firstName",
- "required" : true // <4>
- }, {
- "name" : "lastName",
+ "method" : "put",
+ "properties" : [ {
+ "name" : "name",
"required" : true
}, {
"name" : "role",
- "required" : true
+ "regex" : "[A-Z_]"
} ]
},
"partiallyUpdateEmployee" : { // <5>
- "title" : null,
"method" : "patch", // <6>
- "contentType" : "",
"properties" : [ {
- "name" : "firstName",
- "required" : false // <7>
- }, {
- "name" : "lastName",
- "required" : false
+ "name" : "name"
}, {
"name" : "role",
- "required" : false
+ "regex" : "[A-Z_]"
} ]
}
}
diff --git a/src/main/asciidoc/fundamentals.adoc b/src/main/asciidoc/fundamentals.adoc
index ed6ba875..5b77f82f 100644
--- a/src/main/asciidoc/fundamentals.adoc
+++ b/src/main/asciidoc/fundamentals.adoc
@@ -195,114 +195,3 @@ Collection people = Collections.singleton(new Person("Dave", "Matthews")
CollectionModel model = new CollectionModel<>(people);
----
====
-
-[[fundamentals.affordances]]
-== Affordances
-
-[quote, James J. Gibson, The Ecological Approach to Visual Perception (page 126)]
-____
-The affordances of the environment are what it offers … what it provides or furnishes, either for good or ill. The verb 'to afford' is found in the dictionary, but the noun 'affordance' is not. I have made it up.
-____
-
-REST-based resources provide not just data but controls. The last ingredient to form a flexible service are detailed *affordances*
-on how to use the various controls.
-
-Because affordances are associated with links, Spring HATEOAS provides an API to attach as many related methods as needed to a link.
-The following code shows how to take a *self* link and associate two more affordances:
-
-.Connecting affordances to `GET /employees/{id}`
-====
-[source, java, indent=0, tabsize=2]
-----
-include::{code-dir}/EmployeeController.java[tag=get]
-----
-<1> Create the *self* link.
-<2> Associate the `updateEmployee` method with the `self` link.
-<3> Associate the `partiallyUpdateEmployee` method with the `self` link.
-
-Using `.andAffordance(afford(...))`, you can use the controller's methods to connect a `PUT` and a `PATCH` operation to a `GET` operation.
-====
-
-Imagine that the related methods *afforded* above looking like this:
-
-.`updateEmpoyee` method that responds to `PUT /employees/{id}`
-====
-[source, java, indent=0, tabsize=2]
-----
-include::{code-dir}/EmployeeController.java[tag=put]
-----
-====
-
-.`partiallyUpdateEmployee` method that responds to `PATCH /employees/{id}`
-====
-[source, java, indent=0, tabsize=2]
-----
-include::{code-dir}/EmployeeController.java[tag=patch]
-----
-====
-
-There are many media types that support rendering affordances. Unfortunately, HAL isn't one of them.
-
-A HAL document for `GET /employees/{id}` would look like this:
-
-.HAL document with no affordances
-====
-[source, json]
-----
-{
- "firstname" : "Frodo",
- "lastname" : "Baggins",
- "role" : "ring bearer",
- "_links" : {
- "self" : {
- "href" : "http://localhost:8080/employees/1"
- }
- }
-}
-----
-====
-
-HAL supports providing links, but nothing else. While powerful, it doesn't let you show clients what inputs are required
-by its various operations. Nor does it show _what_ HTTP methods are supported.
-
-However, https://rwcbook.github.io/hal-forms/[HAL-FORMS] (`application/prs.hal-forms+json`), is a backwards compatible
-extension of HAL s that adds `_templates`. This affordance-aware media type can fill in what's missing.
-
-The same resource above will render the following HAL-FORMS document:
-
-.HAL-FORMS document with affordances
-====
-[source, json, tabsize=2]
-----
-include::{resource-dir}/docs/mediatype/hal/forms/hal-forms-sample-with-notes.json[]
-----
-<1> The `_templates` attribute provided by HAL-FORMS with affordance-based information.
-<2> The `updateEmployee` method's `@PutMapping` annotation is translated to `put`.
-<3> The method's `@RequestBody` input type is used to find domain `properties`.
-<4> For `POST` and `PUT`, all attributes are `required`.
-<5> The second affordance is named after the `partiallyUpdateEmployee` method.
-<6> `@PatchMapping` is translated into `patch`.
-<7> For `PATCH`, attributes are _not_ `required`.
-====
-
-This rich document, consumable by any HAL-FORMS aware client includes enough extra details for full interaction with the resource.
-
-In fact, this type of document makes it easy to write custom client-side code to generate an HTML form:
-
-[source, html, tabsize=2]
-----
-
-----
-
-Letting hypermedia drive web forms for users reduces the need for the client to know about the domain.
-
-By trading in domain knowledge and instead adding protocol support for HAL-FORMS, clients can become flexible and receptive
-to server-side changes. No need to update your client every time a domain change is made on the server.
-
-IMPORTANT: HAL-FORMS only supports affordances against the `self` link, but other affordance-aware media types may not
-have the same restriction. In general, don't define affordances based on one particular media type.
diff --git a/src/main/asciidoc/mediatypes.adoc b/src/main/asciidoc/mediatypes.adoc
index 8fa6b882..a7990d6a 100644
--- a/src/main/asciidoc/mediatypes.adoc
+++ b/src/main/asciidoc/mediatypes.adoc
@@ -200,11 +200,42 @@ include::{resource-dir}/docs/mediatype/hal/forms/hal-forms-sample.json[]
====
Checkout the https://rwcbook.github.io/hal-forms/[HAL-FORMS spec] to understand the details of the *_templates* attribute.
-Read about the <> to augment your controllers with this extra metadata.
+Read about the <> to augment your controllers with this extra metadata.
As for single-item (`EntityModel`) and aggregate root collections (`CollectionModel`), Spring HATEOAS renders them
identically to <>.
+[[mediatypes.hal-forms.metadata]]
+=== Defining HAL-FORMS metadata
+HAL-FORMS allows to describe criterias for each form field.
+Spring HATEOAS allows to customize those by shaping the model type for the input and output types and using annotations on them.
+
+[options="header", cols="1,4"]
+|===============
+|Attribute|Description
+|`readOnly`| Set to `true` if there's no setter method for the property. If that is present, use Jackson's `@JsonProperty(Access.READ_ONLY)` on the accessors or field explicitly. Not rendered by default, thus defaulting to `false`.
+|`regex`| Can be customized by using JSR-303's `@Pattern` annotation either on the field or a type. In case of the latter the pattern will be used for every property declared as that particular type. Not rendered by default.
+|`required`| Can be customized by using JSR-303's `@NotNull`. Not rendered by default and thus defaulting to `false`. Templates using `PATCH` as method will automatically have set all properties to not required.
+|===============
+
+For types that you cannot annotate manually, you can register a custom pattern via a `HalFormsConfiguration` bean present in the application context.
+
+[source, java]
+----
+@Configuration
+class CustomConfiguration {
+
+ @Bean
+ HalFormsConfiguration halFormsConfiguration() {
+
+ HalFormsConfiguration configuration = new HalFormsConfiguration();
+ configuration.registerPatternFor(CreditCardNumber.class, "[0-9]{16}");
+ }
+}
+----
+
+This setup will cause the HAL-FORMS template properties for representation model properties of type `CreditCardNumber` to declare a `regex` field with value `[0-9]{16}`.
+
[[mediatypes.hal-forms.i18n]]
=== Internationalization of form attributes
HAL-FORMS contains attributes that are intended for human interpretation, like a template's title or property prompts.
@@ -232,7 +263,7 @@ com.acme.Employee._templates.default.title=Create employee <4>
NOTE: Keys using the actual affordance name enjoy preference over the defaulted ones.
==== Property prompts
-Property prompts can also be resolved via the `rest-messages` resource bundle automatically configured by Spring HATEOAS.
+Property prompts can also be resolved via the `rest-messages` resource bundle automatically configured by Spring HATEOAS.
The keys can be defined globally, locally or fully-qualified and need an `._prompt` concatenated to the actual property key:
.Defining prompts for an `email` property
@@ -326,7 +357,7 @@ The previous fragment was lifted from the spec. When Spring HATEOAS renders an `
* Put the `self` link into both the document's `href` attribute and the item-level `href` attribute.
* Put the rest of the model's links into both the top-level `links` as well as the item-level `links`.
-* Extract the properties from the `EntityModel` and turn them into
+* Extract the properties from the `EntityModel` and turn them into
====
When rendering a collection of resources, the document is almost the same, except there will be multiple entries inside
diff --git a/src/main/asciidoc/migrate-to-1.0.adoc b/src/main/asciidoc/migrate-to-1.0.adoc
index 450bd501..20583364 100644
--- a/src/main/asciidoc/migrate-to-1.0.adoc
+++ b/src/main/asciidoc/migrate-to-1.0.adoc
@@ -62,3 +62,11 @@ Note that the script will not necessarily be able to entirely fix all changes, b
Now verify the changes made to the files in your favorite Git client and commit as appropriate.
In case you find method or type references unmigrated, please open a ticket in out issue tracker.
+
+[[migration.1-0-M3-to-1-0-RC1]]
+== Migrating from 1.0 M3 to 1.0 RC1
+
+- `Link.andAffordance(…)` taking Affordance details have been moved to `Affordances`. To manually build up `Affordance` instances now use `Affordances.of(link).afford(…)`. Also note the new `AffordanceBuilder` type exposed from `Affordances` for fluent usage. See <> for details.
+- `AffordanceModelFactory.getAffordanceModel(…)` now receives `InputPayloadMetadata` and `PayloadMetadata` instances instead of ``ResolvableType``s to allow non-type-based implementations. Custom media type implementations have to be adapted to that accordingly.
+- HAL Forms now does not render property attributes if their value adheres to what's defined as default in the spec. I.e. if previously `required` was explicitly set to `false`, we now just omit the entry for `required`.
+We also now only force them to be non-required for templates that use `PATCH` as the HTTP method.
diff --git a/src/main/asciidoc/server.adoc b/src/main/asciidoc/server.adoc
index b8e945c1..4e7b00d0 100644
--- a/src/main/asciidoc/server.adoc
+++ b/src/main/asciidoc/server.adoc
@@ -110,6 +110,72 @@ assertThat(link.getHref()).endsWith("/people/2");
TODO
+[[server.affordances]]
+== Affordances
+
+[quote, James J. Gibson, The Ecological Approach to Visual Perception (page 126)]
+____
+The affordances of the environment are what it offers … what it provides or furnishes, either for good or ill. The verb 'to afford' is found in the dictionary, but the noun 'affordance' is not. I have made it up.
+____
+
+REST-based resources provide not just data but controls.
+The last ingredient to form a flexible service are detailed *affordances* on how to use the various controls.
+Because affordances are associated with links, Spring HATEOAS provides an API to attach as many related methods as needed to a link.
+Just as you can create links by pointing to Spring MVC controller methods (see <> for details) you
+The following code shows how to take a *self* link and associate two more affordances:
+
+.Connecting affordances to `GET /employees/{id}`
+====
+[source, java, indent=0, tabsize=2]
+----
+include::{code-dir}/EmployeeController.java[tag=get]
+----
+<1> Create the *self* link.
+<2> Associate the `updateEmployee` method with the `self` link.
+<3> Associate the `partiallyUpdateEmployee` method with the `self` link.
+====
+
+Using `.andAffordance(afford(...))`, you can use the controller's methods to connect a `PUT` and a `PATCH` operation to a `GET` operation.
+Imagine that the related methods *afforded* above looking like this:
+
+.`updateEmpoyee` method that responds to `PUT /employees/{id}`
+====
+[source, java, indent=0, tabsize=2]
+----
+include::{code-dir}/EmployeeController.java[tag=put]
+----
+====
+
+.`partiallyUpdateEmployee` method that responds to `PATCH /employees/{id}`
+====
+[source, java, indent=0, tabsize=2]
+----
+include::{code-dir}/EmployeeController.java[tag=patch]
+----
+====
+
+Pointing to those methods using the `afford(…)` methods will cause Spring HATEOAS to analyze the request body and response types and capture metadata to allow different media type implementations to use that information to translate that into descriptions of the input and outputs.
+
+[[server.affordances.api]]
+=== Building affordances manually
+
+While the primary way to register affordances for a link, it might be necessary to build some of them manually.
+This can be achieved by using the `Affordances` API:
+
+.Using the `Affordances` API to manually register affordances
+====
+[source, java, indent=0, tabsize=2]
+----
+include::{code-dir}/AffordancesSample.java[tag=affordances]
+----
+<1> You start by creating an instance of `Affordances` from a `Link` instance creating the context for describing the affordances.
+<2> Each affordance starts with the HTTP method it's supposed to support. We then register a type as payload description and name the affordance explicitly. The latter can be omitted and a default name will be derived from the HTTP method and input type name. This effectively creates the same affordance as the pointer to `EmployeeController.newEmployee(…)` created.
+<3> The next affordance is built to reflect what's happening for the pointer to `EmployeeController.search(…)`. Here we define `Employee` to be the model for the response created and explicitly register ``QueryParameter``s.
+====
+
+Affordances are backed by media type specific affordance models that translate the general affordance metadata into specific representations.
+Please make sure to check the section on affordances in the <> section to find more details about how to control the exposure of that metadata.
+
[[server.link-builder.forwarded-headers]]
== Forwarded header handling
diff --git a/src/main/java/org/springframework/hateoas/Affordance.java b/src/main/java/org/springframework/hateoas/Affordance.java
index 0c727c0c..aaea96b8 100644
--- a/src/main/java/org/springframework/hateoas/Affordance.java
+++ b/src/main/java/org/springframework/hateoas/Affordance.java
@@ -19,16 +19,11 @@ import lombok.AccessLevel;
import lombok.Getter;
import lombok.Value;
-import java.util.HashMap;
-import java.util.List;
+import java.util.Iterator;
import java.util.Map;
-import org.springframework.core.ResolvableType;
-import org.springframework.core.io.support.SpringFactoriesLoader;
-import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
-import org.springframework.util.Assert;
/**
* Hold the {@link AffordanceModel}s for all supported media types.
@@ -37,37 +32,13 @@ import org.springframework.util.Assert;
* @author Oliver Gierke
*/
@Value
-public class Affordance {
-
- private static List factories = SpringFactoriesLoader
- .loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());
+public class Affordance implements Iterable {
/**
* Collection of {@link AffordanceModel}s related to this affordance.
*/
- private final @Getter(AccessLevel.PACKAGE) Map affordanceModels = new HashMap<>();
-
- /**
- * Creates a new {@link Affordance}.
- *
- * @param name
- * @param link
- * @param httpMethod
- * @param inputType
- * @param queryMethodParameters
- * @param outputType
- */
- public Affordance(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
-
- Assert.notNull(httpMethod, "httpMethod must not be null!");
- Assert.notNull(queryMethodParameters, "queryMethodParameters must not be null!");
-
- for (AffordanceModelFactory factory : factories) {
- this.affordanceModels.put(factory.getMediaType(),
- factory.getAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType));
- }
- }
+ @Getter(AccessLevel.PACKAGE) //
+ private final Map models;
/**
* Look up the {@link AffordanceModel} for the requested {@link MediaType}.
@@ -78,6 +49,15 @@ public class Affordance {
@Nullable
@SuppressWarnings("unchecked")
public T getAffordanceModel(MediaType mediaType) {
- return (T) this.affordanceModels.get(mediaType);
+ return (T) this.models.get(mediaType);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Iterable#iterator()
+ */
+ @Override
+ public Iterator iterator() {
+ return models.values().iterator();
}
}
diff --git a/src/main/java/org/springframework/hateoas/AffordanceModel.java b/src/main/java/org/springframework/hateoas/AffordanceModel.java
index 0ae92832..bd6de6f8 100644
--- a/src/main/java/org/springframework/hateoas/AffordanceModel.java
+++ b/src/main/java/org/springframework/hateoas/AffordanceModel.java
@@ -18,8 +18,14 @@ package org.springframework.hateoas;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.ToString;
+import java.util.Collections;
import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpMethod;
@@ -54,7 +60,7 @@ public abstract class AffordanceModel {
/**
* Domain type used to create a new resource.
*/
- private ResolvableType inputType;
+ private InputPayloadMetadata input;
/**
* Collection of {@link QueryParameter}s to interrogate a resource.
@@ -64,7 +70,7 @@ public abstract class AffordanceModel {
/**
* Response body domain type.
*/
- private ResolvableType outputType;
+ private PayloadMetadata output;
/**
* Expand the {@link Link} into an {@literal href} with no parameters.
@@ -100,4 +106,209 @@ public abstract class AffordanceModel {
return getURI().equals(link.expand().getHref());
}
+
+ /**
+ * Metadata about payloads.
+ *
+ * @author Oliver Drotbohm
+ */
+ public interface PayloadMetadata {
+
+ public static PayloadMetadata NONE = NoPayloadMetadata.INSTANCE;
+
+ /**
+ * Returns all properties contained in a payload.
+ *
+ * @return
+ */
+ Stream stream();
+
+ default Optional getPropertyMetadata(String name) {
+ return stream().filter(it -> it.hasName(name)).findFirst();
+ }
+ }
+
+ /**
+ * Payload metadata for incoming requests.
+ *
+ * @author Oliver Drotbohm
+ */
+ public interface InputPayloadMetadata extends PayloadMetadata {
+
+ static InputPayloadMetadata NONE = from(PayloadMetadata.NONE);
+
+ static InputPayloadMetadata from(PayloadMetadata metadata) {
+
+ return InputPayloadMetadata.class.isInstance(metadata) //
+ ? InputPayloadMetadata.class.cast(metadata)
+ : DelegatingInputPayloadMetadata.of(metadata);
+ }
+
+ /**
+ * Applies the {@link InputPayloadMetadata} to the given target.
+ *
+ * @param
+ * @param target
+ * @return
+ */
+ & Named> T applyTo(T target);
+
+ T customize(T target, Function customizer);
+
+ /**
+ * Returns the I18n codes to be used to resolve a name for the payload metadata.
+ *
+ * @return
+ */
+ List getI18nCodes();
+ }
+
+ /**
+ * {@link InputPayloadMetadata} to delegate to a target {@link PayloadMetadata} not applying any customizations.
+ *
+ * @author Oliver Drotbohm
+ */
+ @ToString
+ @EqualsAndHashCode
+ @RequiredArgsConstructor(staticName = "of")
+ private static class DelegatingInputPayloadMetadata implements InputPayloadMetadata {
+
+ private final PayloadMetadata metadata;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#stream()
+ */
+ @Override
+ public Stream stream() {
+ return metadata.stream();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured)
+ */
+ @Override
+ public & Named> T applyTo(T target) {
+ return target;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.Named, java.util.function.Function)
+ */
+ @Override
+ public T customize(T target, Function customizer) {
+ return target;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#getI18nCodes()
+ */
+ @Override
+ public List getI18nCodes() {
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Metadata about the property model of a representation.
+ *
+ * @author Oliver Drotbohm
+ */
+ public interface PropertyMetadata {
+
+ /**
+ * The name of the property.
+ *
+ * @return will never be {@literal null} or empty.
+ */
+ String getName();
+
+ /**
+ * Whether the property has the given name.
+ *
+ * @param name must not be {@literal null} or empty.
+ * @return
+ */
+ default boolean hasName(String name) {
+
+ Assert.hasText(name, "Name must not be null or empty!");
+
+ return getName().equals(name);
+ }
+
+ /**
+ * Whether the property is required to be submitted or always present in the representation returned.
+ *
+ * @return
+ */
+ boolean isRequired();
+
+ /**
+ * Whether the property is read only, i.e. must not be manipulated in requests modifying state.
+ *
+ * @return
+ */
+ boolean isReadOnly();
+
+ /**
+ * Returns the (regular expression) pattern the property has to adhere to.
+ *
+ * @return will never be {@literal null}.
+ */
+ Optional getPattern();
+
+ /**
+ * Return the type of the property. If no type can be determined, return {@link Object}.
+ *
+ * @return
+ */
+ ResolvableType getType();
+ }
+
+ /**
+ * SPI for a type that can get {@link PropertyMetadata} applied.
+ *
+ * @author Oliver Drotbohm
+ */
+ public interface PropertyMetadataConfigured {
+
+ /**
+ * Applies the given {@link PropertyMetadata}.
+ *
+ * @param metadata will never be {@literal null}.
+ * @return
+ */
+ T apply(PropertyMetadata metadata);
+ }
+
+ /**
+ * A named component.
+ *
+ * @author Oliver Drotbohm
+ */
+ public interface Named {
+ String getName();
+ }
+
+ /**
+ * Empty {@link PayloadMetadata}.
+ *
+ * @author Oliver Drotbohm
+ */
+ private static enum NoPayloadMetadata implements PayloadMetadata {
+
+ INSTANCE;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#stream()
+ */
+ @Override
+ public Stream stream() {
+ return Stream.empty();
+ }
+ }
}
diff --git a/src/main/java/org/springframework/hateoas/Link.java b/src/main/java/org/springframework/hateoas/Link.java
index cb032b7d..7f489f6d 100755
--- a/src/main/java/org/springframework/hateoas/Link.java
+++ b/src/main/java/org/springframework/hateoas/Link.java
@@ -31,8 +31,6 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import org.springframework.core.ResolvableType;
-import org.springframework.http.HttpMethod;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -211,60 +209,6 @@ public class Link implements Serializable {
return withAffordances(newAffordances);
}
- /**
- * Convenience method when chaining an existing {@link Link}.
- *
- * @param name
- * @param httpMethod
- * @param inputType
- * @param queryMethodParameters
- * @param outputType
- * @return
- */
- public Link andAffordance(String name, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
- return andAffordance(new Affordance(name, this, httpMethod, inputType, queryMethodParameters, outputType));
- }
-
- /**
- * Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname,
- * e.g. {@literal } produces {@literal }.
- *
- * @param httpMethod
- * @param inputType
- * @param queryMethodParameters
- * @param outputType
- * @return
- */
- public Link andAffordance(HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters,
- ResolvableType outputType) {
-
- String name = httpMethod.toString().toLowerCase();
-
- Class> resolvedInputType = inputType.resolve();
- if (resolvedInputType != null) {
- name += resolvedInputType.getSimpleName();
- }
-
- return andAffordance(name, httpMethod, inputType, queryMethodParameters, outputType);
- }
-
- /**
- * Convenience method when chaining an existing {@link Link}. Defaults the name of the affordance to verb + classname,
- * e.g. {@literal } produces {@literal }.
- *
- * @param httpMethod
- * @param inputType
- * @param queryMethodParameters
- * @param outputType
- * @return
- */
- public Link andAffordance(HttpMethod httpMethod, Class> inputType, List queryMethodParameters,
- Class> outputType) {
- return andAffordance(httpMethod, ResolvableType.forClass(inputType), queryMethodParameters,
- ResolvableType.forClass(outputType));
- }
-
/**
* Create new {@link Link} with additional {@link Affordance}s.
*
diff --git a/src/main/java/org/springframework/hateoas/QueryParameter.java b/src/main/java/org/springframework/hateoas/QueryParameter.java
index dbc33724..a065e111 100644
--- a/src/main/java/org/springframework/hateoas/QueryParameter.java
+++ b/src/main/java/org/springframework/hateoas/QueryParameter.java
@@ -15,19 +15,82 @@
*/
package org.springframework.hateoas;
-import lombok.Data;
+import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
+import lombok.Value;
+import lombok.experimental.Wither;
+
+import java.util.Optional;
+
+import org.springframework.core.MethodParameter;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.annotation.MergedAnnotations;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.RequestParam;
/**
* Representation of a web request's query parameter (https://example.com?name=foo) => {"name", "foo", true}.
*
* @author Greg Turnquist
+ * @author Oliver Drotbohm
*/
-@Data
-@RequiredArgsConstructor
+@Value
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class QueryParameter {
private final String name;
- private final String value;
+ private final @Nullable @Wither String value;
private final boolean required;
+
+ /**
+ * Creates a new {@link QueryParameter} from the given {@link MethodParameter}.
+ *
+ * @param parameter must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public static QueryParameter of(MethodParameter parameter) {
+
+ MergedAnnotation annotation = MergedAnnotations //
+ .from(parameter.getParameter()) //
+ .get(RequestParam.class);
+
+ String name = annotation.isPresent() && annotation.hasNonDefaultValue("name") //
+ ? annotation.getString("name") //
+ : parameter.getParameterName();
+
+ if (name == null || !StringUtils.hasText(name)) {
+ throw new IllegalStateException(String.format("Couldn't determine parameter name for %s!", parameter));
+ }
+
+ boolean required = annotation.isPresent() && annotation.hasNonDefaultValue("required") //
+ ? annotation.getBoolean("required") //
+ : !Optional.class.equals(parameter.getParameterType()); //
+
+ return required ? required(name) : optional(name);
+ }
+
+ /**
+ * Creates a new required {@link QueryParameter} with the given name;
+ *
+ * @param name must not be {@literal null} or empty.
+ * @return
+ */
+ public static QueryParameter required(String name) {
+
+ Assert.hasText(name, "Name must not be null or empty!");
+
+ return new QueryParameter(name, null, true);
+ }
+
+ /**
+ * Creates a new optional {@link QueryParameter} with the given name;
+ *
+ * @param name must not be {@literal null} or empty.
+ * @return
+ */
+ public static QueryParameter optional(String name) {
+ return new QueryParameter(name, null, false);
+ }
}
diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java b/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java
index 7ea5d1f0..28da095a 100644
--- a/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java
+++ b/src/main/java/org/springframework/hateoas/config/HypermediaConfigurationImportSelector.java
@@ -44,7 +44,8 @@ class HypermediaConfigurationImportSelector implements ImportSelector {
Map attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName());
- Collection types = attributes == null ? Collections.emptyList()
+ Collection types = attributes == null //
+ ? Collections.emptyList() //
: Arrays.stream((HypermediaType[]) attributes.get("type")) //
.flatMap(it -> it.getMediaTypes().stream()) //
.collect(Collectors.toList());
diff --git a/src/main/java/org/springframework/hateoas/AffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/mediatype/AffordanceModelFactory.java
similarity index 72%
rename from src/main/java/org/springframework/hateoas/AffordanceModelFactory.java
rename to src/main/java/org/springframework/hateoas/mediatype/AffordanceModelFactory.java
index 7149d378..8f1a2f85 100644
--- a/src/main/java/org/springframework/hateoas/AffordanceModelFactory.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/AffordanceModelFactory.java
@@ -13,11 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.springframework.hateoas;
+package org.springframework.hateoas.mediatype;
import java.util.List;
-import org.springframework.core.ResolvableType;
+import org.springframework.hateoas.AffordanceModel;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.QueryParameter;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
@@ -45,6 +49,6 @@ public interface AffordanceModelFactory {
* @param outputType
* @return
*/
- AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType);
+ AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
+ List queryMethodParameters, PayloadMetadata outputType);
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/AffordanceOperations.java b/src/main/java/org/springframework/hateoas/mediatype/AffordanceOperations.java
new file mode 100644
index 00000000..871b21e1
--- /dev/null
+++ b/src/main/java/org/springframework/hateoas/mediatype/AffordanceOperations.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype;
+
+import org.springframework.hateoas.Affordance;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.mediatype.Affordances.AffordanceBuilder;
+
+/**
+ * Operations commons to all builder APIs.
+ *
+ * @author Oliver Drotbohm
+ * @see AffordanceBuilder
+ */
+public interface AffordanceOperations {
+
+ /**
+ * Returns a {@link Link} equipped with the {@link Affordance} currently under construction.
+ *
+ * @return will never be {@literal null}.
+ */
+ Link toLink();
+}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/Affordances.java b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java
new file mode 100644
index 00000000..0483038a
--- /dev/null
+++ b/src/main/java/org/springframework/hateoas/mediatype/Affordances.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype;
+
+import static java.util.stream.Collectors.*;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Wither;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.springframework.core.ResolvableType;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.hateoas.Affordance;
+import org.springframework.hateoas.AffordanceModel;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.QueryParameter;
+import org.springframework.http.HttpMethod;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Primary API to construct {@link Affordance} instances.
+ *
+ * @author Oliver Drotbohm
+ * @see #afford(HttpMethod)
+ */
+@RequiredArgsConstructor(staticName = "of")
+public class Affordances implements AffordanceOperations {
+
+ private static List factories = SpringFactoriesLoader
+ .loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());
+
+ private final Link link;
+
+ /**
+ * Returns all {@link Affordance}s created.
+ *
+ * @return will never be {@literal null}.
+ */
+ public Stream stream() {
+ return link.getAffordances().stream();
+ }
+
+ /**
+ * Creates a new {@link AffordanceBuilder} for the given HTTP method for further customization. See the wither-methods
+ * for details.
+ *
+ * @param httpMethod must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder afford(HttpMethod httpMethod) {
+
+ Assert.notNull(httpMethod, "HTTP method must not be null!");
+
+ return new AffordanceBuilder(this, httpMethod, link, InputPayloadMetadata.NONE, PayloadMetadata.NONE,
+ Collections.emptyList(), null);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.TerminalOperations#build()
+ */
+ public Link toLink() {
+ return link;
+ }
+
+ /**
+ * Builder API for {@link Affordance} instances.
+ *
+ * @author Oliver Drotbohm
+ */
+ @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class AffordanceBuilder implements AffordanceOperations {
+
+ private final Affordances context;
+
+ private final HttpMethod method;
+ private final @Wither Link target;
+ private final InputPayloadMetadata inputMetdata;
+ private final PayloadMetadata outputMetadata;
+
+ private List parameters = Collections.emptyList();
+ private @Nullable @Wither String name;
+
+ /**
+ * Registers the given type as input and output model for the affordance.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInputAndOutput(Class> type) {
+ return withInput(type).withOutput(type);
+ }
+
+ /**
+ * Registers the given {@link ResolvableType} as input and output model for the affordance.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInputAndOutput(ResolvableType type) {
+ return withInput(type).withOutput(type);
+ }
+
+ /**
+ * Registers the given {@link PayloadMetadata} as input and output model.
+ *
+ * @param metadata must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInputAndOutput(PayloadMetadata metadata) {
+ return withInput(metadata).withOutput(metadata);
+ }
+
+ /**
+ * Registers the given type as input model for the affordance.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInput(Class> type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ return withInput(ResolvableType.forClass(type));
+ }
+
+ /**
+ * Registers the given {@link ResolvableType} as input model for the affordance.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInput(ResolvableType type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ return withInput(PropertyUtils.getExposedProperties(type));
+ }
+
+ /**
+ * Registers the given {@link PayloadMetadata} as input model.
+ *
+ * @param metadata must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withInput(PayloadMetadata metadata) {
+
+ InputPayloadMetadata inputMetadata = InputPayloadMetadata.from(metadata);
+
+ return new AffordanceBuilder(context, method, target, inputMetadata, outputMetadata, parameters, name);
+ }
+
+ /**
+ * Registers the given type as the output model.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withOutput(Class> type) {
+ return withOutput(ResolvableType.forClass(type));
+ }
+
+ /**
+ * Registers the given {@link ResolvableType} as the output model.
+ *
+ * @param type must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withOutput(ResolvableType type) {
+ return withOutput(PropertyUtils.getExposedProperties(type));
+ }
+
+ /**
+ * Registers the given {@link PayloadMetadata} as output model.
+ *
+ * @param metadata must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withOutput(PayloadMetadata metadata) {
+ return new AffordanceBuilder(context, method, target, inputMetdata, metadata, parameters, name);
+ }
+
+ /**
+ * Replaces the current {@link QueryParameters} with the given ones.
+ *
+ * @param parameters must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withParameters(QueryParameter... parameters) {
+ return withParameters(Arrays.asList(parameters));
+ }
+
+ /**
+ * Replaces the current {@link QueryParameters} with the given ones.
+ *
+ * @param parameters must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder withParameters(List parameters) {
+ return new AffordanceBuilder(context, method, target, inputMetdata, outputMetadata, parameters, name);
+ }
+
+ /**
+ * Adds the given {@link QueryParameter}s to the {@link Affordance} to build.
+ *
+ * @param parameters must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ public AffordanceBuilder addParameters(QueryParameter... parameters) {
+
+ List newParameters = new ArrayList<>(this.parameters.size() + parameters.length);
+ newParameters.addAll(this.parameters);
+ newParameters.addAll(Arrays.asList(parameters));
+
+ return new AffordanceBuilder(context, method, target, inputMetdata, outputMetadata, newParameters, name);
+ }
+
+ /**
+ * Concludes the creation of the current {@link Affordance} to build and starts a new one.
+ *
+ * @param method must not be {@literal null}.
+ * @return
+ * @see #build()
+ * @see #toLink()
+ */
+ public AffordanceBuilder andAfford(HttpMethod method) {
+ return build().afford(method);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.AffordanceOperations#toLink()
+ */
+ @Override
+ public Link toLink() {
+ return context.link.andAffordance(buildAffordance());
+ }
+
+ /**
+ * Builds the {@link Affordance} currently under construction and returns in alongside the ones already contained in
+ * the {@link Link} the buildup started from.
+ *
+ * @return will never be {@literal null}.
+ */
+ public Affordances build() {
+ return Affordances.of(toLink());
+ }
+
+ /**
+ * Builds an {@link Affordance} from the current state of the builder.
+ *
+ * @return must not be {@literal null}.
+ */
+ private Affordance buildAffordance() {
+
+ return factories.stream() //
+ .collect(collectingAndThen(toMap(AffordanceModelFactory::getMediaType, //
+ it -> createModel(it, parameters == null ? Collections.emptyList() : parameters)), //
+ Affordance::new));
+ }
+
+ /**
+ * Creates a new {@link AffordanceModel} using the given {@link AffordanceModelFactory} and {@link QueryParameter}s.
+ *
+ * @param factory must not be {@literal null}.
+ * @param parameters must not be {@literal null}.
+ * @return will never be {@literal null}.
+ */
+ private AffordanceModel createModel(AffordanceModelFactory factory, List parameters) {
+ return factory.getAffordanceModel(getNameOrDefault(), target, method, inputMetdata, parameters, outputMetadata);
+ }
+
+ /**
+ * Returns the explicitly configured name of the {@link Affordance} or calculates a default based on the
+ * {@link HttpMethod} and type backing it.
+ *
+ * @return
+ */
+ private String getNameOrDefault() {
+
+ if (name != null) {
+ return name;
+ }
+
+ String name = method.toString().toLowerCase();
+
+ ResolvableType type = TypeBasedPayloadMetadata.class.isInstance(inputMetdata) //
+ ? TypeBasedPayloadMetadata.class.cast(inputMetdata).getType() //
+ : null;
+
+ if (type == null) {
+ return name;
+ }
+
+ Class> resolvedType = type.resolve();
+
+ return resolvedType == null ? name : name.concat(resolvedType.getSimpleName());
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java
index 424f579c..45392975 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/PropertyUtils.java
@@ -15,35 +15,46 @@
*/
package org.springframework.hateoas.mediatype;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
-import java.beans.FeatureDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
+import java.util.*;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+import org.reactivestreams.Publisher;
import org.springframework.beans.BeanUtils;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.core.ResolvableType;
-import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.annotation.MergedAnnotations;
+import org.springframework.core.convert.Property;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
+import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
-import org.springframework.hateoas.support.WebStack;
+import org.springframework.http.HttpEntity;
import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.util.ReflectionUtils;
+import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonProperty.Access;
/**
* @author Greg Turnquist
@@ -51,70 +62,43 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
*/
public class PropertyUtils {
- private final static HashSet FIELDS_TO_IGNORE = new HashSet<>();
+ private static final Map DOMAIN_TYPE_CACHE = new ConcurrentReferenceHashMap<>();
+ private static final Map METADATA_CACHE = new ConcurrentReferenceHashMap<>();
+ private static final Set FIELDS_TO_IGNORE = new HashSet<>(Arrays.asList("class", "links"));
+ private static final boolean JSR_303_PRESENT = ClassUtils.isPresent("javax.validation.Valid",
+ PropertyUtils.class.getClassLoader());
+ private static final List> TYPES_TO_UNWRAP = new ArrayList<>(
+ Arrays.asList(EntityModel.class, CollectionModel.class, HttpEntity.class));
+ private static final ResolvableType OBJECT_TYPE = ResolvableType.forClass(Object.class);
static {
- FIELDS_TO_IGNORE.add("class");
- FIELDS_TO_IGNORE.add("links");
+ if (ClassUtils.isPresent("org.reactivestreams.Publisher", PropertyUtils.class.getClassLoader())) {
+ TYPES_TO_UNWRAP.addAll(ReactiveWrappers.getTypesToUnwrap());
+ }
}
- public static Map findProperties(@Nullable Object object) {
+ private static class ReactiveWrappers {
+
+ static List> getTypesToUnwrap() {
+ return Arrays.asList(Publisher.class);
+ }
+ }
+
+ public static Map extractPropertyValues(@Nullable Object object) {
if (object == null) {
return Collections.emptyMap();
}
- if (object.getClass().equals(EntityModel.class)) {
- return findProperties(((EntityModel>) object).getContent());
+ if (EntityModel.class.isInstance(object)) {
+ return extractPropertyValues(EntityModel.class.cast(object).getContent());
}
- return getPropertyDescriptors(object.getClass()) //
- .collect(HashMap::new, (hashMap, descriptor) -> {
+ BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(object);
- try {
-
- Method readMethod = descriptor.getReadMethod();
- ReflectionUtils.makeAccessible(readMethod);
- hashMap.put(descriptor.getName(), readMethod.invoke(object));
-
- } catch (IllegalAccessException | InvocationTargetException e) {
- throw new RuntimeException(e);
- }
-
- }, HashMap::putAll);
- }
-
- public static List findPropertyNames(ResolvableType resolvableType) {
-
- Class> type = resolvableType.getRawClass();
-
- if (WebStack.WEBFLUX.isAvailable()) {
- if (Mono.class.equals(type) || Flux.class.equals(type)) {
- ResolvableType generic = resolvableType.getGeneric(0);
- return findPropertyNames(generic);
- }
- }
-
- if (type == null) {
- return Collections.emptyList();
- }
-
- if (!type.equals(EntityModel.class)) {
- return findPropertyNames(type);
- }
-
- Class> genericEntityModelParameter = resolvableType.resolveGeneric(0);
-
- return genericEntityModelParameter == null //
- ? Collections.emptyList() //
- : findPropertyNames(genericEntityModelParameter);
- }
-
- public static List findPropertyNames(Class> clazz) {
-
- return getPropertyDescriptors(clazz) //
- .map(FeatureDescriptor::getName) //
- .collect(Collectors.toList());
+ return getExposedProperties(object.getClass()).stream() //
+ .map(PropertyMetadata::getName)
+ .collect(HashMap::new, (map, name) -> map.put(name, wrapper.getPropertyValue(name)), HashMap::putAll);
}
public static T createObjectFromProperties(Class clazz, Map properties) {
@@ -140,18 +124,86 @@ public class PropertyUtils {
return obj;
}
+ public static InputPayloadMetadata getExposedProperties(@Nullable Class> type) {
+ return getExposedProperties(type == null ? null : ResolvableType.forClass(type));
+ }
+
+ /**
+ * Returns the {@link InputPayloadMetadata} model for the given {@link ResolvableType}.
+ *
+ * @param type must not be {@literal null}.
+ * @return
+ */
+ public static InputPayloadMetadata getExposedProperties(@Nullable ResolvableType type) {
+
+ if (type == null) {
+ return InputPayloadMetadata.NONE;
+ }
+
+ return METADATA_CACHE.computeIfAbsent(type, it -> {
+
+ ResolvableType domainType = unwrapDomainType(type);
+ Class> resolved = domainType.resolve(Object.class);
+
+ return Object.class.equals(resolved) //
+ ? InputPayloadMetadata.NONE //
+ : new TypeBasedPayloadMetadata(domainType, lookupExposedProperties(resolved));
+ });
+ }
+
+ private static ResolvableType unwrapDomainType(ResolvableType type) {
+
+ if (!type.hasGenerics()) {
+ return type;
+ }
+
+ if (type.hasUnresolvableGenerics()) {
+ return replaceIfUnwrappable(type, () -> OBJECT_TYPE);
+ }
+
+ return DOMAIN_TYPE_CACHE.computeIfAbsent(type,
+ it -> replaceIfUnwrappable(it, () -> unwrapDomainType(it.getGeneric(0))));
+ }
+
+ /**
+ * Replaces the given {@link ResolvableType} with the one produced by the given {@link Supplier} if the former is
+ * assignable from one of the types to be unwrapped.
+ *
+ * @param type must not be {@literal null}.
+ * @param mapper must not be {@literal null}.
+ * @return
+ * @see #TYPES_TO_UNWRAP
+ */
+ private static ResolvableType replaceIfUnwrappable(ResolvableType type, Supplier mapper) {
+
+ Class> resolved = type.resolve(Object.class);
+
+ return TYPES_TO_UNWRAP.stream().anyMatch(it -> it.isAssignableFrom(resolved)) //
+ ? mapper.get() //
+ : type;
+ }
+
+ private static Stream lookupExposedProperties(@Nullable Class> type) {
+
+ return type == null //
+ ? Stream.empty() //
+ : getPropertyDescriptors(type) //
+ .map(it -> new AnnotatedProperty(new Property(type, it.getReadMethod(), it.getWriteMethod())))
+ .map(it -> JSR_303_PRESENT ? new Jsr303AwarePropertyMetadata(it) : new DefaultPropertyMetadata(it));
+ }
+
/**
* Take a {@link Class} and find all properties that are NOT to be ignored, and return them as a {@link Stream}.
*
- * @param clazz
+ * @param type
* @return
*/
- private static Stream getPropertyDescriptors(Class> clazz) {
+ private static Stream getPropertyDescriptors(Class> type) {
- return Arrays.stream(BeanUtils.getPropertyDescriptors(clazz))
+ return Arrays.stream(BeanUtils.getPropertyDescriptors(type))
.filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName()))
- .filter(descriptor -> !descriptorToBeIgnoredByJackson(clazz, descriptor))
- .filter(descriptor -> !toBeIgnoredByJackson(clazz, descriptor.getName()))
+ .filter(descriptor -> !descriptorToBeIgnoredByJackson(type, descriptor))
+ .filter(descriptor -> !toBeIgnoredByJackson(type, descriptor.getName()))
.filter(descriptor -> !readerIsNotToBeIgnoredByJackson(descriptor));
}
@@ -168,7 +220,7 @@ public class PropertyUtils {
return descriptorField == null //
? false //
- : toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptorField));
+ : toBeIgnoredByJackson(MergedAnnotations.from(descriptorField));
}
/**
@@ -178,7 +230,7 @@ public class PropertyUtils {
* @return
*/
private static boolean readerIsNotToBeIgnoredByJackson(PropertyDescriptor descriptor) {
- return toBeIgnoredByJackson(AnnotationUtils.getAnnotations(descriptor.getReadMethod()));
+ return toBeIgnoredByJackson(MergedAnnotations.from(descriptor.getReadMethod()));
}
/**
@@ -187,15 +239,12 @@ public class PropertyUtils {
* @param annotations
* @return
*/
- private static boolean toBeIgnoredByJackson(@Nullable Annotation[] annotations) {
+ private static boolean toBeIgnoredByJackson(MergedAnnotations annotations) {
- return annotations == null //
- ? false
- : Arrays.stream(annotations) //
- .filter(annotation -> annotation.annotationType().equals(JsonIgnore.class)) //
- .findFirst() //
- .map(annotation -> (Boolean) AnnotationUtils.getAnnotationAttributes(annotation).get("value")) //
- .orElse(false);
+ return annotations.stream(JsonIgnore.class) //
+ .findFirst() //
+ .map(it -> it.getBoolean("value")) //
+ .orElse(false);
}
/**
@@ -207,14 +256,261 @@ public class PropertyUtils {
*/
private static boolean toBeIgnoredByJackson(Class> clazz, String field) {
- Annotation[] annotations = AnnotationUtils.getAnnotations(clazz);
+ MergedAnnotations annotations = MergedAnnotations.from(clazz);
- return annotations == null //
- ? false //
- : Arrays.stream(annotations) //
- .filter(annotation -> annotation.annotationType().equals(JsonIgnoreProperties.class)) //
- .map(annotation -> (String[]) AnnotationUtils.getAnnotationAttributes(annotation).get("value")) //
- .flatMap(Arrays::stream) //
- .anyMatch(propertyName -> propertyName.equalsIgnoreCase(field));
+ return annotations.stream(JsonIgnoreProperties.class) //
+ .map(it -> it.getStringArray("value")) //
+ .flatMap(Arrays::stream) //
+ .anyMatch(it -> it.equalsIgnoreCase(field));
+ }
+
+ /**
+ * An abstraction of a {@link Property} in combination with an underlying field for the purpose of looking up
+ * annotations on either the accessors or the field itself.
+ *
+ * @author Oliver Drotbohm
+ */
+ private static class AnnotatedProperty {
+
+ private final Map, MergedAnnotation>> annotationCache = new ConcurrentReferenceHashMap<>();
+
+ private final Property property;
+ private final ResolvableType type;
+ private final List annotations;
+ private final MergedAnnotations typeAnnotations;
+
+ /**
+ * Creates a new {@link AnnotatedProperty} for the given {@link Property}.
+ *
+ * @param property must not be {@literal null}.
+ */
+ @SuppressWarnings("unchecked")
+ public AnnotatedProperty(Property property) {
+
+ Assert.notNull(property, "Property must not be null!");
+
+ this.property = property;
+
+ Field field = ReflectionUtils.findField(property.getObjectType(), property.getName());
+
+ this.type = firstNonEmpty( //
+ () -> Optional.ofNullable(property.getReadMethod()).map(ResolvableType::forMethodReturnType), //
+ () -> Optional.ofNullable(property.getWriteMethod()).map(it -> ResolvableType.forMethodParameter(it, 0)), //
+ () -> Optional.ofNullable(field).map(ResolvableType::forField));
+
+ this.annotations = Stream.of(property.getReadMethod(), property.getWriteMethod(), field) //
+ .filter(it -> it != null) //
+ .map(MergedAnnotations::from) //
+ .collect(Collectors.toList());
+
+ this.typeAnnotations = MergedAnnotations.from(this.type.resolve(Object.class));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T firstNonEmpty(Supplier>... suppliers) {
+
+ Assert.notNull(suppliers, "Suppliers must not be null!");
+
+ return Stream.of(suppliers) //
+ .map(Supplier::get).flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty)) //
+ .findFirst() //
+ .orElseThrow(() -> new IllegalStateException("Could not resolve value!"));
+ }
+
+ /**
+ * Returns the name of the property.
+ *
+ * @return will never be {@literal null} or empty.
+ */
+ public String getName() {
+ return property.getName();
+ }
+
+ /**
+ * Returns the property type.
+ *
+ * @return will never be {@literal null}.
+ */
+ public ResolvableType getType() {
+ return type;
+ }
+
+ /**
+ * Returns the annotations on the type of the property.
+ *
+ * @return will never be {@literal null}.
+ */
+ public MergedAnnotations getTypeAnnotations() {
+ return typeAnnotations;
+ }
+
+ /**
+ * Returns whether the write method for the property is present.
+ *
+ * @return
+ */
+ public boolean hasWriteMethod() {
+ return property.getWriteMethod() != null;
+ }
+
+ /**
+ * Returns the {@link MergedAnnotation} of the given type.
+ *
+ * @param the annotation type.
+ * @param type must not be {@literal null}.
+ * @return the {@link MergedAnnotation} if available or {@link MergedAnnotation#missing()} if not.
+ */
+ @SuppressWarnings("unchecked")
+ public MergedAnnotation getAnnotation(Class type) {
+
+ Assert.notNull(type, "Type must not be null!");
+
+ return (MergedAnnotation) annotationCache.computeIfAbsent(type, it -> lookupAnnotation(type));
+ }
+
+ private MergedAnnotation lookupAnnotation(Class type) {
+
+ return this.annotations.stream() //
+ .map(it -> it.get(type)) //
+ .filter(it -> it != null && it.isPresent()) //
+ .findFirst() //
+ .orElse(MergedAnnotation.missing());
+ }
+ }
+
+ /**
+ * Default {@link PropertyMetadata} implementation, considering accessor methods and Jackson annotations to calculate
+ * the metadata settings.
+ *
+ * @author Oliver Drotbohm
+ */
+ @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+ private static class DefaultPropertyMetadata implements PropertyMetadata, Comparable {
+
+ private static Comparator BY_NAME = Comparator.comparing(PropertyMetadata::getName);
+
+ private final AnnotatedProperty property;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyMetadata#getName()
+ */
+ @Override
+ public String getName() {
+ return property.getName();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyMetadata#isRequired()
+ */
+ @Override
+ public boolean isRequired() {
+ return false;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyMetadata#isReadOnly()
+ */
+ @Override
+ public boolean isReadOnly() {
+
+ if (!property.hasWriteMethod()) {
+ return true;
+ }
+
+ MergedAnnotation annotation = property.getAnnotation(JsonProperty.class);
+
+ return !annotation.isPresent() //
+ ? false //
+ : Access.READ_ONLY.equals(annotation.getEnum("access", Access.class));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyMetadata#getRegex()
+ */
+ @Override
+ public Optional getPattern() {
+ return Optional.empty();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.PropertyMetadata#getType()
+ */
+ @Override
+ public ResolvableType getType() {
+ return property.getType();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see java.lang.Comparable#compareTo(java.lang.Object)
+ */
+ @Override
+ @SuppressWarnings("null")
+ public int compareTo(DefaultPropertyMetadata that) {
+ return BY_NAME.compare(this, that);
+ }
+
+ }
+
+ /**
+ * Creates a new {@link PropertyMetadata} aware of JSR-303 annotationns.
+ *
+ * @author Oliver Drotbohm
+ */
+ private static class Jsr303AwarePropertyMetadata extends DefaultPropertyMetadata {
+
+ private final AnnotatedProperty property;
+
+ /**
+ * Creates a new {@link Jsr303AwarePropertyMetadata} instance for the given {@link AnnotatedProperty}.
+ *
+ * @param property must not be {@literal null}.
+ */
+ private Jsr303AwarePropertyMetadata(AnnotatedProperty property) {
+
+ super(property);
+
+ this.property = property;
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata#isRequired()
+ */
+ @Override
+ public boolean isRequired() {
+ return super.isRequired() || property.getAnnotation(NotNull.class).isPresent();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata#getRegex()
+ */
+ @Override
+ public Optional getPattern() {
+
+ MergedAnnotation annotation = property.getAnnotation(Pattern.class);
+
+ if (annotation.isPresent()) {
+ return fromAnnotation(annotation);
+ }
+
+ annotation = property.getTypeAnnotations().get(Pattern.class);
+
+ return annotation.isPresent() //
+ ? fromAnnotation(annotation) //
+ : Optional.empty();
+ }
+
+ private static Optional fromAnnotation(MergedAnnotation annotation) {
+
+ return Optional.of(annotation.getString("regexp")) //
+ .filter(StringUtils::hasText);
+ }
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java
new file mode 100644
index 00000000..e9403f77
--- /dev/null
+++ b/src/main/java/org/springframework/hateoas/mediatype/TypeBasedPayloadMetadata.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.springframework.core.ResolvableType;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.Named;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured;
+
+/**
+ * {@link InputPayloadMetadata} implementation based on a Java type.
+ *
+ * @author Oliver Drotbohm
+ */
+class TypeBasedPayloadMetadata implements InputPayloadMetadata {
+
+ private final @Getter(AccessLevel.PACKAGE) ResolvableType type;
+ private final SortedMap properties;
+
+ TypeBasedPayloadMetadata(ResolvableType type, Stream properties) {
+
+ this.type = type;
+ this.properties = new TreeMap<>(
+ properties.collect(Collectors.toMap(PropertyMetadata::getName, Function.identity())));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PayloadMetadata#customize(T)
+ */
+ @Override
+ public & Named> T applyTo(T target) {
+
+ PropertyMetadata metadata = this.properties.get(target.getName());
+
+ return metadata == null ? target : target.apply(metadata);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.AffordanceModel.PayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.Named, java.util.function.Function)
+ */
+ @Override
+ public T customize(T target, Function customizer) {
+
+ PropertyMetadata metadata = this.properties.get(target.getName());
+
+ return metadata == null ? target : customizer.apply(metadata);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PayloadMetadata#stream()
+ */
+ @Override
+ public Stream stream() {
+ return properties.values().stream();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.MessageSourceResolvable#getCodes()
+ */
+ @Override
+ public List getI18nCodes() {
+
+ Class> type = this.type.resolve(Object.class);
+
+ return Arrays.asList(type.getName(), type.getSimpleName());
+ }
+}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModel.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModel.java
index b41694dc..9ffc041f 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModel.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModel.java
@@ -24,12 +24,10 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.QueryParameter;
-import org.springframework.hateoas.mediatype.PropertyUtils;
import org.springframework.http.HttpMethod;
/**
@@ -47,8 +45,8 @@ class CollectionJsonAffordanceModel extends AffordanceModel {
private final @Getter List inputProperties;
private final @Getter List queryProperties;
- public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
+ public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
+ List queryMethodParameters, PayloadMetadata outputType) {
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
@@ -66,7 +64,7 @@ class CollectionJsonAffordanceModel extends AffordanceModel {
return Collections.emptyList();
}
- return PropertyUtils.findPropertyNames(getInputType()).stream() //
+ return getInput().stream().map(PropertyMetadata::getName) //
.map(propertyName -> new CollectionJsonData() //
.withName(propertyName) //
.withValue("")) //
diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModelFactory.java
index cd6d77de..25274f47 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModelFactory.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonAffordanceModelFactory.java
@@ -19,9 +19,10 @@ import lombok.Getter;
import java.util.List;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.AffordanceModel;
-import org.springframework.hateoas.AffordanceModelFactory;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.mediatype.AffordanceModelFactory;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.QueryParameter;
@@ -38,7 +39,8 @@ class CollectionJsonAffordanceModelFactory implements AffordanceModelFactory {
private final @Getter MediaType mediaType = MediaTypes.COLLECTION_JSON;
@Override
- public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) {
+ public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
+ InputPayloadMetadata inputType, List queryMethodParameters, PayloadMetadata outputType) {
return new CollectionJsonAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonItem.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonItem.java
index c3d8c018..19741849 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonItem.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/CollectionJsonItem.java
@@ -89,7 +89,7 @@ class CollectionJsonItem {
return Collections.singletonList(new CollectionJsonData().withValue(this.rawData));
}
- return PropertyUtils.findProperties(this.rawData).entrySet().stream() //
+ return PropertyUtils.extractPropertyValues(this.rawData).entrySet().stream() //
.map(entry -> new CollectionJsonData() //
.withName(entry.getKey()) //
.withValue(entry.getValue())) //
diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
index a3cf5699..ab27a43e 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java
@@ -37,6 +37,7 @@ import org.springframework.hateoas.mediatype.JacksonHelper;
import org.springframework.hateoas.mediatype.PropertyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
@@ -947,6 +948,7 @@ class Jackson2CollectionJsonModule extends SimpleModule {
return selfLink.getAffordances().stream() //
.map(it -> it.getAffordanceModel(MediaTypes.COLLECTION_JSON)) //
+ .peek(it -> Assert.notNull(it, "No Collection/JSON affordance model found but expected!"))
.map(CollectionJsonAffordanceModel.class::cast) //
.filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
.filter(it -> !it.pointsToTargetOf(selfLink)) //
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java
index 14497998..96a9a7b1 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModel.java
@@ -26,12 +26,10 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.QueryParameter;
-import org.springframework.hateoas.mediatype.PropertyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
@@ -45,12 +43,11 @@ import org.springframework.http.MediaType;
class HalFormsAffordanceModel extends AffordanceModel {
private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH);
- private static final Set REQUIRED_METHODS = EnumSet.of(POST, PUT);
private final @Getter List inputProperties;
- public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
+ public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
+ List queryMethodParameters, PayloadMetadata outputType) {
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
@@ -67,10 +64,10 @@ class HalFormsAffordanceModel extends AffordanceModel {
return Collections.emptyList();
}
- return PropertyUtils.findPropertyNames(getInputType()).stream() //
- .map(propertyName -> new HalFormsProperty() //
- .withName(propertyName) //
- .withRequired(REQUIRED_METHODS.contains(getHttpMethod()))) //
+ return getInput().stream() //
+ .map(PropertyMetadata::getName) //
+ .map(it -> new HalFormsProperty() //
+ .withName(it)) //
.collect(Collectors.toList());
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModelFactory.java
index 9d156210..e764bf68 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModelFactory.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsAffordanceModelFactory.java
@@ -19,9 +19,10 @@ import lombok.Getter;
import java.util.List;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.AffordanceModel;
-import org.springframework.hateoas.AffordanceModelFactory;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.mediatype.AffordanceModelFactory;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.QueryParameter;
@@ -43,8 +44,8 @@ class HalFormsAffordanceModelFactory implements AffordanceModelFactory {
* @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.core.ResolvableType, java.util.List, org.springframework.core.ResolvableType)
*/
@Override
- public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
- return new HalFormsAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
+ public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
+ InputPayloadMetadata inputType, List parameters, PayloadMetadata outputType) {
+ return new HalFormsAffordanceModel(name, link, httpMethod, inputType, parameters, outputType);
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java
index 195f3ecc..2756ca8c 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsConfiguration.java
@@ -18,6 +18,11 @@ package org.springframework.hateoas.mediatype.hal.forms;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.springframework.core.ResolvableType;
import org.springframework.hateoas.mediatype.hal.HalConfiguration;
/**
@@ -30,6 +35,7 @@ import org.springframework.hateoas.mediatype.hal.HalConfiguration;
public class HalFormsConfiguration {
private final @Getter HalConfiguration halConfiguration;
+ private final Map, String> patterns = new HashMap<>();
/**
* Creates a new {@link HalFormsConfiguration} backed by a default {@link HalConfiguration}.
@@ -37,4 +43,21 @@ public class HalFormsConfiguration {
public HalFormsConfiguration() {
this.halConfiguration = new HalConfiguration();
}
+
+ public HalFormsConfiguration registerPattern(Class> type, String pattern) {
+
+ patterns.put(type, pattern);
+
+ return this;
+ }
+
+ /**
+ * Returns the regular expression pattern that is registered for the given type.
+ *
+ * @param type must not be {@literal null}.
+ * @return
+ */
+ Optional getTypePatternFor(ResolvableType type) {
+ return Optional.ofNullable(patterns.get(type.resolve(Object.class)));
+ }
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsDocument.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsDocument.java
index 51486991..1d302368 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsDocument.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsDocument.java
@@ -58,24 +58,24 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
@Value
@Wither
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
-@JsonPropertyOrder({ "attributes", "resource", "resources", "embedded", "links", "templates", "metadata" })
+@JsonPropertyOrder({ "attributes", "entity", "entities", "embedded", "links", "templates", "metadata" })
public class HalFormsDocument {
@Nullable //
- @Getter(onMethod = @__(@JsonAnyGetter))
- @JsonInclude(Include.NON_EMPTY) //
+ @Getter(onMethod = @__(@JsonAnyGetter)) @JsonInclude(Include.NON_EMPTY) //
@Wither(AccessLevel.PRIVATE) //
private Map attributes;
+ @Nullable //
@JsonUnwrapped //
@JsonInclude(Include.NON_NULL) //
- private T resource;
+ private T entity;
@Nullable //
@JsonInclude(Include.NON_EMPTY) //
@JsonIgnore //
@Wither(AccessLevel.PRIVATE) //
- private Collection resources;
+ private Collection entities;
@JsonProperty("_embedded") //
@JsonInclude(Include.NON_EMPTY) //
@@ -86,14 +86,12 @@ public class HalFormsDocument {
@JsonInclude(Include.NON_NULL) //
private PagedModel.PageMetadata pageMetadata;
- // @Singular //
@JsonProperty("_links") //
@JsonInclude(Include.NON_EMPTY) //
@JsonSerialize(using = HalLinkListSerializer.class) //
@JsonDeserialize(using = HalFormsLinksDeserializer.class) //
private Links links;
- // @Singular //
@JsonProperty("_templates") //
@JsonInclude(Include.NON_EMPTY) //
private Map templates;
@@ -110,7 +108,7 @@ public class HalFormsDocument {
*/
public static HalFormsDocument> forRepresentationModel(RepresentationModel> model) {
- Map attributes = PropertyUtils.findProperties(model);
+ Map attributes = PropertyUtils.extractPropertyValues(model);
attributes.remove("links");
return new HalFormsDocument<>().withAttributes(attributes);
@@ -122,21 +120,21 @@ public class HalFormsDocument {
* @param resource can be {@literal null}.
* @return
*/
- public static HalFormsDocument forResource(@Nullable T resource) {
- return new HalFormsDocument().withResource(resource);
+ public static HalFormsDocument forEntity(@Nullable T resource) {
+ return new HalFormsDocument().withEntity(resource);
}
/**
* returns a new {@link HalFormsDocument} for the given resources.
*
- * @param resources must not be {@literal null}.
+ * @param entities must not be {@literal null}.
* @return
*/
- public static HalFormsDocument forResources(Collection resources) {
+ public static HalFormsDocument forEntities(Collection entities) {
- Assert.notNull(resources, "Resources must not be null!");
+ Assert.notNull(entities, "Resources must not be null!");
- return new HalFormsDocument().withResources(resources);
+ return new HalFormsDocument().withEntities(entities);
}
/**
@@ -173,11 +171,11 @@ public class HalFormsDocument {
}
public HalFormsDocument withPageMetadata(@Nullable PageMetadata metadata) {
- return new HalFormsDocument(attributes, resource, resources, embedded, metadata, links, templates);
+ return new HalFormsDocument(attributes, entity, entities, embedded, metadata, links, templates);
}
- private HalFormsDocument withResource(@Nullable T resource) {
- return new HalFormsDocument(attributes, resource, resources, embedded, pageMetadata, links, templates);
+ private HalFormsDocument withEntity(@Nullable T entity) {
+ return new HalFormsDocument(attributes, entity, entities, embedded, pageMetadata, links, templates);
}
/**
@@ -190,7 +188,7 @@ public class HalFormsDocument {
Assert.notNull(link, "Link must not be null!");
- return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links.and(link), templates);
+ return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links.and(link), templates);
}
/**
@@ -208,7 +206,7 @@ public class HalFormsDocument {
Map templates = new HashMap<>(this.templates);
templates.put(name, template);
- return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links, templates);
+ return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links, templates);
}
/**
@@ -226,6 +224,6 @@ public class HalFormsDocument {
Map embedded = new HashMap<>(this.embedded);
embedded.put(key, value);
- return new HalFormsDocument<>(attributes, resource, resources, embedded, pageMetadata, links, templates);
+ return new HalFormsDocument<>(attributes, entity, entities, embedded, pageMetadata, links, templates);
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java
index 8e8ca6ca..379efc70 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsProperty.java
@@ -19,9 +19,14 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.NonNull;
+import lombok.ToString;
import lombok.Value;
import lombok.experimental.Wither;
+import org.springframework.hateoas.AffordanceModel.Named;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured;
+
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
@@ -36,15 +41,16 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
@Wither
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true)
-public class HalFormsProperty {
+@ToString
+public class HalFormsProperty implements PropertyMetadataConfigured, Named {
private @NonNull String name;
- private Boolean readOnly;
+ private @JsonInclude(Include.NON_DEFAULT) boolean readOnly;
private String value;
- private String prompt;
+ private @JsonInclude(Include.NON_EMPTY) String prompt;
private String regex;
private boolean templated;
- private @JsonInclude boolean required;
+ private @JsonInclude(Include.NON_DEFAULT) boolean required;
private boolean multi;
/**
@@ -53,8 +59,21 @@ public class HalFormsProperty {
* @param name must not be {@literal null}.
* @return
*/
-
public static HalFormsProperty named(String name) {
return new HalFormsProperty().withName(name);
}
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.hateoas.mediatype.PropertyMetadataAware#apply(org.springframework.hateoas.mediatype.PropertyUtils.PropertyMetadata)
+ */
+ public HalFormsProperty apply(PropertyMetadata metadata) {
+
+ HalFormsProperty customized = withRequired(metadata.isRequired()) //
+ .withReadOnly(metadata.isReadOnly());
+
+ return metadata.getPattern() //
+ .map(customized::withRegex) //
+ .orElse(customized);
+ }
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java
index 97d09787..149df147 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsSerializers.java
@@ -15,32 +15,16 @@
*/
package org.springframework.hateoas.mediatype.hal.forms;
-import lombok.RequiredArgsConstructor;
-
import java.io.IOException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import org.springframework.context.MessageSourceResolvable;
-import org.springframework.context.support.MessageSourceAccessor;
-import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
-import org.springframework.hateoas.IanaLinkRelations;
-import org.springframework.hateoas.Link;
-import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.mediatype.hal.HalLinkRelation;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
-import org.springframework.http.HttpMethod;
-import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
-import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
@@ -64,18 +48,19 @@ class HalFormsSerializers {
private static final long serialVersionUID = -4583146321934407153L;
- private final MessageSourceAccessor accessor;
+ private final HalFormsTemplateBuilder builder;
private final BeanProperty property;
- HalFormsRepresentationModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property) {
+ HalFormsRepresentationModelSerializer(HalFormsTemplateBuilder builder, @Nullable BeanProperty property) {
super(RepresentationModel.class, false);
+
+ this.builder = builder;
this.property = property;
- this.accessor = accessor;
}
- HalFormsRepresentationModelSerializer(MessageSourceAccessor accessor) {
- this(accessor, null);
+ HalFormsRepresentationModelSerializer(HalFormsTemplateBuilder customizations) {
+ this(customizations, null);
}
/*
@@ -89,7 +74,7 @@ class HalFormsSerializers {
HalFormsDocument> doc = HalFormsDocument.forRepresentationModel(value) //
.withLinks(value.getLinks()) //
- .withTemplates(findTemplates(value, accessor));
+ .withTemplates(builder.findTemplates(value));
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
}
@@ -124,7 +109,7 @@ class HalFormsSerializers {
@Override
@SuppressWarnings("null")
public JsonSerializer> createContextual(SerializerProvider prov, BeanProperty property) {
- return new HalFormsRepresentationModelSerializer(accessor, property);
+ return new HalFormsRepresentationModelSerializer(builder, property);
}
}
@@ -136,18 +121,19 @@ class HalFormsSerializers {
private static final long serialVersionUID = -7912243216469101379L;
- private final MessageSourceAccessor accessor;
+ private final HalFormsTemplateBuilder builder;
private final BeanProperty property;
- HalFormsEntityModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property) {
+ HalFormsEntityModelSerializer(HalFormsTemplateBuilder builder, @Nullable BeanProperty property) {
super(EntityModel.class, false);
- this.accessor = accessor;
+
+ this.builder = builder;
this.property = property;
}
- HalFormsEntityModelSerializer(MessageSourceAccessor accessor) {
- this(accessor, null);
+ HalFormsEntityModelSerializer(HalFormsTemplateBuilder builder) {
+ this(builder, null);
}
/*
@@ -158,9 +144,9 @@ class HalFormsSerializers {
@SuppressWarnings("null")
public void serialize(EntityModel> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
- HalFormsDocument> doc = HalFormsDocument.forResource(value.getContent()) //
+ HalFormsDocument> doc = HalFormsDocument.forEntity(value.getContent()) //
.withLinks(value.getLinks()) //
- .withTemplates(findTemplates(value, accessor));
+ .withTemplates(builder.findTemplates(value));
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
}
@@ -214,7 +200,7 @@ class HalFormsSerializers {
@SuppressWarnings("null")
public JsonSerializer> createContextual(SerializerProvider prov, BeanProperty property)
throws JsonMappingException {
- return new HalFormsEntityModelSerializer(accessor, property);
+ return new HalFormsEntityModelSerializer(builder, property);
}
}
@@ -228,20 +214,21 @@ class HalFormsSerializers {
private final BeanProperty property;
private final Jackson2HalModule.EmbeddedMapper embeddedMapper;
- private final MessageSourceAccessor accessor;
+ private final HalFormsTemplateBuilder customizations;
- HalFormsCollectionModelSerializer(MessageSourceAccessor accessor, @Nullable BeanProperty property,
+ HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations, @Nullable BeanProperty property,
Jackson2HalModule.EmbeddedMapper embeddedMapper) {
super(CollectionModel.class, false);
this.property = property;
this.embeddedMapper = embeddedMapper;
- this.accessor = accessor;
+ this.customizations = customizations;
}
- HalFormsCollectionModelSerializer(MessageSourceAccessor accessor, Jackson2HalModule.EmbeddedMapper embeddedMapper) {
- this(accessor, null, embeddedMapper);
+ HalFormsCollectionModelSerializer(HalFormsTemplateBuilder customizations,
+ Jackson2HalModule.EmbeddedMapper embeddedMapper) {
+ this(customizations, null, embeddedMapper);
}
/*
@@ -262,14 +249,14 @@ class HalFormsSerializers {
.withEmbedded(embeddeds) //
.withPageMetadata(((PagedModel>) value).getMetadata()) //
.withLinks(value.getLinks()) //
- .withTemplates(findTemplates(value, accessor));
+ .withTemplates(customizations.findTemplates(value));
} else {
doc = HalFormsDocument.empty() //
.withEmbedded(embeddeds) //
.withLinks(value.getLinks()) //
- .withTemplates(findTemplates(value, accessor));
+ .withTemplates(customizations.findTemplates(value));
}
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
@@ -324,137 +311,7 @@ class HalFormsSerializers {
@SuppressWarnings("null")
public JsonSerializer> createContextual(SerializerProvider prov, BeanProperty property)
throws JsonMappingException {
- return new HalFormsCollectionModelSerializer(accessor, property, embeddedMapper);
- }
- }
-
- /**
- * Extract template details from a {@link RepresentationModel}'s {@link Affordance}s.
- *
- * @param resource
- * @return
- */
- private static Map findTemplates(RepresentationModel> resource,
- MessageSourceAccessor accessor) {
-
- if (!resource.hasLink(IanaLinkRelations.SELF)) {
- return Collections.emptyMap();
- }
-
- Map templates = new HashMap<>();
- List affordances = resource.getLink(IanaLinkRelations.SELF) //
- .map(Link::getAffordances) //
- .orElse(Collections.emptyList());
-
- affordances.stream() //
- .map(it -> it.getAffordanceModel(MediaTypes.HAL_FORMS_JSON)) //
- .map(HalFormsAffordanceModel.class::cast) //
- .filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
- .forEach(it -> {
-
- Class> type = it.getInputType().resolve(Object.class);
-
- List propertiesWithPrompt = it.getInputProperties().stream() //
- .map(property -> property.withPrompt(accessor.getMessage(PropertyPrompt.of(type, property))))
- .collect(Collectors.toList());
-
- HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) //
- .withProperties(propertiesWithPrompt);
-
- String defaultedName = templates.isEmpty() ? "default" : it.getName();
- String title = accessor.getMessage(TemplateTitle.of(it, templates.isEmpty()));
-
- if (StringUtils.hasText(title)) {
- template = template.withTitle(title);
- }
-
- /*
- * First template in HAL-FORMS is "default".
- */
- templates.put(defaultedName, template);
- });
-
- return templates;
- }
-
- @RequiredArgsConstructor(staticName = "of")
- static class TemplateTitle implements MessageSourceResolvable {
-
- private static final String TEMPLATE_TEMPLATE = "_templates.%s.title";
-
- private final HalFormsAffordanceModel affordance;
- private final boolean soleTemplate;
-
- /*
- * (non-Javadoc)
- * @see org.springframework.context.MessageSourceResolvable#getCodes()
- */
- @NonNull
- @Override
- public String[] getCodes() {
-
- Stream seed = Stream.concat(//
- Stream.of(affordance.getName()), //
- soleTemplate ? Stream.of("default") : Stream.empty());
-
- Class> type = affordance.getInputType().resolve(Object.class);
-
- return seed.flatMap(it -> getCodesFor(it, type)) //
- .toArray(String[]::new);
- }
-
- private static Stream getCodesFor(String name, Class> type) {
-
- String global = String.format(TEMPLATE_TEMPLATE, name);
-
- return Stream.of(//
- String.format("%s.%s", type.getName(), global), //
- String.format("%s.%s", type.getSimpleName(), global), //
- global);
- }
-
- /*
- * (non-Javadoc)
- * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
- */
- @Nullable
- @Override
- public String getDefaultMessage() {
- return "";
- }
- }
-
- @RequiredArgsConstructor(staticName = "of")
- static class PropertyPrompt implements MessageSourceResolvable {
-
- private static final String PROMPT_TEMPLATE = "%s._prompt";
-
- private final Class> type;
- private final HalFormsProperty property;
-
- /*
- * (non-Javadoc)
- * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
- */
- @Nullable
- @Override
- public String getDefaultMessage() {
- return StringUtils.capitalize(property.getName());
- }
-
- /*
- * (non-Javadoc)
- * @see org.springframework.context.MessageSourceResolvable#getCodes()
- */
- @NonNull
- @Override
- public String[] getCodes() {
-
- String globalCode = String.format(PROMPT_TEMPLATE, property.getName());
- String localCode = String.format("%s.%s", type.getSimpleName(), globalCode);
- String qualifiedCode = String.format("%s.%s", type.getName(), globalCode);
-
- return new String[] { qualifiedCode, localCode, globalCode };
+ return new HalFormsCollectionModelSerializer(customizations, property, embeddedMapper);
}
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplate.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplate.java
index 2d55242b..a6d81057 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplate.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplate.java
@@ -26,6 +26,7 @@ import lombok.experimental.Wither;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import org.springframework.hateoas.mediatype.hal.forms.HalFormsDeserializers.MediaTypesDeserializer;
import org.springframework.http.HttpMethod;
@@ -37,6 +38,8 @@ import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@@ -60,7 +63,7 @@ public class HalFormsTemplate {
public static final String DEFAULT_KEY = "default";
- private String title;
+ private @JsonInclude(Include.NON_EMPTY) String title;
private @Wither(AccessLevel.PRIVATE) HttpMethod httpMethod;
private List properties;
private List contentTypes;
@@ -108,6 +111,7 @@ public class HalFormsTemplate {
// Jackson helper methods to create the right representation format
+ @JsonInclude(Include.NON_EMPTY)
String getContentType() {
return StringUtils.collectionToDelimitedString(contentTypes, ", ");
}
@@ -125,4 +129,8 @@ public class HalFormsTemplate {
void setMethod(String method) {
this.httpMethod = HttpMethod.valueOf(method.toUpperCase());
}
+
+ Optional getPropertyByName(String name) {
+ return properties.stream().filter(it -> it.getName().equals(name)).findFirst();
+ }
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java
new file mode 100644
index 00000000..8abe141f
--- /dev/null
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilder.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype.hal.forms;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.springframework.context.MessageSourceResolvable;
+import org.springframework.context.support.MessageSourceAccessor;
+import org.springframework.hateoas.Affordance;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.MediaTypes;
+import org.springframework.hateoas.RepresentationModel;
+import org.springframework.http.HttpMethod;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+@RequiredArgsConstructor
+class HalFormsTemplateBuilder {
+
+ private final HalFormsConfiguration configuration;
+ private final MessageSourceAccessor accessor;
+
+ /**
+ * Extract template details from a {@link RepresentationModel}'s {@link Affordance}s.
+ *
+ * @param resource
+ * @return
+ */
+ public Map findTemplates(RepresentationModel> resource) {
+
+ if (!resource.hasLink(IanaLinkRelations.SELF)) {
+ return Collections.emptyMap();
+ }
+
+ Map templates = new HashMap<>();
+ List affordances = resource.getLink(IanaLinkRelations.SELF) //
+ .map(Link::getAffordances) //
+ .orElse(Collections.emptyList());
+
+ affordances.stream() //
+ .map(it -> it.getAffordanceModel(MediaTypes.HAL_FORMS_JSON)) //
+ .peek(it -> {
+ Assert.notNull(it, "No HAL Forms affordance model found but expected!");
+ }) //
+ .map(HalFormsAffordanceModel.class::cast) //
+ .filter(it -> !it.hasHttpMethod(HttpMethod.GET)) //
+ .forEach(it -> {
+
+ PropertyCustomizations propertyCustomizations = forMetadata(it.getInput());
+
+ List propertiesWithPrompt = it.getInputProperties().stream() //
+ .map(property -> propertyCustomizations.apply(property)) //
+ .map(property -> it.hasHttpMethod(HttpMethod.PATCH) ? property.withRequired(false) : property)
+ .collect(Collectors.toList());
+
+ HalFormsTemplate template = HalFormsTemplate.forMethod(it.getHttpMethod()) //
+ .withProperties(propertiesWithPrompt);
+
+ template = applyTo(template, TemplateTitle.of(it, templates.isEmpty()));
+ templates.put(templates.isEmpty() ? "default" : it.getName(), template);
+ });
+
+ return templates;
+ }
+
+ public PropertyCustomizations forMetadata(InputPayloadMetadata metadata) {
+ return new PropertyCustomizations(metadata);
+ }
+
+ public HalFormsTemplate applyTo(HalFormsTemplate template, HalFormsTemplateBuilder.TemplateTitle templateTitle) {
+
+ return Optional.ofNullable(accessor.getMessage(templateTitle)) //
+ .filter(StringUtils::hasText) //
+ .map(template::withTitle) //
+ .orElse(template);
+ }
+
+ @RequiredArgsConstructor
+ class PropertyCustomizations {
+
+ private final InputPayloadMetadata metadata;
+
+ private HalFormsProperty apply(HalFormsProperty property) {
+
+ String message = accessor.getMessage(PropertyPrompt.of(metadata, property));
+ HalFormsProperty withPrompt = property.withPrompt(message);
+ HalFormsProperty withConfig = metadata.getPropertyMetadata(withPrompt.getName()) //
+ .flatMap(it -> applyConfig(it, withPrompt)) //
+ .orElse(withPrompt);
+
+ return metadata.applyTo(withConfig);
+ }
+
+ private Optional applyConfig(PropertyMetadata metadata, HalFormsProperty property) {
+ return configuration.getTypePatternFor(metadata.getType()).map(property::withRegex);
+ }
+ }
+
+ @RequiredArgsConstructor(staticName = "of")
+ static class TemplateTitle implements MessageSourceResolvable {
+
+ private static final String TEMPLATE_TEMPLATE = "_templates.%s.title";
+
+ private final HalFormsAffordanceModel affordance;
+ private final boolean soleTemplate;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.MessageSourceResolvable#getCodes()
+ */
+ @NonNull
+ @Override
+ public String[] getCodes() {
+
+ Stream seed = Stream.concat(//
+ Stream.of(affordance.getName()), //
+ soleTemplate ? Stream.of("default") : Stream.empty());
+
+ return seed.flatMap(it -> getCodesFor(it, affordance.getInput())) //
+ .toArray(String[]::new);
+ }
+
+ private static Stream getCodesFor(String name, InputPayloadMetadata type) {
+
+ String global = String.format(TEMPLATE_TEMPLATE, name);
+
+ Stream inputBased = type.getI18nCodes().stream() //
+ .map(it -> String.format("%s.%s", it, global));
+
+ return Stream.concat(inputBased, Stream.of(global));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
+ */
+ @Nullable
+ @Override
+ public String getDefaultMessage() {
+ return "";
+ }
+ }
+
+ @RequiredArgsConstructor(staticName = "of")
+ static class PropertyPrompt implements MessageSourceResolvable {
+
+ private static final String PROMPT_TEMPLATE = "%s._prompt";
+
+ private final InputPayloadMetadata metadata;
+ private final HalFormsProperty property;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.MessageSourceResolvable#getDefaultMessage()
+ */
+ @Nullable
+ @Override
+ public String getDefaultMessage() {
+ return "";
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.context.MessageSourceResolvable#getCodes()
+ */
+ @NonNull
+ @Override
+ public String[] getCodes() {
+
+ String globalCode = String.format(PROMPT_TEMPLATE, property.getName());
+
+ List codes = new ArrayList<>();
+
+ metadata.getI18nCodes().stream() //
+ .map(it -> String.format("%s.%s", it, globalCode)) //
+ .forEach(codes::add);
+
+ codes.add(globalCode);
+
+ return codes.toArray(new String[codes.size()]);
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java
index be49ad0e..279783d1 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java
@@ -168,12 +168,13 @@ class Jackson2HalFormsModule extends SimpleModule {
super(resolver, curieProvider, accessor, enforceEmbeddedCollections, configuration.getHalConfiguration());
EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections);
+ HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, accessor);
this.serializers.put(HalFormsRepresentationModelSerializer.class,
- new HalFormsRepresentationModelSerializer(accessor));
- this.serializers.put(HalFormsEntityModelSerializer.class, new HalFormsEntityModelSerializer(accessor));
+ new HalFormsRepresentationModelSerializer(builder));
+ this.serializers.put(HalFormsEntityModelSerializer.class, new HalFormsEntityModelSerializer(builder));
this.serializers.put(HalFormsCollectionModelSerializer.class,
- new HalFormsCollectionModelSerializer(accessor, mapper));
+ new HalFormsCollectionModelSerializer(builder, mapper));
this.serializers.put(HalLinkListSerializer.class,
new HalLinkListSerializer(curieProvider, mapper, accessor, configuration.getHalConfiguration()));
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModel.java b/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModel.java
index 71da33b2..ffe534c1 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModel.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModel.java
@@ -24,12 +24,10 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.QueryParameter;
-import org.springframework.hateoas.mediatype.PropertyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
@@ -42,14 +40,16 @@ import org.springframework.lang.Nullable;
*/
class UberAffordanceModel extends AffordanceModel {
- private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH);
+ private static final Set ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT,
+ HttpMethod.PATCH);
private final @Getter Collection mediaTypes = Collections.singleton(MediaTypes.UBER_JSON);
-
+
private final @Getter List inputProperties;
private final @Getter List queryProperties;
- UberAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List queryMethodParameters, ResolvableType outputType) {
+ UberAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
+ List queryMethodParameters, PayloadMetadata outputType) {
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
this.inputProperties = determineAffordanceInputs();
@@ -58,16 +58,16 @@ class UberAffordanceModel extends AffordanceModel {
private List determineAffordanceInputs() {
- if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
-
- return PropertyUtils.findPropertyNames(getInputType()).stream()
- .map(propertyName -> new UberData()
- .withName(propertyName)
- .withValue(""))
- .collect(Collectors.toList());
- } else {
+ if (!ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
return Collections.emptyList();
}
+
+ return getInput().stream()//
+ .map(PropertyMetadata::getName) //
+ .map(propertyName -> new UberData() //
+ .withName(propertyName) //
+ .withValue("")) //
+ .collect(Collectors.toList());
}
/**
@@ -81,10 +81,8 @@ class UberAffordanceModel extends AffordanceModel {
if (getHttpMethod().equals(HttpMethod.GET)) {
return getQueryMethodParameters().stream()
- .map(queryParameter -> new UberData()
- .withName(queryParameter.getName())
- .withValue(""))
- .collect(Collectors.toList());
+ .map(queryParameter -> new UberData().withName(queryParameter.getName()).withValue(""))
+ .collect(Collectors.toList());
} else {
return Collections.emptyList();
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModelFactory.java b/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModelFactory.java
index 5ba3ebe0..db72a05b 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModelFactory.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/uber/UberAffordanceModelFactory.java
@@ -19,9 +19,10 @@ import lombok.Getter;
import java.util.List;
-import org.springframework.core.ResolvableType;
import org.springframework.hateoas.AffordanceModel;
-import org.springframework.hateoas.AffordanceModelFactory;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.mediatype.AffordanceModelFactory;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.QueryParameter;
@@ -40,11 +41,11 @@ class UberAffordanceModelFactory implements AffordanceModelFactory {
/*
* (non-Javadoc)
- * @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.core.ResolvableType, java.util.List, org.springframework.core.ResolvableType)
+ * @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(java.lang.String, org.springframework.hateoas.Link, org.springframework.http.HttpMethod, org.springframework.hateoas.mediatype.PayloadMetadata, java.util.List, org.springframework.core.ResolvableType)
*/
@Override
- public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
- List queryMethodParameters, ResolvableType outputType) {
+ public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
+ InputPayloadMetadata inputType, List queryMethodParameters, PayloadMetadata outputType) {
return new UberAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
}
}
diff --git a/src/main/java/org/springframework/hateoas/mediatype/uber/UberData.java b/src/main/java/org/springframework/hateoas/mediatype/uber/UberData.java
index 8db40c6e..64745cee 100644
--- a/src/main/java/org/springframework/hateoas/mediatype/uber/UberData.java
+++ b/src/main/java/org/springframework/hateoas/mediatype/uber/UberData.java
@@ -392,7 +392,7 @@ class UberData {
return Collections.singletonList(new UberData().withValue(obj));
}
- return PropertyUtils.findProperties(obj).entrySet().stream()
+ return PropertyUtils.extractPropertyValues(obj).entrySet().stream()
.map(entry -> new UberData().withName(entry.getKey()).withValue(entry.getValue())).collect(Collectors.toList());
}
diff --git a/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java b/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java
index b8ce28c4..e8f82e1b 100644
--- a/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java
+++ b/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java
@@ -17,15 +17,15 @@ package org.springframework.hateoas.server.core;
import java.lang.reflect.Method;
import java.util.List;
-import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.core.ResolvableType;
import org.springframework.hateoas.Affordance;
-import org.springframework.hateoas.AffordanceModelFactory;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.QueryParameter;
+import org.springframework.hateoas.mediatype.AffordanceModelFactory;
+import org.springframework.hateoas.mediatype.Affordances;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
@@ -60,15 +60,20 @@ public class SpringAffordanceBuilder {
.orElse(ResolvableType.NONE);
List queryMethodParameters = parameters.getParametersWith(RequestParam.class).stream() //
- .map(it -> it.getParameterAnnotation(RequestParam.class)) //
- .filter(Objects::nonNull) //
- .map(it -> new QueryParameter(it.name(), it.value(), it.required())) //
+ .map(QueryParameter::of) //
.collect(Collectors.toList());
ResolvableType outputType = ResolvableType.forMethodReturnType(method);
+ Affordances affordances = Affordances.of(affordanceLink);
return discoverer.getRequestMethod(type, method).stream() //
- .map(it -> new Affordance(methodName, affordanceLink, it, inputType, queryMethodParameters, outputType)) //
+ .flatMap(it -> affordances.afford(it) //
+ .withInput(inputType) //
+ .withOutput(outputType) //
+ .withParameters(queryMethodParameters) //
+ .withName(methodName) //
+ .build() //
+ .stream()) //
.collect(Collectors.toList());
}
}
diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories
index 3cdeef4d..66583057 100644
--- a/src/main/resources/META-INF/spring.factories
+++ b/src/main/resources/META-INF/spring.factories
@@ -1,4 +1,4 @@
-org.springframework.hateoas.AffordanceModelFactory=\
+org.springframework.hateoas.mediatype.AffordanceModelFactory=\
org.springframework.hateoas.mediatype.collectionjson.CollectionJsonAffordanceModelFactory,\
org.springframework.hateoas.mediatype.hal.forms.HalFormsAffordanceModelFactory,\
org.springframework.hateoas.mediatype.uber.UberAffordanceModelFactory
diff --git a/src/test/java/org/springframework/hateoas/LinkUnitTest.java b/src/test/java/org/springframework/hateoas/LinkUnitTest.java
index d4def065..b0010ef1 100755
--- a/src/test/java/org/springframework/hateoas/LinkUnitTest.java
+++ b/src/test/java/org/springframework/hateoas/LinkUnitTest.java
@@ -21,12 +21,10 @@ import static org.assertj.core.api.SoftAssertions.*;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.net.URI;
-import java.util.Collections;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.junit.jupiter.api.Test;
-import org.springframework.core.ResolvableType;
-import org.springframework.hateoas.support.Employee;
+import org.springframework.hateoas.mediatype.Affordances;
import org.springframework.http.HttpMethod;
/**
@@ -38,9 +36,6 @@ import org.springframework.http.HttpMethod;
*/
class LinkUnitTest {
- private static final Affordance TEST_AFFORDANCE = new Affordance(null, null, HttpMethod.GET, null,
- Collections.emptyList(), null);
-
@Test
void linkWithHrefOnlyBecomesSelfLink() {
assertThat(new Link("foo").hasRel(IanaLinkRelations.SELF)).isTrue();
@@ -278,8 +273,9 @@ class LinkUnitTest {
void linkWithAffordancesShouldWorkProperly() {
Link originalLink = new Link("/foo");
- Link linkWithAffordance = originalLink.andAffordance(TEST_AFFORDANCE);
- Link linkWithTwoAffordances = linkWithAffordance.andAffordance(TEST_AFFORDANCE);
+
+ Link linkWithAffordance = Affordances.of(originalLink).afford(HttpMethod.GET).toLink();
+ Link linkWithTwoAffordances = Affordances.of(linkWithAffordance).afford(HttpMethod.GET).toLink();
assertSoftly(softly -> {
@@ -319,136 +315,6 @@ class LinkUnitTest {
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> link.hasRel(""));
}
- @Test
- void affordanceConvenienceMethodChainsExistingLink() {
-
- Link link = new Link("/").andAffordance("name", HttpMethod.POST, ResolvableType.forClass(Employee.class),
- Collections.emptyList(), ResolvableType.forClass(Employee.class));
-
- assertThat(link.getHref()).isEqualTo("/");
- assertThat(link.hasRel(IanaLinkRelations.SELF)).isTrue();
- assertThat(link.getAffordances()).hasSize(1);
- assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(3);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName()).isEqualTo("name");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName()).isEqualTo("name");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName()).isEqualTo("name");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
- }
-
- @Test
- void affordanceConvenienceMethodDefaultsNameBasedOnHttpVerb() {
-
- Link link = new Link("/").andAffordance(HttpMethod.POST, ResolvableType.forClass(Employee.class),
- Collections.emptyList(), ResolvableType.forClass(Employee.class));
-
- assertThat(link.getHref()).isEqualTo("/");
- assertThat(link.hasRel(IanaLinkRelations.SELF)).isTrue();
- assertThat(link.getAffordances()).hasSize(1);
- assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(3);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
- }
-
- @Test
- void affordanceConvenienceMethodHandlesBareClasses() {
-
- Link link = new Link("/").andAffordance(HttpMethod.POST, Employee.class, Collections.emptyList(), Employee.class);
-
- assertThat(link.getHref()).isEqualTo("/");
- assertThat(link.hasRel(IanaLinkRelations.SELF)).isTrue();
- assertThat(link.getAffordances()).hasSize(1);
- assertThat(link.getAffordances().get(0).getAffordanceModels()).hasSize(3);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.COLLECTION_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.HAL_FORMS_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
-
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getName())
- .isEqualTo("postEmployee");
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getHttpMethod())
- .isEqualTo(HttpMethod.POST);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getInputType().resolve())
- .isEqualTo(Employee.class);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getQueryMethodParameters())
- .hasSize(0);
- assertThat(link.getAffordances().get(0).getAffordanceModel(MediaTypes.UBER_JSON).getOutputType().resolve())
- .isEqualTo(Employee.class);
- }
-
@Test
void createsUriForSimpleLink() {
assertThat(new Link("/something").toUri()).isEqualTo(URI.create("/something"));
diff --git a/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java
new file mode 100644
index 00000000..5350b44b
--- /dev/null
+++ b/src/test/java/org/springframework/hateoas/mediatype/AffordancesUnitTests.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import org.assertj.core.api.AbstractAssert;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.ResolvableType;
+import org.springframework.hateoas.Affordance;
+import org.springframework.hateoas.AffordanceModel;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.MediaTypes;
+import org.springframework.hateoas.support.Employee;
+import org.springframework.http.HttpMethod;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class AffordancesUnitTests {
+
+ @Test
+ void affordanceConvenienceMethodChainsExistingLink() {
+
+ Link link = Affordances.of(new Link("/")) //
+ .afford(HttpMethod.POST) //
+ .withInputAndOutput(Employee.class) //
+ .withName("name") //
+ .toLink();
+
+ assertThat(link.getHref()).isEqualTo("/");
+ assertThat(link.hasRel(IanaLinkRelations.SELF)).isTrue();
+ assertThat(link.getAffordances()).hasSize(1);
+
+ Affordance affordance = link.getAffordances().get(0);
+
+ assertThat(affordance).hasSize(3);
+
+ assertAffordanceModel(affordance, commonAssertions().andThen(it -> {
+ assertThat(it.getName()).isEqualTo("name");
+ }));
+ }
+
+ @Test
+ void affordanceConvenienceMethodDefaultsNameBasedOnHttpVerb() {
+
+ Link link = Affordances.of(new Link("/")) //
+ .afford(HttpMethod.POST) //
+ .withInputAndOutput(Employee.class) //
+ .toLink();
+
+ assertThat(link.getHref()).isEqualTo("/");
+ assertThat(link.hasRel(IanaLinkRelations.SELF)).isTrue();
+ assertThat(link.getAffordances()).hasSize(1);
+
+ Affordance affordance = link.getAffordances().get(0);
+
+ assertThat(affordance).hasSize(3);
+
+ assertAffordanceModel(affordance, commonAssertions().andThen(it -> {
+ assertThat(it.getName()).isEqualTo("postEmployee");
+ }));
+ }
+
+ private static Consumer commonAssertions() {
+
+ return it -> {
+
+ assertThat(it.getHttpMethod()).isEqualTo(HttpMethod.POST);
+ assertThat(it.getQueryMethodParameters()).hasSize(0);
+
+ assertThatPayload(it.getInput()).isBackedBy(Employee.class);
+ assertThatPayload(it.getOutput()).isBackedBy(Employee.class);
+ };
+ }
+
+ private static void assertAffordanceModel(Affordance affordance, Consumer assertions) {
+
+ Stream.of(MediaTypes.COLLECTION_JSON, MediaTypes.HAL_FORMS_JSON, MediaTypes.UBER_JSON) //
+ .map(affordance::getAffordanceModel) //
+ .map(AffordanceModel.class::cast) //
+ .forEach(assertions);
+ }
+
+ private static PayloadMetadataAssert assertThatPayload(PayloadMetadata metadata) {
+ return new PayloadMetadataAssert(metadata);
+ }
+
+ private static class PayloadMetadataAssert extends AbstractAssert {
+
+ public PayloadMetadataAssert(PayloadMetadata actual) {
+ super(actual, PayloadMetadataAssert.class);
+ }
+
+ public PayloadMetadataAssert isBackedBy(Class> type) {
+
+ Assertions.assertThat(actual).isInstanceOfSatisfying(TypeBasedPayloadMetadata.class, it -> {
+ Assertions.assertThat(it.getType()).isEqualTo(ResolvableType.forClass(type));
+ });
+
+ return this;
+ }
+ }
+}
diff --git a/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java b/src/test/java/org/springframework/hateoas/mediatype/PropertyUtilsTest.java
similarity index 66%
rename from src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java
rename to src/test/java/org/springframework/hateoas/mediatype/PropertyUtilsTest.java
index 943a6ce2..b12c3e93 100644
--- a/src/test/java/org/springframework/hateoas/support/PropertyUtilsTest.java
+++ b/src/test/java/org/springframework/hateoas/mediatype/PropertyUtilsTest.java
@@ -13,23 +13,31 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.springframework.hateoas.support;
+package org.springframework.hateoas.mediatype;
import static org.assertj.core.api.Assertions.*;
import lombok.AllArgsConstructor;
import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.Value;
import java.lang.reflect.Method;
import java.util.AbstractMap.SimpleEntry;
-import java.util.List;
import java.util.Map;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
+import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
+import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
import org.springframework.hateoas.EntityModel;
-import org.springframework.hateoas.mediatype.PropertyUtils;
import org.springframework.hateoas.server.core.MethodParameters;
+import org.springframework.hateoas.support.Employee;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -48,7 +56,7 @@ class PropertyUtilsTest {
Employee employee = new Employee("Frodo Baggins", "ring bearer");
- Map properties = PropertyUtils.findProperties(employee);
+ Map properties = PropertyUtils.extractPropertyValues(employee);
assertThat(properties).hasSize(2);
assertThat(properties.keySet()).contains("name", "role");
@@ -62,7 +70,7 @@ class PropertyUtilsTest {
Employee employee = new Employee("Frodo Baggins", "ring bearer");
EntityModel employeeResource = new EntityModel<>(employee);
- Map properties = PropertyUtils.findProperties(employeeResource);
+ Map properties = PropertyUtils.extractPropertyValues(employeeResource);
assertThat(properties).hasSize(2);
assertThat(properties.keySet()).contains("name", "role");
@@ -76,15 +84,14 @@ class PropertyUtilsTest {
Method method = ReflectionUtils.findMethod(TestController.class, "newEmployee", EntityModel.class);
MethodParameters parameters = MethodParameters.of(method);
- ResolvableType resolvableType = parameters.getParametersWith(RequestBody.class).stream().findFirst()
- .map(methodParameter -> ResolvableType.forMethodParameter(methodParameter.getMethod(),
- methodParameter.getParameterIndex()))
+ ResolvableType resolvableType = parameters.getParametersWith(RequestBody.class).stream() //
+ .findFirst().map(it -> ResolvableType.forMethodParameter(it.getMethod(), it.getParameterIndex()))
.orElseThrow(() -> new RuntimeException("Didn't find a parameter annotated with @RequestBody!"));
- List propertyNames = PropertyUtils.findPropertyNames(resolvableType);
+ InputPayloadMetadata metadata = PropertyUtils.getExposedProperties(resolvableType);
- assertThat(propertyNames).hasSize(2);
- assertThat(propertyNames).contains("name", "role");
+ assertThat(metadata.stream()).hasSize(2);
+ assertThat(metadata.stream().map(PropertyMetadata::getName)).contains("name", "role");
}
@Test
@@ -93,7 +100,7 @@ class PropertyUtilsTest {
EmployeeWithCustomizedReaders employee = new EmployeeWithCustomizedReaders("Frodo", "Baggins", "ring bearer",
"password", "fbaggins", "ignore this one");
- Map properties = PropertyUtils.findProperties(employee);
+ Map properties = PropertyUtils.extractPropertyValues(employee);
assertThat(properties).hasSize(6);
assertThat(properties.keySet()).containsExactlyInAnyOrder("firstName", "lastName", "role", "username", "fullName",
@@ -109,7 +116,7 @@ class PropertyUtilsTest {
EmployeeWithNullReturningGetter employee = new EmployeeWithNullReturningGetter("Frodo");
- Map properties = PropertyUtils.findProperties(employee);
+ Map properties = PropertyUtils.extractPropertyValues(employee);
assertThat(properties).hasSize(2);
assertThat(properties.keySet()).containsExactlyInAnyOrder("name", "father");
@@ -117,6 +124,38 @@ class PropertyUtilsTest {
new SimpleEntry<>("father", null));
}
+ @Test
+ void considersAccessorAvailablility() {
+
+ PayloadMetadata metadata = PropertyUtils.getExposedProperties(MethodExposurePayload.class);
+
+ assertThat(metadata.getPropertyMetadata("readWrite")) //
+ .map(PropertyMetadata::isReadOnly) //
+ .hasValue(false);
+
+ assertThat(metadata.getPropertyMetadata("readOnly")) //
+ .map(PropertyMetadata::isReadOnly) //
+ .hasValue(true);
+ }
+
+ @Test
+ void considersJsr303Annotations() {
+
+ InputPayloadMetadata metadata = PropertyUtils.getExposedProperties(Jsr303SamplePayload.class);
+
+ assertThat(metadata.getPropertyMetadata("nonNull")).hasValueSatisfying(it -> {
+ assertThat(it.isRequired()).isTrue();
+ });
+
+ assertThat(metadata.getPropertyMetadata("pattern")).hasValueSatisfying(it -> {
+ assertThat(it.getPattern()).hasValue("\\w");
+ });
+
+ assertThat(metadata.getPropertyMetadata("annotated")).hasValueSatisfying(it -> {
+ assertThat(it.getPattern()).hasValue("regex");
+ });
+ }
+
@Data
@AllArgsConstructor
@JsonIgnoreProperties({ "ignoreThisProperty" })
@@ -155,6 +194,23 @@ class PropertyUtilsTest {
}
}
+ @Value
+ static class Jsr303SamplePayload {
+
+ @NotNull String nonNull;
+ @Pattern(regexp = "\\w") String pattern;
+ TypeAnnotated annotated;
+ }
+
+ @Pattern(regexp = "regex")
+ static class TypeAnnotated {}
+
+ static class MethodExposurePayload {
+
+ @Getter @Setter String readWrite;
+ @Getter String readOnly;
+ }
+
@RestController
static class TestController {
@@ -163,5 +219,4 @@ class PropertyUtilsTest {
return employee.getContent();
}
}
-
}
diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java
new file mode 100644
index 00000000..9d104e6f
--- /dev/null
+++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsTemplateBuilderUnitTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.hateoas.mediatype.hal.forms;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import lombok.Getter;
+
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.context.support.MessageSourceAccessor;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.RepresentationModel;
+import org.springframework.hateoas.mediatype.Affordances;
+import org.springframework.http.HttpMethod;
+
+/**
+ * @author Oliver Drotbohm
+ */
+public class HalFormsTemplateBuilderUnitTest {
+
+ @ParameterizedTest(name = "Detects regex pattern ''{1}'' for property ''{0}''")
+ @CsvSource({ "number, [0-9]{16}", "overridden, foo", "annotated, bar" })
+ void detectsRegularExpressionsOnProperties(String propertyName, String expected) {
+
+ HalFormsConfiguration configuration = new HalFormsConfiguration();
+ configuration.registerPattern(CreditCardNumber.class, "[0-9]{16}");
+
+ HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(configuration, mock(MessageSourceAccessor.class));
+
+ PatternExample resource = new PatternExample();
+ resource.add(Affordances.of(new Link("/examples")) //
+ .afford(HttpMethod.POST) //
+ .withInput(PatternExample.class) //
+ .toLink());
+
+ Map templates = builder.findTemplates(resource);
+
+ HalFormsTemplate template = templates.get("default");
+
+ assertThat(template).isNotNull();
+ assertThat(template.getPropertyByName(propertyName) //
+ .map(HalFormsProperty::getRegex)) //
+ .hasValue(expected);
+ }
+
+ @Test
+ void allPropertiesAreOptionalForPatchRequests() throws Exception {
+
+ Affordances.of(new Link("/example")) //
+ .afford(HttpMethod.PATCH) //
+ .withInput(RequiredProperty.class);
+
+ RequiredProperty model = new RequiredProperty();
+ model.add(Affordances.of(new Link("/example")) //
+ .afford(HttpMethod.PATCH) //
+ .withInput(RequiredProperty.class) //
+ .andAfford(HttpMethod.POST) //
+ .withInput(RequiredProperty.class) //
+ .withName("post") //
+ .toLink());
+
+ HalFormsTemplateBuilder builder = new HalFormsTemplateBuilder(new HalFormsConfiguration(),
+ mock(MessageSourceAccessor.class));
+
+ Map templates = builder.findTemplates(model);
+
+ HalFormsTemplate template = templates.get("default");
+
+ assertThat(template).isNotNull();
+ assertThat(template.getPropertyByName("name").map(HalFormsProperty::isRequired)).hasValue(false);
+
+ template = templates.get("post");
+
+ assertThat(template).isNotNull();
+ assertThat(template.getPropertyByName("name").map(HalFormsProperty::isRequired)).hasValue(true);
+ }
+
+ @Getter
+ static class PatternExample extends RepresentationModel {
+
+ CreditCardNumber number;
+
+ @Pattern(regexp = "foo") CreditCardNumber overridden;
+
+ WithTypeLevelAnnotation annotated;
+ }
+
+ static class CreditCardNumber {}
+
+ @Pattern(regexp = "bar")
+ static class WithTypeLevelAnnotation {}
+
+ @Getter
+ static class RequiredProperty extends RepresentationModel {
+ @NotNull String name;
+ }
+}
diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java
index e1acba44..ed6de2d6 100644
--- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java
+++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java
@@ -61,31 +61,31 @@ class HalFormsWebFluxIntegrationTest {
@Test
void singleEmployee() {
- this.testClient.get().uri("http://localhost/employees/0")
- .accept(MediaTypes.HAL_FORMS_JSON)
- .exchange()
- .expectStatus().isOk()
- .expectHeader().contentType(MediaTypes.HAL_FORMS_JSON)
- .expectBody(String.class)
- .value(jsonPath("$.name", is("Frodo Baggins")))
- .value(jsonPath("$.role", is("ring bearer")))
+ this.testClient.get().uri("http://localhost/employees/0").accept(MediaTypes.HAL_FORMS_JSON).exchange()
- .value(jsonPath("$._links.*", hasSize(2)))
- .value(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
- .value(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
+ .expectStatus().isOk() //
+ .expectHeader().contentType(MediaTypes.HAL_FORMS_JSON) //
+ .expectBody(String.class)//
- .value(jsonPath("$._templates.*", hasSize(2)))
- .value(jsonPath("$._templates['default'].method", is("put")))
- .value(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .value(jsonPath("$._templates['default'].properties[0].required", is(true)))
- .value(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .value(jsonPath("$._templates['default'].properties[1].required", is(true)))
+ .value(jsonPath("$.name", is("Frodo Baggins"))) //
+ .value(jsonPath("$.role", is("ring bearer"))) //
- .value(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
- .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
- .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
- .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
- .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
+ .value(jsonPath("$._links.*", hasSize(2))) //
+ .value(jsonPath("$._links['self'].href", is("http://localhost/employees/0"))) //
+ .value(jsonPath("$._links['employees'].href", is("http://localhost/employees"))) //
+
+ .value(jsonPath("$._templates.*", hasSize(2))) //
+ .value(jsonPath("$._templates['default'].method", is("put"))) //
+ .value(jsonPath("$._templates['default'].properties[0].name", is("name"))) //
+ .value(jsonPath("$._templates['default'].properties[0].required", is(true))) //
+ .value(jsonPath("$._templates['default'].properties[1].name", is("role"))) //
+ .value(jsonPath("$._templates['default'].properties[1].required").doesNotExist()) //
+
+ .value(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch"))) //
+ .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name"))) //
+ .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required").doesNotExist()) //
+ .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role"))) //
+ .value(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required").doesNotExist());
}
/**
@@ -94,12 +94,8 @@ class HalFormsWebFluxIntegrationTest {
@Test
void collectionOfEmployees() {
- this.testClient.get().uri("http://localhost/employees")
- .accept(MediaTypes.HAL_FORMS_JSON)
- .exchange()
- .expectStatus().isOk()
- .expectHeader().contentType(MediaTypes.HAL_FORMS_JSON)
- .expectBody(String.class)
+ this.testClient.get().uri("http://localhost/employees").accept(MediaTypes.HAL_FORMS_JSON).exchange().expectStatus()
+ .isOk().expectHeader().contentType(MediaTypes.HAL_FORMS_JSON).expectBody(String.class)
.value(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins")))
.value(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
.value(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0")))
@@ -110,12 +106,11 @@ class HalFormsWebFluxIntegrationTest {
.value(jsonPath("$._links.*", hasSize(1)))
.value(jsonPath("$._links['self'].href", is("http://localhost/employees")))
- .value(jsonPath("$._templates.*", hasSize(1)))
- .value(jsonPath("$._templates['default'].method", is("post")))
+ .value(jsonPath("$._templates.*", hasSize(1))).value(jsonPath("$._templates['default'].method", is("post")))
.value(jsonPath("$._templates['default'].properties[0].name", is("name")))
.value(jsonPath("$._templates['default'].properties[0].required", is(true)))
.value(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .value(jsonPath("$._templates['default'].properties[1].required", is(true)));
+ .value(jsonPath("$._templates['default'].properties[1].required").doesNotExist());
}
/**
@@ -126,12 +121,9 @@ class HalFormsWebFluxIntegrationTest {
String specBasedJson = MappingUtils.read(new ClassPathResource("new-employee.json", getClass()));
- this.testClient.post().uri("http://localhost/employees")
- .contentType(MediaTypes.HAL_FORMS_JSON)
- .syncBody(specBasedJson)
- .exchange()
- .expectStatus().isCreated()
- .expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2");
+ this.testClient.post().uri("http://localhost/employees").contentType(MediaTypes.HAL_FORMS_JSON)
+ .syncBody(specBasedJson).exchange().expectStatus().isCreated().expectHeader()
+ .valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2");
}
@Configuration
@@ -147,10 +139,8 @@ class HalFormsWebFluxIntegrationTest {
@Bean
WebTestClient webTestClient(WebClientConfigurer webClientConfigurer, ApplicationContext ctx) {
- return WebTestClient.bindToApplicationContext(ctx).build()
- .mutate()
- .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies())
- .build();
+ return WebTestClient.bindToApplicationContext(ctx).build().mutate()
+ .exchangeStrategies(webClientConfigurer.hypermediaExchangeStrategies()).build();
}
}
}
diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebMvcIntegrationTest.java
index d0769050..3f6b89ad 100644
--- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebMvcIntegrationTest.java
+++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebMvcIntegrationTest.java
@@ -74,8 +74,10 @@ class HalFormsWebMvcIntegrationTest {
void singleEmployee() throws Exception {
this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) //
+
.andExpect(status().isOk()) //
- .andExpect(jsonPath("$.name", is("Frodo Baggins"))).andExpect(jsonPath("$.role", is("ring bearer")))
+ .andExpect(jsonPath("$.name", is("Frodo Baggins"))) //
+ .andExpect(jsonPath("$.role", is("ring bearer")))
.andExpect(jsonPath("$._links.*", hasSize(2)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
@@ -84,15 +86,15 @@ class HalFormsWebMvcIntegrationTest {
.andExpect(jsonPath("$._templates.*", hasSize(2)))
.andExpect(jsonPath("$._templates['default'].method", is("put")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
+ .andExpect(jsonPath("$._templates['default'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)))
+ .andExpect(jsonPath("$._templates['default'].properties[1].required").doesNotExist())
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
+ .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required").doesNotExist())
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
+ .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required").doesNotExist());
}
@Test
@@ -113,9 +115,9 @@ class HalFormsWebMvcIntegrationTest {
.andExpect(jsonPath("$._templates.*", hasSize(1)))
.andExpect(jsonPath("$._templates['default'].method", is("post")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
+ .andExpect(jsonPath("$._templates['default'].properties[0].required").value(true))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)));
+ .andExpect(jsonPath("$._templates['default'].properties[1].required").doesNotExist());
}
@Test
diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java
index 2283b783..42094558 100644
--- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java
+++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsIntegrationTest.java
@@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import lombok.Getter;
+import net.minidev.json.JSONArray;
import java.io.IOException;
import java.util.ArrayList;
@@ -28,6 +29,9 @@ import java.util.Collections;
import java.util.List;
import java.util.Locale;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -36,9 +40,17 @@ import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.context.support.StaticMessageSource;
-import org.springframework.core.ResolvableType;
import org.springframework.core.io.ClassPathResource;
-import org.springframework.hateoas.*;
+import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest;
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.Links;
+import org.springframework.hateoas.PagedModel;
+import org.springframework.hateoas.RepresentationModel;
+import org.springframework.hateoas.UriTemplate;
+import org.springframework.hateoas.mediatype.Affordances;
import org.springframework.hateoas.mediatype.hal.CurieProvider;
import org.springframework.hateoas.mediatype.hal.DefaultCurieProvider;
import org.springframework.hateoas.mediatype.hal.Jackson2HalIntegrationTest;
@@ -57,6 +69,7 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.jayway.jsonpath.JsonPath;
+import com.jayway.jsonpath.PathNotFoundException;
/**
* @author Greg Turnquist
@@ -74,6 +87,8 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
@BeforeEach
void setUpModule() {
+ // TestAffordances.enableMediaTypes(MediaTypes.HAL_FORMS_JSON);
+
LinkRelationProvider provider = new DelegatingLinkRelationProvider(new AnnotationLinkRelationProvider(),
Jackson2HalIntegrationTest.DefaultLinkRelationProvider.INSTANCE);
@@ -130,11 +145,14 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
void rendersRepresentationModelWithTemplates() throws Exception {
EmployeeResource resource = new EmployeeResource("Frodo Baggins");
- Link selfLink = new Link("/employees/1");
- selfLink = selfLink.andAffordance(new Affordance("foo", selfLink, HttpMethod.POST,
- ResolvableType.forClass(EmployeeResource.class), Collections.emptyList(),
- ResolvableType.forClass(EmployeeResource.class)));
- resource.add(selfLink);
+
+ Link link = Affordances.of(new Link("/employees/1")) //
+ .afford(HttpMethod.POST) //
+ .withInputAndOutput(EmployeeResource.class) //
+ .withName("foo") //
+ .toLink();
+
+ resource.add(link);
assertThat(write(resource))
.isEqualTo(MappingUtils.read(new ClassPathResource("employee-resource-support.json", getClass())));
@@ -417,8 +435,12 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
StaticMessageSource source = new StaticMessageSource();
source.addMessage(key, Locale.US, "Vorname");
- Link link = new Link("some:link") //
- .andAffordance(HttpMethod.POST, HalFormsPayload.class, Collections.emptyList(), Object.class);
+ Link link = Affordances.of(new Link("some:link")) //
+ .afford(HttpMethod.POST) //
+ .withInput(HalFormsPayload.class) //
+ .withOutput(Object.class) //
+ .withName("sample") //
+ .toLink();
EntityModel model = new EntityModel<>(new HalFormsPayload(), link);
ObjectMapper mapper = getCuriedObjectMapper(CurieProvider.NONE, source);
@@ -446,8 +468,10 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
StaticMessageSource source = new StaticMessageSource();
source.addMessage(key, Locale.US, "Template title");
- Link link = new Link("some:link") //
- .andAffordance(HttpMethod.POST, HalFormsPayload.class, Collections.emptyList(), Object.class);
+ Link link = Affordances.of(new Link("some:link")) //
+ .afford(HttpMethod.POST) //
+ .withInput(HalFormsPayload.class) //
+ .toLink();
EntityModel model = new EntityModel<>(new HalFormsPayload(), link);
ObjectMapper mapper = getCuriedObjectMapper(CurieProvider.NONE, source);
@@ -462,6 +486,62 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
}).doesNotThrowAnyException();
}
+ @Test
+ void doesNotRenderPromptPropertyIfEmpty() throws Exception {
+
+ HalFormsProperty property = HalFormsProperty.named("someName");
+
+ assertThatPathDoesNotExist(property.withPrompt(""), "$.prompt");
+ assertValueForPath(property.withPrompt("Some prompt"), "$.prompt", "Some prompt");
+ }
+
+ @Test
+ void doesNotRenderRequiredPropertyIfFalse() throws Exception {
+
+ HalFormsProperty property = HalFormsProperty.named("someName");
+
+ assertThatPathDoesNotExist(property, "$.required");
+ assertValueForPath(property.withRequired(true), ".required", true);
+ }
+
+ @Test
+ void considersJsr303AnnotationsForTemplates() throws Exception {
+
+ Link link = Affordances.of(new Link("localhost:8080")) //
+ .afford(HttpMethod.POST) //
+ .withInput(Jsr303Sample.class) //
+ .toLink();
+
+ EntityModel model = new EntityModel<>(new Jsr303Sample(), link);
+
+ assertValueForPath(model, "$._templates.default.properties[0].readOnly", true);
+ assertValueForPath(model, "$._templates.default.properties[0].regex", "[\\w\\s]");
+ assertValueForPath(model, "$._templates.default.properties[0].required", true);
+ }
+
+ private void assertThatPathDoesNotExist(Object toMarshall, String path) throws Exception {
+
+ ObjectMapper mapper = getCuriedObjectMapper();
+ String json = mapper.writeValueAsString(toMarshall);
+
+ assertThatExceptionOfType(PathNotFoundException.class) //
+ .isThrownBy(() -> JsonPath.compile(path).read(json));
+ }
+
+ private void assertValueForPath(Object toMarshall, String path, Object expected) throws Exception {
+
+ ObjectMapper mapper = getCuriedObjectMapper();
+ String json = mapper.writeValueAsString(toMarshall);
+
+ Object actual = JsonPath.compile(path).read(json);
+
+ Object value = JSONArray.class.isInstance(actual) //
+ ? JSONArray.class.cast(actual).get(0) //
+ : actual;
+
+ assertThat(value).isEqualTo(expected);
+ }
+
private void verifyResolvedTitle(String resourceBundleKey) throws Exception {
LocaleContextHolder.setLocale(Locale.US);
@@ -527,6 +607,19 @@ class Jackson2HalFormsIntegrationTest extends AbstractJackson2MarshallingIntegra
public static class HalFormsPayload {
private @Getter String firstname;
+ }
+ public static class Jsr303Sample {
+
+ private String firstname;
+
+ /**
+ * @return the firstname
+ */
+ @NotNull
+ @Pattern(regexp = "[\\w\\s]")
+ public String getFirstname() {
+ return firstname;
+ }
}
}
diff --git a/src/test/java/org/springframework/hateoas/mediatype/uber/Jackson2UberIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/uber/Jackson2UberIntegrationTest.java
index 56451cc3..86f89255 100644
--- a/src/test/java/org/springframework/hateoas/mediatype/uber/Jackson2UberIntegrationTest.java
+++ b/src/test/java/org/springframework/hateoas/mediatype/uber/Jackson2UberIntegrationTest.java
@@ -39,6 +39,7 @@ import org.springframework.hateoas.Links;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.support.MappingUtils;
+import org.springframework.lang.Nullable;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -147,8 +148,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
expected.add(new Link("localhost"));
String resourcesJson = MappingUtils.read(new ClassPathResource("resources.json", getClass()));
- JavaType resourcesType = mapper.getTypeFactory().constructParametricType(CollectionModel.class,
- String.class);
+ JavaType resourcesType = mapper.getTypeFactory().constructParametricType(CollectionModel.class, String.class);
CollectionModel result = mapper.readValue(resourcesJson, resourcesType);
assertThat(result).isEqualTo(expected);
@@ -164,8 +164,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
content.add(new EntityModel<>("first"));
content.add(new EntityModel<>("second"));
- CollectionModel> expected = new CollectionModel<>(
- content);
+ CollectionModel> expected = new CollectionModel<>(content);
expected.add(new Link("localhost"));
String resourcesJson = MappingUtils.read(new ClassPathResource("resources.json", getClass()));
@@ -173,8 +172,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
JavaType resourcesType = mapper.getTypeFactory().constructParametricType(CollectionModel.class,
mapper.getTypeFactory().constructParametricType(EntityModel.class, String.class));
- CollectionModel> result = mapper.readValue(resourcesJson,
- resourcesType);
+ CollectionModel> result = mapper.readValue(resourcesJson, resourcesType);
assertThat(result).isEqualTo(expected);
}
@@ -196,8 +194,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
@Test
void renderResourceWithCustomRel() throws Exception {
- EntityModel data2 = new EntityModel<>("second",
- new Link("localhost").withRel("custom"));
+ EntityModel data2 = new EntityModel<>("second", new Link("localhost").withRel("custom"));
assertThat(write(data2)).isEqualTo(MappingUtils.read(new ClassPathResource("resource2.json", getClass())));
}
@@ -208,8 +205,8 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
@Test
void renderResourceWithMultipleLinks() throws Exception {
- EntityModel data3 = new EntityModel<>("third", new Link("localhost"),
- new Link("second").withRel("second"), new Link("third").withRel("third"));
+ EntityModel data3 = new EntityModel<>("third", new Link("localhost"), new Link("second").withRel("second"),
+ new Link("third").withRel("third"));
assertThat(write(data3)).isEqualTo(MappingUtils.read(new ClassPathResource("resource3.json", getClass())));
}
@@ -233,17 +230,15 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
@Test
void deserializeResource() throws IOException {
- JavaType resourceStringType = mapper.getTypeFactory().constructParametricType(EntityModel.class,
- String.class);
+ JavaType resourceStringType = mapper.getTypeFactory().constructParametricType(EntityModel.class, String.class);
EntityModel> expected = new EntityModel<>("first", new Link("localhost"));
- EntityModel actual = mapper
- .readValue(MappingUtils.read(new ClassPathResource("resource.json", getClass())), resourceStringType);
+ EntityModel actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resource.json", getClass())),
+ resourceStringType);
assertThat(actual).isEqualTo(expected);
- EntityModel expected2 = new EntityModel<>("second",
- new Link("localhost").withRel("custom"));
+ EntityModel expected2 = new EntityModel<>("second", new Link("localhost").withRel("custom"));
EntityModel actual2 = mapper
.readValue(MappingUtils.read(new ClassPathResource("resource2.json", getClass())), resourceStringType);
@@ -275,8 +270,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
data.add(new EntityModel<>("first", new Link("localhost"), new Link("orders").withRel("orders")));
data.add(new EntityModel<>("second", new Link("remotehost"), new Link("order").withRel("orders")));
- CollectionModel> resources = new CollectionModel<>(
- data);
+ CollectionModel> resources = new CollectionModel<>(data);
resources.add(new Link("localhost"));
resources.add(new Link("/page/2").withRel("next"));
@@ -398,8 +392,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
void serializeWrappedSimplePojo() throws Exception {
Employee employee = new Employee("Frodo", "ring bearer");
- EntityModel expected = new EntityModel<>(employee,
- new Link("/employees/1").withSelfRel());
+ EntityModel expected = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
String actual = MappingUtils.read(new ClassPathResource("resource-with-simple-pojo.json", getClass()));
@@ -413,8 +406,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
void deserializeWrappedSimplePojo() throws IOException {
Employee employee = new Employee("Frodo", "ring bearer");
- EntityModel expected = new EntityModel<>(employee,
- new Link("/employees/1").withSelfRel());
+ EntityModel expected = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
EntityModel actual = mapper.readValue(
MappingUtils.read(new ClassPathResource("resource-with-simple-pojo.json", getClass())),
@@ -430,8 +422,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
void deserializeWrappedEmptyPojo() throws IOException {
Employee employee = new Employee();
- EntityModel expected = new EntityModel<>(employee,
- new Link("/employees/1").withSelfRel());
+ EntityModel expected = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
EntityModel actual = mapper.readValue(
MappingUtils.read(new ClassPathResource("resource-with-empty-pojo.json", getClass())),
@@ -552,17 +543,14 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
}
@NotNull
- private static CollectionModel> setupAnnotatedPagedResources(
- int size, int totalElements) {
+ private static CollectionModel> setupAnnotatedPagedResources(int size, int totalElements) {
List> content = new ArrayList<>();
Employee employee = new Employee("Frodo", "ring bearer");
- EntityModel employeeResource = new EntityModel<>(employee,
- new Link("/employees/1").withSelfRel());
+ EntityModel employeeResource = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
content.add(employeeResource);
- return new PagedModel<>(content, new PagedModel.PageMetadata(size, 0, totalElements),
- PAGINATION_LINKS);
+ return new PagedModel<>(content, new PagedModel.PageMetadata(size, 0, totalElements), PAGINATION_LINKS);
}
@Data
@@ -575,13 +563,10 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
}
@Data
- @AllArgsConstructor
+ @AllArgsConstructor()
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
static class EmployeeResource extends RepresentationModel {
-
- private String name;
- private String role;
+ private @Nullable String name, role;
}
-
}
diff --git a/src/test/java/org/springframework/hateoas/server/mvc/MultiMediaTypeWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/server/mvc/MultiMediaTypeWebMvcIntegrationTest.java
index 07a6812b..5af7a49f 100644
--- a/src/test/java/org/springframework/hateoas/server/mvc/MultiMediaTypeWebMvcIntegrationTest.java
+++ b/src/test/java/org/springframework/hateoas/server/mvc/MultiMediaTypeWebMvcIntegrationTest.java
@@ -54,6 +54,8 @@ import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.result.JsonPathResultMatchers;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -197,32 +199,23 @@ class MultiMediaTypeWebMvcIntegrationTest {
@Test
void singleEmployeeHalForms() throws Exception {
- this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) //
+ ResultActions actions = this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) //
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.name", is("Frodo Baggins"))).andExpect(jsonPath("$.role", is("ring bearer")))
.andExpect(jsonPath("$._links.*", hasSize(2)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
- .andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
+ .andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
- .andExpect(jsonPath("$._templates.*", hasSize(2)))
+ expectEmployeeProperties(actions, "default", "partiallyUpdateEmployee") //
.andExpect(jsonPath("$._templates['default'].method", is("put")))
- .andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
- .andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)))
-
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
+ .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")));
}
@Test
void collectionOfEmployeesHalForms() throws Exception {
- this.mockMvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON)) //
+ ResultActions actions = this.mockMvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON)) //
.andExpect(status().isOk()) //
.andExpect(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins")))
.andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
@@ -234,12 +227,9 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(jsonPath("$._links.*", hasSize(1)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees")))
- .andExpect(jsonPath("$._templates.*", hasSize(1)))
- .andExpect(jsonPath("$._templates['default'].method", is("post")))
- .andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
- .andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)));
+ .andExpect(jsonPath("$._templates['default'].method", is("post")));
+
+ expectEmployeeProperties(actions, "default");
}
@Test
@@ -251,26 +241,18 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(status().isCreated())
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
- this.mockMvc.perform(get("/employees/2").accept(MediaTypes.HAL_FORMS_JSON)) //
+ ResultActions actions = this.mockMvc.perform(get("/employees/2").accept(MediaTypes.HAL_FORMS_JSON)) //
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.name", is("Samwise Gamgee"))).andExpect(jsonPath("$.role", is("gardener")))
.andExpect(jsonPath("$._links.*", hasSize(2)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/2")))
- .andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
+ .andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")));
+
+ expectEmployeeProperties(actions, "default", "partiallyUpdateEmployee") //
- .andExpect(jsonPath("$._templates.*", hasSize(2)))
.andExpect(jsonPath("$._templates['default'].method", is("put")))
- .andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
- .andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)))
-
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
- .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
+ .andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")));
}
@Test
@@ -429,6 +411,26 @@ class MultiMediaTypeWebMvcIntegrationTest {
.andExpect(jsonPath("$.uber.data[4].data[1].value", is("Samwise Gamgee")));
}
+ private static final ResultActions expectEmployeeProperties(ResultActions actions, String... templates)
+ throws Exception {
+
+ for (String template : templates) {
+
+ JsonPathResultMatchers namePropertyMatcher = jsonPath("$._templates['%s'].properties[0].required", template);
+
+ actions = actions //
+ .andExpect(jsonPath("$._templates['%s'].properties[0].name", template).value("name"))
+ .andExpect(jsonPath("$._templates['%s'].properties[1].name", template).value("role"))
+ .andExpect(jsonPath("$._templates['%s'].properties[1].required", template).doesNotExist());
+
+ actions = template.equals("partiallyUpdateEmployee") //
+ ? actions.andExpect(namePropertyMatcher.doesNotExist()) //
+ : actions.andExpect(namePropertyMatcher.value(true));
+ }
+
+ return actions.andExpect(jsonPath("$._templates.*", hasSize(templates.length)));
+ }
+
@RestController
static class EmployeeController {
diff --git a/src/test/java/org/springframework/hateoas/support/Employee.java b/src/test/java/org/springframework/hateoas/support/Employee.java
index 95a5eb6f..89bec8eb 100644
--- a/src/test/java/org/springframework/hateoas/support/Employee.java
+++ b/src/test/java/org/springframework/hateoas/support/Employee.java
@@ -19,6 +19,8 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.experimental.Wither;
+import javax.validation.constraints.NotNull;
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
@@ -30,7 +32,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class Employee {
- private String name;
+ private @NotNull String name;
private String role;
Employee() {
diff --git a/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java b/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java
index 20270a33..02661f9d 100644
--- a/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java
+++ b/src/test/java/org/springframework/hateoas/support/JsonPathUtils.java
@@ -15,6 +15,8 @@
*/
package org.springframework.hateoas.support;
+import lombok.RequiredArgsConstructor;
+
import org.assertj.core.matcher.AssertionMatcher;
import org.hamcrest.Matcher;
import org.springframework.lang.Nullable;
@@ -36,12 +38,37 @@ public class JsonPathUtils {
}
public static AssertionMatcher jsonPath(String expression, @Nullable Object expectedValue) {
+ return new JsonPathAssertionMatcher(expression, expectedValue);
+ }
- return new AssertionMatcher() {
- @Override
- public void assertion(String actual) throws AssertionError {
- new JsonPathExpectationsHelper(expression).assertValue(actual, expectedValue);
+ @RequiredArgsConstructor
+ public static class JsonPathAssertionMatcher extends AssertionMatcher {
+
+ private final String expression;
+ private final @Nullable Object expected;
+
+ /*
+ * (non-Javadoc)
+ * @see org.assertj.core.matcher.AssertionMatcher#assertion(java.lang.Object)
+ */
+ @Override
+ public void assertion(String actual) throws AssertionError {
+
+ JsonPathExpectationsHelper helper = new JsonPathExpectationsHelper(expression);
+
+ if (expected == null) {
+ helper.doesNotHaveJsonPath(actual);
+ } else {
+ helper.assertValue(actual, expected);
}
- };
+ }
+
+ public JsonPathAssertionMatcher doesNotExist() {
+ return new JsonPathAssertionMatcher(this.expression, null);
+ }
+ }
+
+ public static JsonPathAssertionMatcher jsonPath(String expression) {
+ return new JsonPathAssertionMatcher(expression, null);
}
}
diff --git a/src/test/resources/org/springframework/hateoas/mediatype/hal/forms/employee-resource-support.json b/src/test/resources/org/springframework/hateoas/mediatype/hal/forms/employee-resource-support.json
index ef1ffb4f..cab33638 100644
--- a/src/test/resources/org/springframework/hateoas/mediatype/hal/forms/employee-resource-support.json
+++ b/src/test/resources/org/springframework/hateoas/mediatype/hal/forms/employee-resource-support.json
@@ -6,12 +6,9 @@
},
"_templates" : {
"default" : {
- "title" : null,
"method" : "post",
- "contentType" : "",
"properties" : [ {
- "name" : "name",
- "required" : true
+ "name" : "name"
} ]
}
},