#1018 - HAL Forms properties customizable via annotations on representation model classes.

We now consider additional Jackson and JSR-303 annotations on representation models to enrich the HAL Forms template properties with additional information:

- @NotNull on a property flips its `required` flag to `true`.
- The "regexp" attribute of @Pattern on a property is forwarded into the "regex" field.
- @JsonProperty(Access.READ_ONLY) on a property is translated into the "readOnly" flag.

The default for the "required" flag have been changed to false even for PUT and POST as whether they're required or not is completely determined by the model, not the HTTP method.

All of this is backed by significant refactoring in the way that Affordance instances are build internally. The new API is centered around the Affordances type in the mediatype package. The methods on Link to create the Affordance` from its details have been removed and replaced by builder style APIs on Affordance. AffordanceModelFactory has been moved to the mediatype as well.

PropertyUtils has been significantly revamped to expose a PayloadMetadata/PropertyMetadata model to abstract the individual traits of a property. This allows to optionally plug in the support for JSR-303 annotations. AffordanceModelFactory has been refactored to rather work with those instead of ResolvableType.
This commit is contained in:
Oliver Drotbohm
2019-07-15 22:01:16 +02:00
parent ca68c7d1b2
commit fc1b4a8b51
48 changed files with 2276 additions and 860 deletions

View File

@@ -798,6 +798,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -993,6 +1000,7 @@
<configuration>
<source>${source.level}</source>
<target>${source.level}</target>
<parameters>true</parameters>
</configuration>
</plugin>

View File

@@ -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[]
}
}

View File

@@ -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<EmployeeModel> {
@NotNull //
private String name;
@Pattern(regexp = "[A-Z_]") //
private String role;
}
// end:hal-forms-model[]

View File

@@ -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_]"
} ]
}
}

View File

@@ -195,114 +195,3 @@ Collection<Person> people = Collections.singleton(new Person("Dave", "Matthews")
CollectionModel<Person> 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]
----
<form method="put" action="http://localhost:8080/employees/1">
<input type="text" id="firstName" name="firstName"/>
<input type="text" id="lastName" name="lastName" />
<input type="text" id="role" name="role" />
<input type="submit" value="Submit" />
</form>
----
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.

View File

@@ -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 <<fundamentals.affordances,Affordances API>> to augment your controllers with this extra metadata.
Read about the <<server.affordances,Affordances API>> 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,HAL documents>>.
[[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

View File

@@ -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 <<server.affordances>> 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.

View File

@@ -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 <<server.link-builder.webmvc>> 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 <<mediatypes>> section to find more details about how to control the exposure of that metadata.
[[server.link-builder.forwarded-headers]]
== Forwarded header handling

View File

@@ -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<AffordanceModelFactory> factories = SpringFactoriesLoader
.loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());
public class Affordance implements Iterable<AffordanceModel> {
/**
* Collection of {@link AffordanceModel}s related to this affordance.
*/
private final @Getter(AccessLevel.PACKAGE) Map<MediaType, AffordanceModel> 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<QueryParameter> 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<MediaType, AffordanceModel> models;
/**
* Look up the {@link AffordanceModel} for the requested {@link MediaType}.
@@ -78,6 +49,15 @@ public class Affordance {
@Nullable
@SuppressWarnings("unchecked")
public <T extends AffordanceModel> 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<AffordanceModel> iterator() {
return models.values().iterator();
}
}

View File

@@ -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<PropertyMetadata> stream();
default Optional<PropertyMetadata> 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 <T>
* @param target
* @return
*/
<T extends PropertyMetadataConfigured<T> & Named> T applyTo(T target);
<T extends Named> T customize(T target, Function<PropertyMetadata, T> customizer);
/**
* Returns the I18n codes to be used to resolve a name for the payload metadata.
*
* @return
*/
List<String> 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<PropertyMetadata> stream() {
return metadata.stream();
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#customize(org.springframework.hateoas.AffordanceModel.PropertyMetadataConfigured)
*/
@Override
public <T extends PropertyMetadataConfigured<T> & 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 extends Named> T customize(T target, Function<PropertyMetadata, T> customizer) {
return target;
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel.InputPayloadMetadata#getI18nCodes()
*/
@Override
public List<String> 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<String> 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<T> {
/**
* 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<PropertyMetadata> stream() {
return Stream.empty();
}
}
}

View File

@@ -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<QueryParameter> 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 <httpMethod=HttpMethod.PUT, inputType=Employee>} produces {@literal <name=putEmployee>}.
*
* @param httpMethod
* @param inputType
* @param queryMethodParameters
* @param outputType
* @return
*/
public Link andAffordance(HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> 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 <httpMethod=HttpMethod.PUT, inputType=Employee>} produces {@literal <name=putEmployee>}.
*
* @param httpMethod
* @param inputType
* @param queryMethodParameters
* @param outputType
* @return
*/
public Link andAffordance(HttpMethod httpMethod, Class<?> inputType, List<QueryParameter> queryMethodParameters,
Class<?> outputType) {
return andAffordance(httpMethod, ResolvableType.forClass(inputType), queryMethodParameters,
ResolvableType.forClass(outputType));
}
/**
* Create new {@link Link} with additional {@link Affordance}s.
*

View File

@@ -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<RequestParam> 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);
}
}

View File

@@ -44,7 +44,8 @@ class HypermediaConfigurationImportSelector implements ImportSelector {
Map<String, Object> attributes = metadata.getAnnotationAttributes(EnableHypermediaSupport.class.getName());
Collection<MediaType> types = attributes == null ? Collections.emptyList()
Collection<MediaType> types = attributes == null //
? Collections.emptyList() //
: Arrays.stream((HypermediaType[]) attributes.get("type")) //
.flatMap(it -> it.getMediaTypes().stream()) //
.collect(Collectors.toList());

View File

@@ -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<QueryParameter> queryMethodParameters, ResolvableType outputType);
AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType);
}

View File

@@ -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();
}

View File

@@ -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<AffordanceModelFactory> 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<Affordance> 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<QueryParameter> 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<QueryParameter> 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<QueryParameter> 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<QueryParameter> 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());
}
}
}

View File

@@ -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<String> FIELDS_TO_IGNORE = new HashSet<>();
private static final Map<ResolvableType, ResolvableType> DOMAIN_TYPE_CACHE = new ConcurrentReferenceHashMap<>();
private static final Map<ResolvableType, InputPayloadMetadata> METADATA_CACHE = new ConcurrentReferenceHashMap<>();
private static final Set<String> 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<Class<?>> 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<String, Object> findProperties(@Nullable Object object) {
private static class ReactiveWrappers {
static List<Class<?>> getTypesToUnwrap() {
return Arrays.asList(Publisher.class);
}
}
public static Map<String, Object> 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<String> 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<String> 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> T createObjectFromProperties(Class<T> clazz, Map<String, Object> 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<ResolvableType> mapper) {
Class<?> resolved = type.resolve(Object.class);
return TYPES_TO_UNWRAP.stream().anyMatch(it -> it.isAssignableFrom(resolved)) //
? mapper.get() //
: type;
}
private static Stream<PropertyMetadata> 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<PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
private static Stream<PropertyDescriptor> 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<Class<?>, MergedAnnotation<?>> annotationCache = new ConcurrentReferenceHashMap<>();
private final Property property;
private final ResolvableType type;
private final List<MergedAnnotations> 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> T firstNonEmpty(Supplier<Optional<T>>... 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 <T> 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 <T extends Annotation> MergedAnnotation<T> getAnnotation(Class<T> type) {
Assert.notNull(type, "Type must not be null!");
return (MergedAnnotation<T>) annotationCache.computeIfAbsent(type, it -> lookupAnnotation(type));
}
private <T extends Annotation> MergedAnnotation<T> lookupAnnotation(Class<T> 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<DefaultPropertyMetadata> {
private static Comparator<PropertyMetadata> 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<JsonProperty> 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<String> 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<String> getPattern() {
MergedAnnotation<Pattern> 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<String> fromAnnotation(MergedAnnotation<Pattern> annotation) {
return Optional.of(annotation.getString("regexp")) //
.filter(StringUtils::hasText);
}
}
}

View File

@@ -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<String, PropertyMetadata> properties;
TypeBasedPayloadMetadata(ResolvableType type, Stream<PropertyMetadata> 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 <T extends PropertyMetadataConfigured<T> & 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 extends Named> T customize(T target, Function<PropertyMetadata, T> 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<PropertyMetadata> stream() {
return properties.values().stream();
}
/*
* (non-Javadoc)
* @see org.springframework.context.MessageSourceResolvable#getCodes()
*/
@Override
public List<String> getI18nCodes() {
Class<?> type = this.type.resolve(Object.class);
return Arrays.asList(type.getName(), type.getSimpleName());
}
}

View File

@@ -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<CollectionJsonData> inputProperties;
private final @Getter List<CollectionJsonData> queryProperties;
public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
public CollectionJsonAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
List<QueryParameter> 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("")) //

View File

@@ -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<QueryParameter> queryMethodParameters, ResolvableType outputType) {
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
InputPayloadMetadata inputType, List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
return new CollectionJsonAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
}
}

View File

@@ -89,7 +89,7 @@ class CollectionJsonItem<T> {
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())) //

View File

@@ -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)) //

View File

@@ -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<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(POST, PUT, PATCH);
private static final Set<HttpMethod> REQUIRED_METHODS = EnumSet.of(POST, PUT);
private final @Getter List<HalFormsProperty> inputProperties;
public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType,
List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
List<QueryParameter> 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());
}
}

View File

@@ -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<QueryParameter> queryMethodParameters, ResolvableType outputType) {
return new HalFormsAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
InputPayloadMetadata inputType, List<QueryParameter> parameters, PayloadMetadata outputType) {
return new HalFormsAffordanceModel(name, link, httpMethod, inputType, parameters, outputType);
}
}

View File

@@ -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<Class<?>, 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<String> getTypePatternFor(ResolvableType type) {
return Optional.ofNullable(patterns.get(type.resolve(Object.class)));
}
}

View File

@@ -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<T> {
@Nullable //
@Getter(onMethod = @__(@JsonAnyGetter))
@JsonInclude(Include.NON_EMPTY) //
@Getter(onMethod = @__(@JsonAnyGetter)) @JsonInclude(Include.NON_EMPTY) //
@Wither(AccessLevel.PRIVATE) //
private Map<String, Object> attributes;
@Nullable //
@JsonUnwrapped //
@JsonInclude(Include.NON_NULL) //
private T resource;
private T entity;
@Nullable //
@JsonInclude(Include.NON_EMPTY) //
@JsonIgnore //
@Wither(AccessLevel.PRIVATE) //
private Collection<T> resources;
private Collection<T> entities;
@JsonProperty("_embedded") //
@JsonInclude(Include.NON_EMPTY) //
@@ -86,14 +86,12 @@ public class HalFormsDocument<T> {
@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<String, HalFormsTemplate> templates;
@@ -110,7 +108,7 @@ public class HalFormsDocument<T> {
*/
public static HalFormsDocument<?> forRepresentationModel(RepresentationModel<?> model) {
Map<String, Object> attributes = PropertyUtils.findProperties(model);
Map<String, Object> attributes = PropertyUtils.extractPropertyValues(model);
attributes.remove("links");
return new HalFormsDocument<>().withAttributes(attributes);
@@ -122,21 +120,21 @@ public class HalFormsDocument<T> {
* @param resource can be {@literal null}.
* @return
*/
public static <T> HalFormsDocument<T> forResource(@Nullable T resource) {
return new HalFormsDocument<T>().withResource(resource);
public static <T> HalFormsDocument<T> forEntity(@Nullable T resource) {
return new HalFormsDocument<T>().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 <T> HalFormsDocument<T> forResources(Collection<T> resources) {
public static <T> HalFormsDocument<T> forEntities(Collection<T> entities) {
Assert.notNull(resources, "Resources must not be null!");
Assert.notNull(entities, "Resources must not be null!");
return new HalFormsDocument<T>().withResources(resources);
return new HalFormsDocument<T>().withEntities(entities);
}
/**
@@ -173,11 +171,11 @@ public class HalFormsDocument<T> {
}
public HalFormsDocument<T> withPageMetadata(@Nullable PageMetadata metadata) {
return new HalFormsDocument<T>(attributes, resource, resources, embedded, metadata, links, templates);
return new HalFormsDocument<T>(attributes, entity, entities, embedded, metadata, links, templates);
}
private HalFormsDocument<T> withResource(@Nullable T resource) {
return new HalFormsDocument<T>(attributes, resource, resources, embedded, pageMetadata, links, templates);
private HalFormsDocument<T> withEntity(@Nullable T entity) {
return new HalFormsDocument<T>(attributes, entity, entities, embedded, pageMetadata, links, templates);
}
/**
@@ -190,7 +188,7 @@ public class HalFormsDocument<T> {
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<T> {
Map<String, HalFormsTemplate> 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<T> {
Map<HalLinkRelation, Object> 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);
}
}

View File

@@ -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<HalFormsProperty>, 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);
}
}

View File

@@ -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<String, HalFormsTemplate> findTemplates(RepresentationModel<?> resource,
MessageSourceAccessor accessor) {
if (!resource.hasLink(IanaLinkRelations.SELF)) {
return Collections.emptyMap();
}
Map<String, HalFormsTemplate> templates = new HashMap<>();
List<Affordance> 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<HalFormsProperty> 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<String> 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<String> 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);
}
}
}

View File

@@ -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<HalFormsProperty> properties;
private List<MediaType> 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<HalFormsProperty> getPropertyByName(String name) {
return properties.stream().filter(it -> it.getName().equals(name)).findFirst();
}
}

View File

@@ -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<String, HalFormsTemplate> findTemplates(RepresentationModel<?> resource) {
if (!resource.hasLink(IanaLinkRelations.SELF)) {
return Collections.emptyMap();
}
Map<String, HalFormsTemplate> templates = new HashMap<>();
List<Affordance> 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<HalFormsProperty> 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<HalFormsProperty> 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<String> 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<String> getCodesFor(String name, InputPayloadMetadata type) {
String global = String.format(TEMPLATE_TEMPLATE, name);
Stream<String> 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<String> 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()]);
}
}
}

View File

@@ -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()));
}

View File

@@ -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<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH);
private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT,
HttpMethod.PATCH);
private final @Getter Collection<MediaType> mediaTypes = Collections.singleton(MediaTypes.UBER_JSON);
private final @Getter List<UberData> inputProperties;
private final @Getter List<UberData> queryProperties;
UberAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
UberAffordanceModel(String name, Link link, HttpMethod httpMethod, InputPayloadMetadata inputType,
List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
this.inputProperties = determineAffordanceInputs();
@@ -58,16 +58,16 @@ class UberAffordanceModel extends AffordanceModel {
private List<UberData> 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();
}

View File

@@ -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<QueryParameter> queryMethodParameters, ResolvableType outputType) {
public AffordanceModel getAffordanceModel(String name, Link link, HttpMethod httpMethod,
InputPayloadMetadata inputType, List<QueryParameter> queryMethodParameters, PayloadMetadata outputType) {
return new UberAffordanceModel(name, link, httpMethod, inputType, queryMethodParameters, outputType);
}
}

View File

@@ -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());
}

View File

@@ -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<QueryParameter> 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());
}
}

View File

@@ -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

View File

@@ -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"));

View File

@@ -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<AffordanceModel> 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<AffordanceModel> 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<PayloadMetadataAssert, PayloadMetadata> {
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;
}
}
}

View File

@@ -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<String, Object> properties = PropertyUtils.findProperties(employee);
Map<String, Object> 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<Employee> employeeResource = new EntityModel<>(employee);
Map<String, Object> properties = PropertyUtils.findProperties(employeeResource);
Map<String, Object> 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<String> 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<String, Object> properties = PropertyUtils.findProperties(employee);
Map<String, Object> 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<String, Object> properties = PropertyUtils.findProperties(employee);
Map<String, Object> 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();
}
}
}

View File

@@ -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<String, HalFormsTemplate> 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<String, HalFormsTemplate> 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<PatternExample> {
CreditCardNumber number;
@Pattern(regexp = "foo") CreditCardNumber overridden;
WithTypeLevelAnnotation annotated;
}
static class CreditCardNumber {}
@Pattern(regexp = "bar")
static class WithTypeLevelAnnotation {}
@Getter
static class RequiredProperty extends RepresentationModel<RequiredProperty> {
@NotNull String name;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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

View File

@@ -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<HalFormsPayload> 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<HalFormsPayload> 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<Jsr303Sample> 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;
}
}
}

View File

@@ -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<String> 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<EntityModel<String>> expected = new CollectionModel<>(
content);
CollectionModel<EntityModel<String>> 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<EntityModel<String>> result = mapper.readValue(resourcesJson,
resourcesType);
CollectionModel<EntityModel<String>> result = mapper.readValue(resourcesJson, resourcesType);
assertThat(result).isEqualTo(expected);
}
@@ -196,8 +194,7 @@ class Jackson2UberIntegrationTest extends AbstractJackson2MarshallingIntegration
@Test
void renderResourceWithCustomRel() throws Exception {
EntityModel<String> data2 = new EntityModel<>("second",
new Link("localhost").withRel("custom"));
EntityModel<String> 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<String> data3 = new EntityModel<>("third", new Link("localhost"),
new Link("second").withRel("second"), new Link("third").withRel("third"));
EntityModel<String> 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<String> actual = mapper
.readValue(MappingUtils.read(new ClassPathResource("resource.json", getClass())), resourceStringType);
EntityModel<String> actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resource.json", getClass())),
resourceStringType);
assertThat(actual).isEqualTo(expected);
EntityModel<String> expected2 = new EntityModel<>("second",
new Link("localhost").withRel("custom"));
EntityModel<String> expected2 = new EntityModel<>("second", new Link("localhost").withRel("custom"));
EntityModel<String> 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<EntityModel<String>> resources = new CollectionModel<>(
data);
CollectionModel<EntityModel<String>> 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<Employee> expected = new EntityModel<>(employee,
new Link("/employees/1").withSelfRel());
EntityModel<Employee> 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<Employee> expected = new EntityModel<>(employee,
new Link("/employees/1").withSelfRel());
EntityModel<Employee> expected = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
EntityModel<Employee> 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<Employee> expected = new EntityModel<>(employee,
new Link("/employees/1").withSelfRel());
EntityModel<Employee> expected = new EntityModel<>(employee, new Link("/employees/1").withSelfRel());
EntityModel<Employee> 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<EntityModel<Employee>> setupAnnotatedPagedResources(
int size, int totalElements) {
private static CollectionModel<EntityModel<Employee>> setupAnnotatedPagedResources(int size, int totalElements) {
List<EntityModel<Employee>> content = new ArrayList<>();
Employee employee = new Employee("Frodo", "ring bearer");
EntityModel<Employee> employeeResource = new EntityModel<>(employee,
new Link("/employees/1").withSelfRel());
EntityModel<Employee> 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<EmployeeResource> {
private String name;
private String role;
private @Nullable String name, role;
}
}

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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<String> jsonPath(String expression, @Nullable Object expectedValue) {
return new JsonPathAssertionMatcher(expression, expectedValue);
}
return new AssertionMatcher<String>() {
@Override
public void assertion(String actual) throws AssertionError {
new JsonPathExpectationsHelper(expression).assertValue(actual, expectedValue);
@RequiredArgsConstructor
public static class JsonPathAssertionMatcher extends AssertionMatcher<String> {
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);
}
}

View File

@@ -6,12 +6,9 @@
},
"_templates" : {
"default" : {
"title" : null,
"method" : "post",
"contentType" : "",
"properties" : [ {
"name" : "name",
"required" : true
"name" : "name"
} ]
}
},