#340 - Add new Affordances API + HAL-FORMS mediatype.

* Introduces new Affordances API to build links related to each other to serve other mediatypes
* Introduces HAL-FORMS, which uses affordances to automatically generate HTML form data based on Spring MVC annotations.

Original pull-request: #340, #447, #581
Related issues: #503, #334, #71
This commit is contained in:
Greg Turnquist
2017-07-13 13:36:36 -05:00
committed by Oliver Gierke
parent 79ebf9b5a4
commit 70448a8540
65 changed files with 3776 additions and 73 deletions

View File

@@ -144,6 +144,13 @@ public class Jackson2HalModule extends SimpleModule {
this.halConfiguration = halConfiguration;
}
/**
* Needed to support Jackson
*/
HalLinkListSerializer() {
this(null, null, null, null, new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_SINGLE));
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
@@ -679,27 +686,6 @@ public class Jackson2HalModule extends SimpleModule {
private final Map<Class<?>, Object> serializers = new HashMap<>();
private final AutowireCapableBeanFactory delegate;
/**
* Creates a new {@link HalHandlerInstantiator} using the given {@link RelProvider}, {@link CurieProvider} and
* {@link MessageSourceAccessor} and {@link AutowireCapableBeanFactory}. Registers a prepared
* {@link HalResourcesSerializer} and {@link HalLinkListSerializer} falling back to instantiation using the given
* {@link AutowireCapableBeanFactory} if provided, or simple default constructor instantiation if not.
*
* @param provider must not be {@literal null}.
* @param curieProvider can be {@literal null}.
* @param accessor can be {@literal null}.
* @param beanFactory can be {@literal null}
*/
public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider, MessageSourceAccessor accessor,
AutowireCapableBeanFactory beanFactory, HalConfiguration halConfiguration) {
this(provider, curieProvider, accessor, true, beanFactory, halConfiguration);
}
public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider,
MessageSourceAccessor messageSourceAccessor, AutowireCapableBeanFactory beanFactory) {
this(provider, curieProvider, messageSourceAccessor, beanFactory, beanFactory.getBean(HalConfiguration.class));
}
public HalHandlerInstantiator(RelProvider provider, CurieProvider curieProvider,
MessageSourceAccessor messageSourceAccessor) {
this(provider, curieProvider, messageSourceAccessor, new HalConfiguration());
@@ -864,7 +850,7 @@ public class Jackson2HalModule extends SimpleModule {
*
* @author Oliver Gierke
*/
private static class EmbeddedMapper {
public static class EmbeddedMapper {
private RelProvider relProvider;
private CurieProvider curieProvider;

View File

@@ -31,7 +31,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
* @author Greg Turnquist
*/
@JsonIgnoreProperties({"rel", "media"})
abstract class LinkMixin extends Link {
public abstract class LinkMixin extends Link {
private static final long serialVersionUID = 4720588561299667409L;

View File

@@ -28,7 +28,14 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
abstract class ResourceSupportMixin extends ResourceSupport {
/**
* Custom mixin to render {@link Link}s in HAL.
*
* @author Alexander Baetz
* @author Oliver Gierke
* @author Greg Turnquist
*/
public abstract class ResourceSupportMixin extends ResourceSupport {
@Override
@XmlElement(name = "link")

View File

@@ -28,6 +28,13 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Custom mixin to to render collection content as {@literal _embedded}.
*
* @author Alexander Baetz
* @author Oliver Gierke
* @author Greg Turnquist
*/
@JsonPropertyOrder({ "content", "links" })
public abstract class ResourcesMixin<T> extends Resources<T> {

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.util.UriComponents;
/**
* {@link AffordanceModel} for a HAL-FORMS {@link org.springframework.http.MediaType}.
*
* @author Greg Turnquist
*/
public class HalFormsAffordanceModel implements AffordanceModel {
private static final Logger log = LoggerFactory.getLogger(HalFormsAffordanceModel.class);
/**
* Details about the affordance's
*/
private final UriComponents components;
/**
* Is this required/not required?
*/
private final boolean required;
/**
* {@link Map} of property names and their types associated with the incoming request body.
*/
private final Map<String, Class<?>> properties;
public HalFormsAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) {
this.components = components;
this.required = determineRequired(affordance.getHttpMethod());
this.properties = new TreeMap<String, Class<?>>();
if (affordance.getHttpMethod().equalsIgnoreCase("POST") ||
affordance.getHttpMethod().equalsIgnoreCase("PUT") ||
affordance.getHttpMethod().equalsIgnoreCase("PATCH")) {
determineAffordanceInputs(invocationValue.getMethod());
}
}
/**
* Transform the details of the Spring MVC method's {@link RequestBody} into a collection of {@link HalFormsProperty}s.
*
* @return
*/
public List<HalFormsProperty> getProperties() {
List<HalFormsProperty> halFormsProperties = new ArrayList<HalFormsProperty>();
for (Map.Entry<String, Class<?>> entry : this.properties.entrySet()) {
halFormsProperties.add(new HalFormsProperty(entry.getKey(), null, null, null, null, false, this.required, false));
}
return halFormsProperties;
}
/**
* Look up the path of the {@link UriComponents}.
*
* @return
*/
public String getPath() {
return this.components.getPath();
}
/**
* 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(String httpMethod) {
if (httpMethod.equalsIgnoreCase("POST") || httpMethod.equalsIgnoreCase("PUT")) {
return true;
} else {
return false;
}
}
/**
* Look at the inputs for a Spring MVC controller method to decide the {@link Affordance}'s properties.
*
* @param method - {@link Method} of the Spring MVC controller tied to this affordance
*/
private void determineAffordanceInputs(Method method) {
if (method == null) {
return;
}
log.debug("Gathering details about " + method.getDeclaringClass().getCanonicalName() + "." + method.getName());
for (int i = 0; i < method.getParameterTypes().length; i++) {
for (Annotation annotation : method.getParameterAnnotations()[i]) {
if (annotation.annotationType().equals(RequestBody.class)) {
log.debug("\tRequest body: " + method.getParameterTypes()[i].getCanonicalName() + "(");
for (PropertyDescriptor descriptor : BeanUtils.getPropertyDescriptors(method.getParameterTypes()[i])) {
if (!descriptor.getName().equals("class")) {
log.debug("\t\t" + descriptor.getPropertyType().getCanonicalName() + " " + descriptor.getName());
this.properties.put(descriptor.getName(), descriptor.getPropertyType());
}
}
log.debug(")");
}
}
}
log.debug("Assembled " + this.toString());
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import lombok.Getter;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.AffordanceModelFactory;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation;
import org.springframework.http.MediaType;
import org.springframework.web.util.UriComponents;
/**
* Factory for creating {@link HalFormsAffordanceModel}s.
*
* @author Greg Turnquist
*/
@Getter
public class HalFormsAffordanceModelFactory extends AffordanceModelFactory {
private final MediaType mediaType = MediaTypes.HAL_FORMS_JSON;
@Override
public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) {
return new HalFormsAffordanceModel(affordance, invocationValue, components);
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.Wither;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.hal.HalConfiguration;
/**
* @author Greg Turnquist
*/
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class HalFormsConfiguration {
private @Wither @Getter RenderSingleLinks renderSingleLinks = RenderSingleLinks.AS_SINGLE;
public enum RenderSingleLinks {
/**
* A single {@link Link} is rendered as a JSON object.
*/
AS_SINGLE,
/**
* A single {@link Link} is rendered as a JSON Array.
*/
AS_ARRAY
}
/**
* Translate a {@link HalFormsConfiguration} into a {@link HalConfiguration}.
*
* @return
*/
public HalConfiguration toHalConfiguration() {
if (this.getRenderSingleLinks() == RenderSingleLinks.AS_SINGLE) {
return new HalConfiguration().withRenderSingleLinks(HalConfiguration.RenderSingleLinks.AS_SINGLE);
}
if (this.getRenderSingleLinks() == RenderSingleLinks.AS_ARRAY) {
return new HalConfiguration().withRenderSingleLinks(HalConfiguration.RenderSingleLinks.AS_ARRAY);
}
throw new IllegalStateException("Don't know how to translate " + this);
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.MediaType;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase;
import com.fasterxml.jackson.databind.type.TypeFactory;
/**
* Collection of components needed to deserialize a HAL-FORMS document.
*
* @author Greg Turnquist
*/
public class HalFormsDeserializers {
static class HalFormsResourcesDeserializer extends ContainerDeserializerBase<List<Object>> implements ContextualDeserializer {
private JavaType contentType;
HalFormsResourcesDeserializer(JavaType contentType) {
super(contentType);
this.contentType = contentType;
}
HalFormsResourcesDeserializer() {
this(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, Object.class));
}
@Override
public List<Object> deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
List<Object> result = new ArrayList<Object>();
JsonDeserializer<Object> deser = ctxt.findRootValueDeserializer(contentType);
Object object;
// links is an object, so we parse till we find its end.
while (!JsonToken.END_OBJECT.equals(jp.nextToken())) {
if (!JsonToken.FIELD_NAME.equals(jp.getCurrentToken())) {
throw new JsonParseException("Expected relation name", jp.getCurrentLocation());
}
if (JsonToken.START_ARRAY.equals(jp.nextToken())) {
while (!JsonToken.END_ARRAY.equals(jp.nextToken())) {
object = deser.deserialize(jp, ctxt);
result.add(object);
}
} else {
object = deser.deserialize(jp, ctxt);
result.add(object);
}
}
return result;
}
@Override
public JavaType getContentType() {
return this.contentType;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
if (property != null) {
JavaType vc = property.getType().getContentType();
return new HalFormsResourcesDeserializer(vc);
} else {
return new HalFormsResourcesDeserializer(ctxt.getContextualType());
}
}
}
/**
* Deserialize a {@link MediaType} embedded inside a HAL-FORMS document.
*/
static class MediaTypesDeserializer extends ContainerDeserializerBase<List<MediaType>> {
private static final long serialVersionUID = -7218376603548438390L;
public MediaTypesDeserializer() {
super(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, MediaType.class));
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase#getContentType()
*/
@Override
public JavaType getContentType() {
return null;
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase#getContentDeserializer()
*/
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.JsonDeserializer#deserialize(com.fasterxml.jackson.core.JsonParser, com.fasterxml.jackson.databind.DeserializationContext)
*/
@Override
public List<MediaType> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
return MediaType.parseMediaTypes(p.getText());
}
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import static com.fasterxml.jackson.annotation.JsonInclude.*;
import static org.springframework.hateoas.hal.Jackson2HalModule.*;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.PagedResources;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Representation of a HAL-FORMS document.
*
* @author Dietrich Schulten
* @author Greg Turnquist
*/
@Data
@Builder(builderMethodName = "halFormsDocument")
@JsonPropertyOrder({ "resource", "resources", "embedded", "links", "templates", "metadata" })
public class HalFormsDocument<T> {
@JsonUnwrapped
@JsonInclude(Include.NON_NULL)
private T resource;
@JsonIgnore
@JsonInclude(Include.NON_EMPTY)
private Collection<T> resources;
@JsonProperty("_embedded")
@JsonInclude(Include.NON_NULL)
private Map<String, Object> embedded;
@JsonProperty("page")
@JsonInclude(Include.NON_NULL)
private PagedResources.PageMetadata pageMetadata;
@Singular private List<Link> links;
@Singular private Map<String, HalFormsTemplate> templates;
HalFormsDocument(T resource, Collection<T> resources, Map<String, Object> embedded,
PagedResources.PageMetadata pageMetadata, List<Link> links, Map<String, HalFormsTemplate> templates) {
this.resource = resource;
this.resources = resources;
this.embedded = embedded;
this.pageMetadata = pageMetadata;
this.links = links;
this.templates = templates;
}
HalFormsDocument() {
this(null, null, null, null, new ArrayList<Link>(), new HashMap<String, HalFormsTemplate>());
}
@JsonProperty("_links")
@JsonInclude(Include.NON_EMPTY)
@JsonSerialize(using = HalLinkListSerializer.class)
@JsonDeserialize(using = HalLinkListDeserializer.class)
public List<Link> getLinks() {
return this.links;
}
@JsonProperty("_templates")
@JsonInclude(Include.NON_EMPTY)
public Map<String, HalFormsTemplate> getTemplates() {
return this.templates;
}
@JsonIgnore
public HalFormsTemplate getTemplate() {
return getTemplate(HalFormsTemplate.DEFAULT_KEY);
}
@JsonIgnore
public HalFormsTemplate getTemplate(String key) {
return this.templates.get(key);
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.core.JsonPathLinkDiscoverer;
/**
* HAL-FORMS based {@link JsonPathLinkDiscoverer}.
*
* @author Greg Turnquist
*/
public class HalFormsLinkDiscoverer extends JsonPathLinkDiscoverer {
public HalFormsLinkDiscoverer() {
super("$._links..['%s']..href", MediaTypes.HAL_FORMS_JSON);
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2016-2017 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
*
* http://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.hal.forms;
import java.io.IOException;
import java.util.Arrays;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* A message converter that converts any object into a HAL-FORMS document before bundling up
* as an {@link HttpOutputMessage}, or that converts any incoming {@link HttpInputMessage} into
* an object.
*
* @author Dietrich Schulten
* @author Greg Turnquist
*/
public class HalFormsMessageConverter extends AbstractHttpMessageConverter<Object> {
private final ObjectMapper objectMapper;
public HalFormsMessageConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.objectMapper.registerModule(new Jackson2HalFormsModule());
setSupportedMediaTypes(Arrays.asList(MediaTypes.HAL_FORMS_JSON));
}
/*
* (non-Javadoc)
* @see org.springframework.http.converter.AbstractHttpMessageConverter#supports(java.lang.Class)
*/
@Override
protected boolean supports(final Class<?> clazz) {
return true;
}
/*
* (non-Javadoc)
* @see org.springframework.http.converter.AbstractHttpMessageConverter#readInternal(java.lang.Class, org.springframework.http.HttpInputMessage)
*/
@Override
protected Object readInternal(final Class<? extends Object> clazz, final HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
return this.objectMapper.readValue(inputMessage.getBody(), clazz);
}
@Override
protected void writeInternal(final Object t, final HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
JsonGenerator jsonGenerator = objectMapper.getFactory().createGenerator(outputMessage.getBody(), JsonEncoding.UTF8);
// A workaround for JsonGenerators not applying serialization features
// https://github.com/FasterXML/jackson-databind/issues/12
if (objectMapper.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
jsonGenerator.useDefaultPrettyPrinter();
}
try {
objectMapper.writeValue(jsonGenerator, t);
} catch (JsonProcessingException ex) {
throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex);
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2016-2017 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
*
* http://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.hal.forms;
import lombok.AllArgsConstructor;
import lombok.Value;
import org.springframework.hateoas.AffordanceModel;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
/**
* Describe a parameter for the associated state transition in a HAL-FORMS document.
* A {@link HalFormsTemplate} may contain a list of {@link HalFormsProperty}s
*
* @see http://mamund.site44.com/misc/hal-forms/
*/
@JsonInclude(Include.NON_DEFAULT)
@Value
@AllArgsConstructor
public class HalFormsProperty {
private String name;
/**
* readOnly uses {@link Boolean} not {@literal boolean}, because if {@literal null}, the element won't be rendered
*/
private Boolean readOnly;
private String value;
private String prompt;
private String regex;
private boolean templated;
private @JsonInclude(Include.ALWAYS) boolean required;
private boolean multi;
/**
* Default constructor to support Jackson.
*/
HalFormsProperty() {
this(null, null, null, null, null, false, false, false);
}
}

View File

@@ -0,0 +1,245 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.PagedResources;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.hal.Jackson2HalModule;
import org.springframework.http.HttpMethod;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.jsontype.TypeSerializer;
import com.fasterxml.jackson.databind.ser.ContainerSerializer;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
/**
* Collection of components needed to serialize a HAL-FORMS document.
*
* @author Greg Turnquist
*/
public class HalFormsSerializers {
/**
* Serializer for {@link Resources}.
*/
static class HalFormsResourceSerializer extends ContainerSerializer<Resource<?>> implements ContextualSerializer {
private final BeanProperty property;
HalFormsResourceSerializer(BeanProperty property) {
super(Resource.class, false);
this.property = property;
}
HalFormsResourceSerializer() {
this(null);
}
@Override
public void serialize(Resource<?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
HalFormsDocument<?> doc = HalFormsDocument.<Object> halFormsDocument()
.resource(value.getContent())
.links(value.getLinks())
.templates(findTemplates(value))
.build();
provider
.findValueSerializer(HalFormsDocument.class, property)
.serialize(doc, gen, provider);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(Resource<?> resource) {
return false;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer typeSerializer) {
return null;
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new HalFormsResourceSerializer(property);
}
}
/**
* Serializer for {@link Resources}
*/
static class HalFormsResourcesSerializer extends ContainerSerializer<Resources<?>> implements ContextualSerializer {
private final BeanProperty property;
private final Jackson2HalModule.EmbeddedMapper embeddedMapper;
HalFormsResourcesSerializer(BeanProperty property, Jackson2HalModule.EmbeddedMapper embeddedMapper) {
super(Resources.class, false);
this.property = property;
this.embeddedMapper = embeddedMapper;
}
HalFormsResourcesSerializer(Jackson2HalModule.EmbeddedMapper embeddedMapper) {
this(null, embeddedMapper);
}
@Override
public void serialize(Resources<?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
Map<String, Object> embeddeds = embeddedMapper.map(value);
HalFormsDocument<?> doc;
if (value instanceof PagedResources) {
doc = HalFormsDocument.<Object> halFormsDocument()
.embedded(embeddeds)
.pageMetadata(((PagedResources) value).getMetadata())
.links(value.getLinks())
.templates(findTemplates(value))
.build();
} else {
doc = HalFormsDocument.<Object> halFormsDocument()
.embedded(embeddeds)
.pageMetadata(null)
.links(value.getLinks())
.templates(findTemplates(value))
.build();
}
provider
.findValueSerializer(HalFormsDocument.class, property)
.serialize(doc, gen, provider);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(Resources<?> resources) {
return resources.getContent().size() == 1;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer typeSerializer) {
return null;
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new HalFormsResourcesSerializer(property, embeddedMapper);
}
}
/**
* Extract template details from a {@link ResourceSupport}'s {@link Affordance}s.
*
* @param resource
* @return
*/
private static Map<String, HalFormsTemplate> findTemplates(ResourceSupport resource) {
Map<String, HalFormsTemplate> templates = new HashMap<String, HalFormsTemplate>();
if (resource.hasLink(Link.REL_SELF)) {
for (Affordance affordance : resource.getLink(Link.REL_SELF).map(Link::getAffordances).orElse(Collections.emptyList())) {
HalFormsAffordanceModel model =
(HalFormsAffordanceModel) affordance.getAffordanceModel(MediaTypes.HAL_FORMS_JSON);
if (!affordance.getHttpMethod().equals(HttpMethod.GET.toString())) {
validate(resource, affordance, model);
HalFormsTemplate template = new HalFormsTemplate();
template.setHttpMethod(HttpMethod.valueOf(affordance.getHttpMethod()));
template.setProperties(model.getProperties());
/**
* First template in HAL-FORMS is "default".
*/
if (templates.isEmpty()) {
templates.put("default", template);
} else {
templates.put(affordance.getName(), template);
}
}
}
}
return templates;
}
/**
* Verify that the resource's self link and the affordance's URI have the same relative path.
* @param resource
* @param affordance
* @param model
*/
private static void validate(ResourceSupport resource, Affordance affordance, HalFormsAffordanceModel model) {
try {
Optional<Link> selfLink = resource.getLink(Link.REL_SELF);
URI selfLinkUri = new URI(selfLink.map(link -> link.expand().getHref()).orElse(""));
if (!selfLinkUri.getPath().equals(model.getPath())) {
throw new IllegalStateException("Affordance's URI " + model.getPath() + " doesn't match self link " + selfLinkUri.getPath() + " as expected in HAL-FORMS");
}
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2016-2017 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
*
* http://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.hal.forms;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.hateoas.hal.forms.HalFormsDeserializers.MediaTypesDeserializer;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Value object for a HAL-FORMS template. Describes the available state transition details.
*
* @author Dietrich Schulten
* @author Greg Turnquist
* @see https://rwcbook.github.io/hal-forms/#_the_code__templates_code_element
*/
@Data
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
@JsonPropertyOrder({ "title", "method", "contentType", "properties" })
@JsonIgnoreProperties({ "key" })
public class HalFormsTemplate {
public static final String DEFAULT_KEY = "default";
private @JsonIgnore String key;
private List<HalFormsProperty> properties = new ArrayList<HalFormsProperty>();
private String title;
private @JsonIgnore HttpMethod httpMethod;
private List<MediaType> contentType;
/**
* Configure a HAL-FORMS template with a key value.
* @param key
*/
public HalFormsTemplate(String key) {
this.key = key;
}
/**
* A HAL-FORMS template with no name is dubbed the <a href="https://rwcbook.github.io/hal-forms/#_the_code__templates_code_element">"default" template</a>.
*/
public HalFormsTemplate() {
this(HalFormsTemplate.DEFAULT_KEY);
}
public String getContentType() {
return StringUtils.collectionToCommaDelimitedString(contentType);
}
@JsonDeserialize(using = MediaTypesDeserializer.class)
public void setContentType(List<MediaType> contentType) {
this.contentType = contentType;
}
public String getMethod() {
return this.httpMethod == null ? null : this.httpMethod.toString().toLowerCase();
}
public void setMethod(String method) {
this.httpMethod = HttpMethod.valueOf(method.toUpperCase());
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2016-2017 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
*
* http://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.hal.forms;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Configure a HAL-FORMS {@link HttpMessageConverter}.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
@Configuration
public class HalFormsWebMvcConfigurer extends WebMvcConfigurerAdapter {
/*
* (non-Javadoc)
* @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#configureMessageConverters(java.util.List)
*/
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new HalFormsMessageConverter(new ObjectMapper()));
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright 2016-2017 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
*
* http://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.hal.forms;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
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;
import org.springframework.hateoas.hal.Jackson2HalModule.EmbeddedMapper;
import org.springframework.hateoas.hal.Jackson2HalModule.HalHandlerInstantiator;
import org.springframework.hateoas.hal.Jackson2HalModule.HalLinkListSerializer;
import org.springframework.hateoas.hal.LinkMixin;
import org.springframework.hateoas.hal.ResourceSupportMixin;
import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourcesSerializer;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.KeyDeserializer;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.jsontype.TypeIdResolver;
import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder;
import com.fasterxml.jackson.databind.module.SimpleModule;
/**
* Serialize/Deserialize all the parts of HAL-FORMS documents using Jackson.
*
* @author Dietrich Schulten
* @author Greg Turnquist
*/
public class Jackson2HalFormsModule extends SimpleModule {
private static final long serialVersionUID = -4496351128468451196L;
public Jackson2HalFormsModule() {
super("hal-forms-module", new Version(1, 0, 0, null, "org.springframework.hateoas", "spring-hateoas"));
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);
}
/**
* Create new HAL-FORMS serializers based on the context.
*/
public static class HalFormsHandlerInstantiator extends HalHandlerInstantiator {
private final Map<Class<?>, Object> serializers = new HashMap<Class<?>, Object>();
public HalFormsHandlerInstantiator(RelProvider resolver, CurieProvider curieProvider,
MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections,
HalFormsConfiguration halFormsConfiguration) {
super(resolver, curieProvider, messageSource, enforceEmbeddedCollections, halFormsConfiguration.toHalConfiguration());
EmbeddedMapper mapper = new EmbeddedMapper(resolver, curieProvider, enforceEmbeddedCollections);
this.serializers.put(HalFormsResourcesSerializer.class, new HalFormsResourcesSerializer(mapper));
this.serializers.put(HalLinkListSerializer.class,
new HalLinkListSerializer(curieProvider, mapper, messageSource, halFormsConfiguration.toHalConfiguration()));
}
public HalFormsHandlerInstantiator(RelProvider relProvider, CurieProvider curieProvider,
MessageSourceAccessor messageSource, boolean enforceEmbeddedCollections,
AutowireCapableBeanFactory beanFactory) {
this(relProvider, curieProvider, messageSource, enforceEmbeddedCollections, beanFactory.getBean(HalFormsConfiguration.class));
}
private Object findInstance(Class<?> type) {
return this.serializers.get(type);
}
/*
* (non-Javadoc)
*
* @see
* com.fasterxml.jackson.databind.cfg.HandlerInstantiator#deserializerInstance(com.fasterxml.jackson.databind.
* DeserializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class)
*/
@Override
public JsonDeserializer<?> deserializerInstance(DeserializationConfig config, Annotated annotated,
Class<?> deserClass) {
Object jsonDeser = findInstance(deserClass);
return jsonDeser != null ? (JsonDeserializer<?>) jsonDeser
: super.deserializerInstance(config, annotated, deserClass);
}
/*
* (non-Javadoc)
*
* @see com.fasterxml.jackson.databind.cfg.HandlerInstantiator#keyDeserializerInstance(com.fasterxml.jackson.
* databind. DeserializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class)
*/
@Override
public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated,
Class<?> keyDeserClass) {
Object keyDeser = findInstance(keyDeserClass);
return keyDeser != null ? (KeyDeserializer) keyDeser
: super.keyDeserializerInstance(config, annotated, keyDeserClass);
}
/*
* (non-Javadoc)
*
* @see
* com.fasterxml.jackson.databind.cfg.HandlerInstantiator#serializerInstance(com.fasterxml.jackson.databind.
* SerializationConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class)
*/
@Override
public JsonSerializer<?> serializerInstance(SerializationConfig config, Annotated annotated, Class<?> serClass) {
Object jsonSer = findInstance(serClass);
return jsonSer != null ? (JsonSerializer<?>) jsonSer : super.serializerInstance(config, annotated, serClass);
}
/*
* (non-Javadoc)
*
* @see
* com.fasterxml.jackson.databind.cfg.HandlerInstantiator#typeResolverBuilderInstance(com.fasterxml.jackson.
* databind .cfg.MapperConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class)
*/
@Override
public TypeResolverBuilder<?> typeResolverBuilderInstance(MapperConfig<?> config, Annotated annotated,
Class<?> builderClass) {
Object builder = findInstance(builderClass);
return builder != null ? (TypeResolverBuilder<?>) builder
: super.typeResolverBuilderInstance(config, annotated, builderClass);
}
/*
* (non-Javadoc)
*
* @see
* com.fasterxml.jackson.databind.cfg.HandlerInstantiator#typeIdResolverInstance(com.fasterxml.jackson.databind.
* cfg. MapperConfig, com.fasterxml.jackson.databind.introspect.Annotated, java.lang.Class)
*/
@Override
public TypeIdResolver typeIdResolverInstance(MapperConfig<?> config, Annotated annotated, Class<?> resolverClass) {
Object resolver = findInstance(resolverClass);
return resolver != null ? (TypeIdResolver) resolver
: super.typeIdResolverInstance(config, annotated, resolverClass);
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import org.springframework.hateoas.PagedResources;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Custom mixin to render {@link org.springframework.hateoas.PagedResources.PageMetadata} in HAL.
*
* @author Greg Turnquist
*/
abstract class PagedResourcesMixin<T> extends PagedResources<T> {
@Override
@JsonProperty("page")
@JsonInclude(Include.NON_EMPTY)
public PageMetadata getMetadata() {
return super.getMetadata();
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourceSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* @author Greg Turnquist
*/
@JsonSerialize(using = HalFormsResourceSerializer.class)
abstract class ResourceMixin {
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2017 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
*
* http://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.hal.forms;
import java.util.Collection;
import javax.xml.bind.annotation.XmlElement;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.hal.forms.HalFormsDeserializers.HalFormsResourcesDeserializer;
import org.springframework.hateoas.hal.forms.HalFormsSerializers.HalFormsResourcesSerializer;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* @author Greg Turnquist
*/
@JsonSerialize(using = HalFormsResourcesSerializer.class)
abstract class ResourcesMixin<T> extends Resources<T> {
@Override
@XmlElement(name = "embedded")
@JsonProperty("_embedded")
@JsonInclude(Include.NON_EMPTY)
@JsonDeserialize(using = HalFormsResourcesDeserializer.class)
public abstract Collection<T> getContent();
}