#707 - Refactor Affordances to better support Spring Data REST.

* Move Spring MVC method scanning out of the core Affordances API and into SpringMvcAffordanceBuilder to make it easy to use in Spring Data REST (where method scanning isn't needed).
* Also push attributes from Affordance into AffordanceModel to centralize the details.
* Certain serializers must be registered differently due to Jackson's rules of precedence given Spring Data REST needs to override them.
This commit is contained in:
Greg Turnquist
2018-04-18 14:22:54 -05:00
parent 2e7eabdc1a
commit 820f66179f
29 changed files with 549 additions and 543 deletions

View File

@@ -15,108 +15,60 @@
*/
package org.springframework.hateoas.hal.forms;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
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.MediaTypes;
import org.springframework.hateoas.core.MethodParameters;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.QueryParameter;
import org.springframework.hateoas.support.PropertyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.util.UriComponents;
/**
* {@link AffordanceModel} for a HAL-FORMS {@link org.springframework.http.MediaType}.
* {@link AffordanceModel} for a HAL-FORMS {@link MediaType}.
*
* @author Greg Turnquist
*/
class HalFormsAffordanceModel implements AffordanceModel {
@EqualsAndHashCode(callSuper = true)
public class HalFormsAffordanceModel extends AffordanceModel {
private static final List<HttpMethod> METHODS_FOR_INPUT_DETECTTION = Arrays.asList(HttpMethod.POST, HttpMethod.PUT,
HttpMethod.PATCH);
private static final Set<HttpMethod> ENTITY_ALTERING_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH);
private final Affordance affordance;
private final UriComponents components;
private final boolean required;
private final List<String> properties;
private final @Getter List<HalFormsProperty> inputProperties;
public HalFormsAffordanceModel(Affordance affordance, UriComponents components) {
public HalFormsAffordanceModel(String name, Link link, HttpMethod httpMethod, ResolvableType inputType, List<QueryParameter> queryMethodParameters, ResolvableType outputType) {
this.affordance = affordance;
this.components = components;
this.required = determineRequired(affordance.getHttpMethod());
this.properties = METHODS_FOR_INPUT_DETECTTION.contains(affordance.getHttpMethod()) //
? determineAffordanceInputs() //
: Collections.emptyList();
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);
this.inputProperties = determineInputs();
}
/**
* Transform the details of the REST method's {@link MethodParameters} into
* {@link HalFormsProperty}s.
*
* @return
* Look at the input's domain type to extract the {@link Affordance}'s properties.
* Then transform them into a list of {@link HalFormsProperty} objects.
*/
public List<HalFormsProperty> getProperties() {
private List<HalFormsProperty> determineInputs() {
return properties.stream() //
.map(name -> HalFormsProperty.named(name).withRequired(required)) //
.collect(Collectors.toList());
}
public String getURI() {
return components.toUriString();
}
/**
* Returns whether the affordance is pointing to the same path as the given one.
*
* @param path must not be {@literal null}.
* @return
*/
public boolean hasPath(String path) {
Assert.notNull(path, "Path must not be null!");
return getURI().equals(path);
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModel#getMediaType()
*/
@Override
public Collection<MediaType> getMediaTypes() {
return Collections.singleton(MediaTypes.HAL_FORMS_JSON);
}
/**
* Based on the Spring MVC controller's HTTP method, decided whether or not input attributes are required or not.
*
* @param httpMethod - string representation of an HTTP method, e.g. GET, POST, etc.
* @return
*/
private boolean determineRequired(HttpMethod httpMethod) {
return Arrays.asList(HttpMethod.POST, HttpMethod.PUT).contains(httpMethod);
}
/**
* Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties.
*/
private List<String> determineAffordanceInputs() {
return this.affordance.getInputMethodParameters().stream()
.findFirst()
.map(methodParameter -> {
ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter);
return PropertyUtils.findProperties(resolvableType);
})
.orElse(Collections.emptyList());
if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {
return PropertyUtils.findPropertyNames(getInputType()).stream()
.map(propertyName -> new HalFormsProperty()
.withName(propertyName)
.withRequired(Arrays.asList(HttpMethod.POST, HttpMethod.PUT).contains(getHttpMethod())))
.collect(Collectors.toList());
} else {
return Collections.emptyList();
}
}
}

View File

@@ -17,13 +17,16 @@ package org.springframework.hateoas.hal.forms;
import lombok.Getter;
import org.springframework.hateoas.Affordance;
import java.util.List;
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.core.AffordanceModelFactory;
import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.web.util.UriComponents;
/**
* Factory for creating {@link HalFormsAffordanceModel}s.
@@ -35,13 +38,8 @@ class HalFormsAffordanceModelFactory implements AffordanceModelFactory {
private final @Getter MediaType mediaType = MediaTypes.HAL_FORMS_JSON;
/*
* (non-Javadoc)
* @see org.springframework.hateoas.AffordanceModelFactory#getAffordanceModel(org.springframework.hateoas.Affordance, org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation, org.springframework.web.util.UriComponents)
*/
@Override
public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue,
UriComponents components) {
return new HalFormsAffordanceModel(affordance, components);
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);
}
}

