#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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user