Skip to content

Commit

Permalink
#707 - Refactor Affordances to better support Spring Data REST.
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
gregturn committed Jan 7, 2019
1 parent 2e7eabd commit 820f661
Show file tree
Hide file tree
Showing 29 changed files with 552 additions and 546 deletions.
55 changes: 28 additions & 27 deletions src/main/java/org/springframework/hateoas/Affordance.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,52 +15,53 @@
*/
package org.springframework.hateoas;

import lombok.Value;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.hateoas.core.AffordanceModelFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;

/**
* Abstract representation of an action a link is able to take. Web frameworks must provide concrete implementation.
* Hold the {@link AffordanceModel}s for all supported media types.
*
* @author Greg Turnquist
* @author Oliver Gierke
*/
public interface Affordance {
@Value
public class Affordance {

/**
* HTTP method this affordance covers. For multiple methods, add multiple {@link Affordance}s.
*
* @return
*/
HttpMethod getHttpMethod();
private static List<AffordanceModelFactory> factories = SpringFactoriesLoader.loadFactories(AffordanceModelFactory.class, Affordance.class.getClassLoader());

/**
* Name for the REST action this {@link Affordance} can take.
*
* @return
* Collection of {@link AffordanceModel}s related to this affordance.
*/
String getName();
private final Map<MediaType, AffordanceModel> affordanceModels = new HashMap<>();

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

/**
* Look up the {@link AffordanceModel} for the requested {@link MediaType}.
*
* @param mediaType
* @return
*/
<T extends AffordanceModel> T getAffordanceModel(MediaType mediaType);

/**
* Get a listing of {@link MethodParameter}s.
*
* @return
*/
List<MethodParameter> getInputMethodParameters();

/**
* Get a listing of {@link QueryParameter}s.
* @return
*/
List<QueryParameter> getQueryMethodParameters();
@SuppressWarnings("unchecked")
public <T extends AffordanceModel> T getAffordanceModel(MediaType mediaType) {
return (T) this.affordanceModels.get(mediaType);
}
}
60 changes: 49 additions & 11 deletions src/main/java/org/springframework/hateoas/AffordanceModel.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017 the original author or authors.
* Copyright 2018 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.
Expand All @@ -15,23 +15,61 @@
*/
package org.springframework.hateoas;

import java.util.Collection;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import org.springframework.http.MediaType;
import java.util.List;

import org.springframework.core.ResolvableType;
import org.springframework.http.HttpMethod;

/**
* An affordance model is a media type specific description of an affordance.
* Collection of attributes needed to render any form of hypermedia.
*
* @author Greg Turnquist
* @author Oliver Gierke
*/
public interface AffordanceModel {
@EqualsAndHashCode
@AllArgsConstructor
@Getter
public abstract class AffordanceModel {

/**
* Name for the REST action of this resource.
*/
private String name;

/**
* {@link Link} for the URI of the resource.
*/
private Link link;

/**
* Request method verb for this resource. For multiple methods, add multiple {@link Affordance}s.
*/
private HttpMethod httpMethod;

/**
* Domain type used to create a new resource.
*/
private ResolvableType inputType;

/**
* Collection of {@link QueryParameter}s to interrogate a resource.
*/
private List<QueryParameter> queryMethodParameters;

/**
* Response body domain type.
*/
private ResolvableType outputType;

/**
* The media types this is a model for. Can be multiple ones as often media types come in different flavors like an
* XML and JSON one and in simple cases a single model might serve them all.
*
* @return will never be {@literal null}.
* Expand the {@link Link} into an {@literal href} with no parameters.
*
* @return
*/
Collection<MediaType> getMediaTypes();
public String getURI() {
return this.link.expand().getHref();
}
}
44 changes: 44 additions & 0 deletions src/main/java/org/springframework/hateoas/Link.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
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;

Expand Down Expand Up @@ -189,6 +191,48 @@ public Link andAffordance(Affordance affordance) {
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) {
return andAffordance(httpMethod.toString().toLowerCase() + inputType.resolve().getSimpleName(), 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.
*
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/org/springframework/hateoas/QueryParameter.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import lombok.RequiredArgsConstructor;

/**
* Web framework-neutral representation of a web request's query parameter (http://example.com?name=foo).
* Representation of a web request's query parameter (http://example.com?name=foo) => {"name", "foo", true}.
*
* @author Greg Turnquist
*/
Expand All @@ -28,6 +28,6 @@
public class QueryParameter {

private final String name;
private final boolean required;
private final String value;
private final boolean required;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,98 +15,78 @@
*/
package org.springframework.hateoas.collectionjson;

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.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.web.util.UriComponents;

/**
* @author Greg Turnquist
*/
class CollectionJsonAffordanceModel implements AffordanceModel {
@EqualsAndHashCode(callSuper = true)
public class CollectionJsonAffordanceModel extends AffordanceModel {

private static final List<HttpMethod> METHODS_FOR_INPUT_DETECTION = 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 @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) {

CollectionJsonAffordanceModel(Affordance affordance, UriComponents components) {
super(name, link, httpMethod, inputType, queryMethodParameters, outputType);

this.affordance = affordance;
this.components = components;

this.inputProperties = determineAffordanceInputs();
this.inputProperties = determineInputs();
this.queryProperties = determineQueryProperties();
}

@Override
public Collection<MediaType> getMediaTypes() {
return Collections.singleton(MediaTypes.COLLECTION_JSON);
}

public String getRel() {
return isHttpGetMethod() ? this.affordance.getName() : "";
}

public String getUri() {
return isHttpGetMethod() ? this.components.toUriString() : "";
}

/**
* Transform a list of {@link QueryParameter}s into a list of {@link CollectionJsonData} objects.
*
* @return
* Look at the input's domain type to extract the {@link Affordance}'s properties.
* Then transform them into a list of {@link CollectionJsonData} objects.
*/
private List<CollectionJsonData> determineQueryProperties() {
private List<CollectionJsonData> determineInputs() {

if (!isHttpGetMethod()) {
return Collections.emptyList();
}
if (ENTITY_ALTERING_METHODS.contains(getHttpMethod())) {

return this.affordance.getQueryMethodParameters().stream()
.map(queryProperty -> new CollectionJsonData().withName(queryProperty.getName()).withValue(""))
.collect(Collectors.toList());
}
return PropertyUtils.findPropertyNames(getInputType()).stream()
.map(propertyName -> new CollectionJsonData()
.withName(propertyName)
.withValue(""))
.collect(Collectors.toList());

private boolean isHttpGetMethod() {
return this.affordance.getHttpMethod().equals(HttpMethod.GET);
} else {
return Collections.emptyList();

}
}

/**
* Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties.
* Then transform them into a list of {@link CollectionJsonData} objects.
* Transform a list of general {@link QueryParameter}s into a list of {@link CollectionJsonData} objects.
*
* @return
*/
private List<CollectionJsonData> determineAffordanceInputs() {
private List<CollectionJsonData> determineQueryProperties() {

if (!METHODS_FOR_INPUT_DETECTION.contains(affordance.getHttpMethod())) {
if (getHttpMethod().equals(HttpMethod.GET)) {

return getQueryMethodParameters().stream()
.map(queryProperty -> new CollectionJsonData()
.withName(queryProperty.getName())
.withValue(""))
.collect(Collectors.toList());
} else {
return Collections.emptyList();
}

return this.affordance.getInputMethodParameters().stream()
.findFirst()
.map(methodParameter -> {
ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter);
return PropertyUtils.findProperties(resolvableType);
})
.orElse(Collections.emptyList())
.stream()
.map(property -> new CollectionJsonData().withName(property).withValue(""))
.collect(Collectors.toList());
}
}
Loading

0 comments on commit 820f661

Please sign in to comment.