View File

@@ -22,8 +22,6 @@ import lombok.NonNull;
import lombok.Value;
import lombok.experimental.Wither;
import org.springframework.util.Assert;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
@@ -37,28 +35,26 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
@Value
@Wither
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
public class HalFormsProperty {
@NoArgsConstructor(force = true)
class HalFormsProperty {
private @Wither(AccessLevel.PRIVATE) @NonNull String name;
private @NonNull String name;
private Boolean readOnly;
private String value;
private String prompt;
private String regex;
private boolean templated;
private @JsonInclude(Include.ALWAYS) boolean required;
private @JsonInclude boolean required;
private boolean multi;
/**
* Creates a new {@link HalFormsProperty} with the given name.
*
* @param name must not be {@literal null} or empty.
* @param name must not be {@literal null}.
* @return
*/
public static HalFormsProperty named(String name) {
Assert.hasText(name, "Property name must not be null or empty!");
return new HalFormsProperty().withName(name);
}
}

View File

@@ -73,7 +73,9 @@ class HalFormsSerializers {
.withLinks(value.getLinks()) //
.withTemplates(findTemplates(value));
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
provider
.findValueSerializer(HalFormsDocument.class, property)
.serialize(doc, gen, provider);
}
@Override
@@ -148,7 +150,9 @@ class HalFormsSerializers {
.withTemplates(findTemplates(value));
}
provider.findValueSerializer(HalFormsDocument.class, property).serialize(doc, gen, provider);
provider
.findValueSerializer(HalFormsDocument.class, property)
.serialize(doc, gen, provider);
}
@Override
@@ -186,25 +190,27 @@ class HalFormsSerializers {
*/
private static Map<String, HalFormsTemplate> findTemplates(ResourceSupport resource) {
Map<String, HalFormsTemplate> templates = new HashMap<String, HalFormsTemplate>();
Map<String, HalFormsTemplate> templates = new HashMap<>();
if (resource.hasLink(Link.REL_SELF)) {
for (Affordance affordance : resource.getLink(Link.REL_SELF).map(Link::getAffordances)
.orElse(Collections.emptyList())) {
HalFormsAffordanceModel model = affordance.getAffordanceModel(MediaTypes.HAL_FORMS_JSON);
if (!affordance.getHttpMethod().equals(HttpMethod.GET)) {
if (!(model.getHttpMethod() == HttpMethod.GET)) {
validate(resource, affordance, model);
HalFormsTemplate template = HalFormsTemplate.forMethod(affordance.getHttpMethod()) //
.withProperties(model.getProperties());
HalFormsTemplate template = HalFormsTemplate.forMethod(model.getHttpMethod()) //
.withProperties(model.getInputProperties());
/**
* First template in HAL-FORMS is "default".
*/
templates.put(templates.isEmpty() ? "default" : affordance.getName(), template);
templates.put(templates.isEmpty() ? "default" : model.getName(), template);
}
}
}
@@ -222,7 +228,7 @@ class HalFormsSerializers {
private static void validate(ResourceSupport resource, Affordance affordance, HalFormsAffordanceModel model) {
String affordanceUri = model.getURI();
String selfLinkUri = resource.getRequiredLink(Link.REL_SELF).getHref();
String selfLinkUri = resource.getRequiredLink(Link.REL_SELF).expand().getHref();
if (!affordanceUri.equals(selfLinkUri)) {
throw new IllegalStateException("Affordance's URI " + affordanceUri + " doesn't match self link "

View File

@@ -24,7 +24,6 @@ import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.PagedResources;
import org.springframework.hateoas.RelProvider;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.hal.CurieProvider;
@@ -74,10 +73,12 @@ public class Jackson2HalFormsModule extends SimpleModule {
setMixInAnnotation(Link.class, LinkMixin.class);
setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class);
setMixInAnnotation(Resource.class, ResourceMixin.class);
setMixInAnnotation(Resources.class, ResourcesMixin.class);
setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class);
setMixInAnnotation(MediaType.class, MediaTypeMixin.class);
addSerializer(new HalFormsResourceSerializer());
}
@JsonSerialize(using = HalFormsResourceSerializer.class)
@@ -105,7 +106,7 @@ public class Jackson2HalFormsModule extends SimpleModule {
@JsonSerialize(using = ToStringSerializer.class)
@JsonDeserialize(using = MediaTypeDeserializer.class)
static interface MediaTypeMixin {}
interface MediaTypeMixin {}
/**
* Create new HAL-FORMS serializers based on the context.