#482 - Add support for Collection+JSON mediatype.

Introduce support for media type application/vnd.collection+json. Collection+JSON doesn't allow metadata at the top, so paging data can't be covered, however, everything else fits.

Also moved a little bit more into Affordance and SpringMvcAffordance to avoid using Spring MVC annotations directly in a given mediatype's AffordanceModel.

Refactored bits of HAL-FORMS to reuse the new PropertyUtils, ensuring Jackson ignore annotations are taken into consideration. Also added MockMVC tests to show HAL-FORMS and Collection+JSON working together, against the same controller.
This commit is contained in:
Greg Turnquist
2015-08-26 15:09:22 -05:00
committed by Oliver Gierke
parent 3af698d928
commit d6e02857f1
74 changed files with 4632 additions and 204 deletions

View File

@@ -15,6 +15,9 @@
*/
package org.springframework.hateoas;
import java.util.List;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
@@ -47,4 +50,17 @@ public interface Affordance {
* @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();
}

View File

@@ -49,7 +49,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
*/
@XmlType(name = "link", namespace = Link.ATOM_NAMESPACE)
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties("templated")
@JsonIgnoreProperties(value = "templated", ignoreUnknown = true)
@AllArgsConstructor(access = AccessLevel.PACKAGE)
@Getter
@EqualsAndHashCode(of = { "rel", "href", "hreflang", "media", "title", "deprecation", "affordances" })

View File

@@ -67,4 +67,13 @@ public class MediaTypes {
*/
public static final MediaType HAL_FORMS_JSON = MediaType.parseMediaType(HAL_FORMS_JSON_VALUE);
/**
* A String equivalent of {@link MediaTypes#COLLECTION_JSON}.
*/
public static final String COLLECTION_JSON_VALUE = "application/vnd.collection+json";
/**
* Public constant media type for {@code application/vnd.collection+json}.
*/
public static final MediaType COLLECTION_JSON = MediaType.valueOf(COLLECTION_JSON_VALUE);
}

View File

@@ -0,0 +1,33 @@
/*
* 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.
* 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;
import lombok.Data;
import lombok.RequiredArgsConstructor;
/**
* Web framework-neutral representation of a web request's query parameter (http://example.com?name=foo).
*
* @author Greg Turnquist
*/
@Data
@RequiredArgsConstructor
public class QueryParameter {
private final String name;
private final boolean required;
private final String value;
}

View File

@@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonUnwrapped;
* A simple {@link Resource} wrapping a domain object and adding links to it.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
@XmlRootElement
public class Resource<T> extends ResourceSupport {
@@ -38,7 +39,7 @@ public class Resource<T> extends ResourceSupport {
/**
* Creates an empty {@link Resource}.
*/
Resource() {
protected Resource() {
this.content = null;
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2015 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.collectionjson;
import lombok.AccessLevel;
import lombok.Value;
import lombok.experimental.Wither;
import java.util.List;
import org.springframework.hateoas.Link;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Representation of the "collection" part of a Collection+JSON document.
*
* @author Greg Turnquist
*/
@Value
@Wither(AccessLevel.PACKAGE)
class CollectionJson<T> {
private String version;
private String href;
@JsonInclude(Include.NON_EMPTY)
private List<Link> links;
@JsonInclude(Include.NON_EMPTY)
private List<CollectionJsonItem<T>> items;
@JsonInclude(Include.NON_EMPTY)
private List<CollectionJsonQuery> queries;
@JsonInclude(Include.NON_NULL)
private CollectionJsonTemplate template;
@JsonInclude(Include.NON_NULL)
private CollectionJsonError error;
@JsonCreator
CollectionJson(@JsonProperty("version") String version, @JsonProperty("href") String href,
@JsonProperty("links") List<Link> links, @JsonProperty("items") List<CollectionJsonItem<T>> items,
@JsonProperty("queries") List<CollectionJsonQuery> queries,
@JsonProperty("template") CollectionJsonTemplate template,
@JsonProperty("error") CollectionJsonError error) {
this.version = version;
this.href = href;
this.links = links;
this.items = items;
this.queries = queries;
this.template = template;
this.error = error;
}
CollectionJson() {
this("1.0", null, null, null, null, null, null);
}
}

View File

@@ -0,0 +1,112 @@
/*
* 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.
* 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.collectionjson;
import lombok.Getter;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
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.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 {
private static final List<HttpMethod> METHODS_FOR_INPUT_DETECTION = Arrays.asList(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;
CollectionJsonAffordanceModel(Affordance affordance, UriComponents components) {
this.affordance = affordance;
this.components = components;
this.inputProperties = determineAffordanceInputs();
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
*/
private List<CollectionJsonData> determineQueryProperties() {
if (!isHttpGetMethod()) {
return Collections.emptyList();
}
return this.affordance.getQueryMethodParameters().stream()
.map(queryProperty -> new CollectionJsonData().withName(queryProperty.getName()).withValue(""))
.collect(Collectors.toList());
}
private boolean isHttpGetMethod() {
return this.affordance.getHttpMethod().equals(HttpMethod.GET);
}
/**
* 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.
*/
private List<CollectionJsonData> determineAffordanceInputs() {
if (!METHODS_FOR_INPUT_DETECTION.contains(affordance.getHttpMethod())) {
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());
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.
* 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.collectionjson;
import lombok.Getter;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.core.AffordanceModelFactory;
import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation;
import org.springframework.http.MediaType;
import org.springframework.web.util.UriComponents;
/**
* @author Greg Turnquist
*/
class CollectionJsonAffordanceModelFactory implements AffordanceModelFactory {
private final @Getter MediaType mediaType = MediaTypes.COLLECTION_JSON;
/**
* Look up the {@link AffordanceModel} for this factory.
*
* @param affordance
* @param invocationValue
* @param components
* @return
*/
@Override
public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) {
return new CollectionJsonAffordanceModel(affordance, components);
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.collectionjson;
import lombok.AccessLevel;
import lombok.Value;
import lombok.experimental.Wither;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Greg Turnquist
*/
@Value
@Wither(AccessLevel.PACKAGE)
@JsonIgnoreProperties()
class CollectionJsonData {
@JsonInclude(Include.NON_NULL) //
private String name;
@JsonInclude(Include.NON_NULL) //
private Object value;
@JsonInclude(Include.NON_NULL) //
private String prompt;
@JsonCreator
CollectionJsonData(@JsonProperty("name") String name, @JsonProperty("value") Object value,
@JsonProperty("prompt") String prompt) {
this.name = name;
this.value = value;
this.prompt = prompt;
}
CollectionJsonData() {
this(null, null, null);
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2015 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.collectionjson;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.experimental.Wither;
import java.util.List;
import org.springframework.hateoas.Link;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Represents an entire Collection+JSON document.
*
* @author Greg Turnquist
*/
@Value
@Wither(AccessLevel.PACKAGE)
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class CollectionJsonDocument<T> {
private CollectionJson<T> collection;
@JsonCreator
CollectionJsonDocument(@JsonProperty("version") String version, @JsonProperty("href") String href,
@JsonProperty("links") List<Link> links, @JsonProperty("items") List<CollectionJsonItem<T>> items,
@JsonProperty("queries") List<CollectionJsonQuery> queries,
@JsonProperty("template") CollectionJsonTemplate template,
@JsonProperty("error") CollectionJsonError error) {
this.collection = new CollectionJson(version, href, links, items, queries, template, error);
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.
* 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.collectionjson;
import lombok.AccessLevel;
import lombok.Value;
import lombok.experimental.Wither;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Greg Turnquist
*/
@Value
@Wither(AccessLevel.PACKAGE)
class CollectionJsonError {
private String title;
private String code;
private String message;
@JsonCreator
CollectionJsonError(@JsonProperty("title") String title, @JsonProperty("code") String code,
@JsonProperty("message") String message) {
this.title = title;
this.code = code;
this.message = message;
}
CollectionJsonError() {
this(null, null, null);
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2015 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.collectionjson;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.experimental.Wither;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.support.PropertyUtils;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JavaType;
/**
* Representation of an "item" in a Collection+JSON document.
*
* @author Greg Turnquist
*/
@Value
@Wither(AccessLevel.PACKAGE)
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class CollectionJsonItem<T> {
private String href;
private List<CollectionJsonData> data;
@JsonInclude(Include.NON_EMPTY)
private List<Link> links;
@Getter(onMethod = @__({@JsonIgnore}), value = AccessLevel.PRIVATE)
private T rawData;
@JsonCreator
CollectionJsonItem(@JsonProperty("href") String href, @JsonProperty("data") List<CollectionJsonData> data,
@JsonProperty("links") List<Link> links) {
this.href = href;
this.data = data;
this.links = links;
this.rawData = null;
}
CollectionJsonItem() {
this(null, null, null);
}
/**
* Simple scalar types that can be encoded by value, not type.
*/
private final static HashSet<Class<?>> PRIMITIVE_TYPES = new HashSet<Class<?>>() {{
add(String.class);
}};
/**
* Transform a domain object into a collection of {@link CollectionJsonData} objects to serialize properly.
*
* @return
*/
public List<CollectionJsonData> getData() {
if (this.data != null) {
return this.data;
}
if (PRIMITIVE_TYPES.contains(this.rawData.getClass())) {
return Collections.singletonList(new CollectionJsonData().withValue(this.rawData));
}
return PropertyUtils.findProperties(this.rawData).entrySet().stream()
.map(entry -> new CollectionJsonData()
.withName(entry.getKey())
.withValue(entry.getValue()))
.collect(Collectors.toList());
}
/**
* Generate an object used the deserialized properties and the provided type from the deserializer.
*
* @param javaType - type of the object to create
* @return
*/
public Object toRawData(JavaType javaType) {
if (PRIMITIVE_TYPES.contains(javaType.getRawClass())) {
return this.data.get(0).getValue();
}
return PropertyUtils.createObjectFromProperties(javaType.getRawClass(), //
this.data.stream()
.collect(Collectors.toMap(
CollectionJsonData::getName,
CollectionJsonData::getValue)));
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2015 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.collectionjson;
import java.io.InputStream;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkDiscoverer;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.core.JsonPathLinkDiscoverer;
/**
* {@link LinkDiscoverer} implementation based on JSON Collection link structure.
*
* NOTE: Since links can appear in two different places in a Collection+JSON document, this discoverer
* uses two.
*
* @author Greg Turnquist
*/
public class CollectionJsonLinkDiscoverer extends JsonPathLinkDiscoverer {
private final CollectionJsonSelfLinkDiscoverer selfLinkDiscoverer;
public CollectionJsonLinkDiscoverer() {
super("$.collection..links..[?(@.rel == '%s')].href", MediaTypes.COLLECTION_JSON);
this.selfLinkDiscoverer = new CollectionJsonSelfLinkDiscoverer();
}
@Override
public Link findLinkWithRel(String rel, String representation) {
if (rel.equals(Link.REL_SELF)) {
return findSelfLink(representation);
} else {
return super.findLinkWithRel(rel, representation);
}
}
@Override
public Link findLinkWithRel(String rel, InputStream representation) {
if (rel.equals(Link.REL_SELF)) {
return findSelfLink(representation);
} else {
return super.findLinkWithRel(rel, representation);
}
}
@Override
public List<Link> findLinksWithRel(String rel, String representation) {
if (rel.equals(Link.REL_SELF)) {
return addSelfLink(super.findLinksWithRel(rel, representation), representation);
} else {
return super.findLinksWithRel(rel, representation);
}
}
@Override
public List<Link> findLinksWithRel(String rel, InputStream representation) {
if (rel.equals(Link.REL_SELF)) {
return addSelfLink(super.findLinksWithRel(rel, representation), representation);
} else {
return super.findLinksWithRel(rel, representation);
}
}
//
// Internal methods to support discovering the "self" link found at "$.collection.href".
//
private Link findSelfLink(String representation) {
return this.selfLinkDiscoverer.findLinkWithRel(Link.REL_SELF, representation);
}
private Link findSelfLink(InputStream representation) {
return this.selfLinkDiscoverer.findLinkWithRel(Link.REL_SELF, representation);
}
private List<Link> addSelfLink(List<Link> links, String representation) {
return Stream.concat(
Stream.of(findSelfLink(representation)),
links.stream()
)
.collect(Collectors.toList());
}
private List<Link> addSelfLink(List<Link> links, InputStream representation) {
return Stream.concat(
Stream.of(findSelfLink(representation)),
links.stream()
)
.collect(Collectors.toList());
}
/**
* {@link JsonPathLinkDiscoverer} that looks for the non-parameterized {@literal collection.href} link.
*/
private static class CollectionJsonSelfLinkDiscoverer extends JsonPathLinkDiscoverer {
CollectionJsonSelfLinkDiscoverer() {
super("$.collection.href", MediaTypes.COLLECTION_JSON);
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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.collectionjson;
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 Collection+JSON document before bundling up as an
* {@link HttpOutputMessage}, or that converts any incoming {@link HttpInputMessage} into an object.
*
* @author Greg Turnquist
*/
public class CollectionJsonMessageConverter extends AbstractHttpMessageConverter<Object> {
private final ObjectMapper objectMapper;
public CollectionJsonMessageConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.objectMapper.registerModule(new Jackson2CollectionJsonModule());
setSupportedMediaTypes(Arrays.asList(MediaTypes.COLLECTION_JSON));
}
@Override
protected boolean supports(Class<?> clazz) {
return true;
}
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
return this.objectMapper.readValue(inputMessage.getBody(), clazz);
}
@Override
protected void writeInternal(Object t, 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,61 @@
/*
* 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.
* 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.collectionjson;
import static com.fasterxml.jackson.annotation.JsonInclude.*;
import lombok.Value;
import lombok.experimental.Wither;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Greg Turnquist
*/
@Value
@Wither
class CollectionJsonQuery {
@JsonInclude(Include.NON_NULL)
private String rel;
@JsonInclude(Include.NON_NULL)
private String href;
@JsonInclude(Include.NON_NULL)
private String prompt;
@JsonInclude(Include.NON_EMPTY)
private List<CollectionJsonData> data;
@JsonCreator
CollectionJsonQuery(@JsonProperty("rel") String rel, @JsonProperty("href") String href,
@JsonProperty("prompt") String prompt, @JsonProperty("data") List<CollectionJsonData> data) {
this.rel = rel;
this.href = href;
this.prompt = prompt;
this.data = data;
}
CollectionJsonQuery() {
this(null, null, null, null);
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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.
* 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.collectionjson;
import lombok.Value;
import lombok.experimental.Wither;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Greg Turnquist
*/
@Value
@Wither
class CollectionJsonTemplate {
private List<CollectionJsonData> data;
@JsonCreator
CollectionJsonTemplate(@JsonProperty("data") List<CollectionJsonData> data) {
this.data = data;
}
CollectionJsonTemplate() {
this(null);
}
}

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.collectionjson;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* @author Greg Turnquist
*/
@Configuration
public class CollectionJsonWebMvcConfigurer implements WebMvcConfigurer {
/*
* (non-Javadoc)
* @see org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter#configureMessageConverters(java.util.List)
*/
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new CollectionJsonMessageConverter(new ObjectMapper()));
}
}

View File

@@ -0,0 +1,904 @@
/*
* Copyright 2015 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.collectionjson;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
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.beans.BeanUtils;
import org.springframework.context.support.MessageSourceAccessor;
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.support.JacksonHelper;
import org.springframework.hateoas.support.PropertyUtils;
import org.springframework.http.HttpMethod;
import org.springframework.util.ClassUtils;
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.cfg.HandlerInstantiator;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import com.fasterxml.jackson.databind.deser.std.ContainerDeserializerBase;
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.jsontype.TypeSerializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.ContainerSerializer;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.fasterxml.jackson.databind.type.TypeFactory;
/**
* Jackson 2 module implementation to render {@link Resources}, {@link Resource}, and {@link ResourceSupport}
* instances in Collection+JSON compatible JSON.
*
* @author Greg Turnquist
*/
public class Jackson2CollectionJsonModule extends SimpleModule {
public Jackson2CollectionJsonModule() {
super("collection-json-module", new Version(1, 0, 0, null, "org.springframework.hateoas", "spring-hateoas"));
setMixInAnnotation(ResourceSupport.class, ResourceSupportMixin.class);
setMixInAnnotation(Resource.class, ResourceMixin.class);
setMixInAnnotation(Resources.class, ResourcesMixin.class);
setMixInAnnotation(PagedResources.class, PagedResourcesMixin.class);
}
/**
* Custom {@link JsonSerializer} to render Link instances in JSON Collection compatible JSON.
*
* @author Alexander Baetz
* @author Oliver Gierke
*/
static class CollectionJsonLinkListSerializer extends ContainerSerializer<List<Link>> implements ContextualSerializer {
private final BeanProperty property;
private final MessageSourceAccessor messageSource;
CollectionJsonLinkListSerializer(MessageSourceAccessor messageSource) {
this(null, messageSource);
}
CollectionJsonLinkListSerializer(BeanProperty property, MessageSourceAccessor messageSource) {
super(List.class, false);
this.property = property;
this.messageSource = messageSource;
}
@Override
public void serialize(List<Link> value, JsonGenerator jgen, SerializerProvider provider)
throws IOException {
ResourceSupport resource = new ResourceSupport();
resource.add(value);
CollectionJson<?> collectionJson = new CollectionJson()
.withVersion("1.0")
.withHref(resource.getRequiredLink(Link.REL_SELF).expand().getHref())
.withLinks(withoutSelfLink(value))
.withItems(Collections.EMPTY_LIST);
provider
.findValueSerializer(CollectionJson.class, property)
.serialize(collectionJson, jgen, provider);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new CollectionJsonLinkListSerializer(property, messageSource);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean isEmpty(List<Link> value) {
return value.isEmpty();
}
@Override
public boolean hasSingleElement(List<Link> value) {
return value.size() == 1;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
}
static class CollectionJsonResourceSupportSerializer extends ContainerSerializer<ResourceSupport> implements ContextualSerializer {
private final BeanProperty property;
CollectionJsonResourceSupportSerializer() {
this(null);
}
CollectionJsonResourceSupportSerializer(BeanProperty property) {
super(ResourceSupport.class, false);
this.property = property;
}
@Override
public void serialize(ResourceSupport value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
String href = value.getRequiredLink(Link.REL_SELF).getHref();
CollectionJson<?> collectionJson = new CollectionJson()
.withVersion("1.0")
.withHref(href)
.withLinks(withoutSelfLink(value.getLinks()))
.withQueries(findQueries(value))
.withTemplate(findTemplate(value));
CollectionJsonItem item = new CollectionJsonItem()
.withHref(href)
.withLinks(withoutSelfLink(value.getLinks()))
.withRawData(value);
if (!item.getData().isEmpty()) {
collectionJson = collectionJson.withItems(Collections.singletonList(item));
}
CollectionJsonDocument<?> doc = new CollectionJsonDocument<>(collectionJson);
provider
.findValueSerializer(CollectionJsonDocument.class, property)
.serialize(doc, jgen, provider);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new CollectionJsonResourceSupportSerializer(property);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(ResourceSupport value) {
return true;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
}
static class CollectionJsonResourceSerializer extends ContainerSerializer<Resource<?>> implements ContextualSerializer {
private final BeanProperty property;
CollectionJsonResourceSerializer() {
this(null);
}
CollectionJsonResourceSerializer(BeanProperty property) {
super(Resource.class, false);
this.property = property;
}
@Override
public void serialize(Resource<?> value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
String href = value.getRequiredLink(Link.REL_SELF).getHref();
CollectionJson<?> collectionJson = new CollectionJson()
.withVersion("1.0")
.withHref(href)
.withLinks(withoutSelfLink(value.getLinks()))
.withItems(Collections.singletonList(new CollectionJsonItem<>()
.withHref(href)
.withLinks(withoutSelfLink(value.getLinks()))
.withRawData(value.getContent())))
.withQueries(findQueries(value))
.withTemplate(findTemplate(value));
CollectionJsonDocument<?> doc = new CollectionJsonDocument<>(collectionJson);
provider
.findValueSerializer(CollectionJsonDocument.class, property)
.serialize(doc, jgen, provider);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new CollectionJsonResourceSerializer(property);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean hasSingleElement(Resource<?> value) {
return true;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
}
static class CollectionJsonResourcesSerializer extends ContainerSerializer<Resources<?>> implements ContextualSerializer {
private final BeanProperty property;
CollectionJsonResourcesSerializer() {
this(null);
}
CollectionJsonResourcesSerializer(BeanProperty property) {
super(Resources.class, false);
this.property = property;
}
@Override
public void serialize(Resources<?> value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
CollectionJson<?> collectionJson = new CollectionJson()
.withVersion("1.0")
.withHref(value.getRequiredLink(Link.REL_SELF).getHref())
.withLinks(withoutSelfLink(value.getLinks()))
.withItems(resourcesToCollectionJsonItems(value))
.withQueries(findQueries(value))
.withTemplate(findTemplate(value));
CollectionJsonDocument<?> doc = new CollectionJsonDocument<>(collectionJson);
provider
.findValueSerializer(CollectionJsonDocument.class, property)
.serialize(doc, jgen, provider);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new CollectionJsonResourcesSerializer(property);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean isEmpty(Resources<?> value) {
return value.getContent().size() == 0;
}
@Override
public boolean hasSingleElement(Resources<?> value) {
return value.getContent().size() == 1;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
}
static class CollectionJsonPagedResourcesSerializer extends ContainerSerializer<PagedResources<?>> implements ContextualSerializer {
private final BeanProperty property;
CollectionJsonPagedResourcesSerializer() {
this(null);
}
CollectionJsonPagedResourcesSerializer(BeanProperty property) {
super(Resources.class, false);
this.property = property;
}
@Override
public void serialize(PagedResources<?> value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonGenerationException {
CollectionJson<?> collectionJson = new CollectionJson()
.withVersion("1.0")
.withHref(value.getRequiredLink(Link.REL_SELF).getHref())
.withLinks(withoutSelfLink(value.getLinks()))
.withItems(resourcesToCollectionJsonItems(value))
.withQueries(findQueries(value))
.withTemplate(findTemplate(value));
CollectionJsonDocument<?> doc = new CollectionJsonDocument<>(collectionJson);
provider
.findValueSerializer(CollectionJsonDocument.class, property)
.serialize(doc, jgen, provider);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
return new CollectionJsonPagedResourcesSerializer(property);
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonSerializer<?> getContentSerializer() {
return null;
}
@Override
public boolean isEmpty(PagedResources<?> value) {
return value.getContent().size() == 0;
}
@Override
public boolean hasSingleElement(PagedResources<?> value) {
return value.getContent().size() == 1;
}
@Override
protected ContainerSerializer<?> _withValueTypeSerializer(TypeSerializer vts) {
return null;
}
}
static class CollectionJsonLinkListDeserializer extends ContainerDeserializerBase<List<Link>> {
CollectionJsonLinkListDeserializer() {
super(TypeFactory.defaultInstance().constructCollectionLikeType(List.class, Link.class));
}
@Override
public JavaType getContentType() {
return null;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public List<Link> deserialize(JsonParser jp, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
CollectionJsonDocument<?> document = jp.getCodec().readValue(jp, CollectionJsonDocument.class);
return potentiallyAddSelfLink(document.getCollection().getLinks(), document.getCollection().getHref());
}
}
static class CollectionJsonResourceSupportDeserializer extends ContainerDeserializerBase<ResourceSupport>
implements ContextualDeserializer {
private final JavaType contentType;
CollectionJsonResourceSupportDeserializer() {
this(TypeFactory.defaultInstance().constructType(ResourceSupport.class));
}
CollectionJsonResourceSupportDeserializer(JavaType contentType) {
super(contentType);
this.contentType = contentType;
}
@Override
public JavaType getContentType() {
return this.contentType;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public ResourceSupport deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
JavaType rootType = ctxt.getTypeFactory().constructSimpleType(Object.class, new JavaType[]{});
JavaType wrappedType = ctxt.getTypeFactory().constructParametricType(CollectionJsonDocument.class, rootType);
CollectionJsonDocument<?> document = jp.getCodec().readValue(jp, wrappedType);
List<? extends CollectionJsonItem<?>> items = Optional.ofNullable(document.getCollection().getItems()).orElse(new ArrayList<>());
List<Link> links = Optional.ofNullable(document.getCollection().getLinks()).orElse(new ArrayList<>());
if (items.size() == 0) {
if (document.getCollection().getTemplate() != null) {
Map<String, Object> properties = document.getCollection().getTemplate().getData().stream()
.collect(Collectors.toMap(CollectionJsonData::getName, CollectionJsonData::getValue));
ResourceSupport obj = (ResourceSupport) PropertyUtils.createObjectFromProperties(this.contentType.getRawClass(), properties);
obj.add(potentiallyAddSelfLink(links, document.getCollection().getHref()));
return obj;
} else {
ResourceSupport resource = new ResourceSupport();
resource.add(potentiallyAddSelfLink(links, document.getCollection().getHref()));
return resource;
}
} else {
items.stream()
.flatMap(item -> Optional.ofNullable(item.getLinks())
.map(Collection::stream)
.orElse(Stream.empty()))
.forEach(link -> {
if (!links.contains(link))
links.add(link);
});
ResourceSupport resource = (ResourceSupport) items.get(0).toRawData(this.contentType);
resource.add(potentiallyAddSelfLink(links, items.get(0).getHref()));
return resource;
}
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt,
BeanProperty property) throws JsonMappingException {
if (property != null) {
return new CollectionJsonResourceSupportDeserializer(property.getType().getContentType());
} else {
return new CollectionJsonResourceSupportDeserializer(ctxt.getContextualType());
}
}
}
static class CollectionJsonResourceDeserializer extends ContainerDeserializerBase<Resource<?>>
implements ContextualDeserializer {
private final JavaType contentType;
CollectionJsonResourceDeserializer() {
this(TypeFactory.defaultInstance().constructType(CollectionJson.class));
}
CollectionJsonResourceDeserializer(JavaType contentType) {
super(contentType);
this.contentType = contentType;
}
@Override
public JavaType getContentType() {
return this.contentType;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public Resource<?> deserialize(JsonParser jp, DeserializationContext ctxt)
throws IOException {
JavaType rootType = JacksonHelper.findRootType(this.contentType);
JavaType wrappedType = ctxt.getTypeFactory().constructParametricType(CollectionJsonDocument.class, rootType);
CollectionJsonDocument<?> document = jp.getCodec().readValue(jp, wrappedType);
List<? extends CollectionJsonItem<?>> items = Optional.ofNullable(document.getCollection().getItems()).orElse(new ArrayList<>());
List<Link> links = Optional.ofNullable(document.getCollection().getLinks()).orElse(new ArrayList<>());
if (items.size() == 0 && document.getCollection().getTemplate() != null) {
Map<String, Object> properties = document.getCollection().getTemplate().getData().stream()
.collect(Collectors.toMap(CollectionJsonData::getName, CollectionJsonData::getValue));
Object obj = PropertyUtils.createObjectFromProperties(rootType.getRawClass(), properties);
return new Resource<>(obj, potentiallyAddSelfLink(links, document.getCollection().getHref()));
} else {
items.stream()
.flatMap(item -> Optional.ofNullable(item.getLinks())
.map(Collection::stream)
.orElse(Stream.empty()))
.forEach(link -> {
if (!links.contains(link))
links.add(link);
});
return new Resource<>(items.get(0).toRawData(rootType),
potentiallyAddSelfLink(links, items.get(0).getHref()));
}
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt,
BeanProperty property) throws JsonMappingException {
if (property != null) {
return new CollectionJsonResourceDeserializer(property.getType().getContentType());
} else {
return new CollectionJsonResourceDeserializer(ctxt.getContextualType());
}
}
}
static class CollectionJsonResourcesDeserializer extends ContainerDeserializerBase<Resources>
implements ContextualDeserializer {
private final JavaType contentType;
CollectionJsonResourcesDeserializer() {
this(TypeFactory.defaultInstance().constructType(CollectionJson.class));
}
CollectionJsonResourcesDeserializer(JavaType contentType) {
super(contentType);
this.contentType = contentType;
}
@Override
public JavaType getContentType() {
return this.contentType;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public Resources deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
JavaType rootType = JacksonHelper.findRootType(this.contentType);
JavaType wrappedType = ctxt.getTypeFactory().constructParametricType(CollectionJsonDocument.class, rootType);
CollectionJsonDocument<?> document = jp.getCodec().readValue(jp, wrappedType);
List<Object> contentList = new ArrayList<Object>();
if (document.getCollection().getItems() != null) {
for (CollectionJsonItem<?> item : document.getCollection().getItems()) {
Object data = item.toRawData(rootType);
if (this.contentType.hasGenericTypes()) {
if (isResource(this.contentType)) {
contentList.add(new Resource<>(data, potentiallyAddSelfLink(item.getLinks(), item.getHref())));
} else {
contentList.add(data);
}
}
}
}
return new Resources(contentList, potentiallyAddSelfLink(document.getCollection().getLinks(), document.getCollection().getHref()));
}
static boolean isResource(JavaType type) {
return type.containedType(0).hasRawClass(Resource.class);
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt,
BeanProperty property) throws JsonMappingException {
if (property != null) {
JavaType vc = property.getType().getContentType();
CollectionJsonResourcesDeserializer des = new CollectionJsonResourcesDeserializer(vc);
return des;
} else {
return new CollectionJsonResourcesDeserializer(ctxt.getContextualType());
}
}
}
static class CollectionJsonPagedResourcesDeserializer extends ContainerDeserializerBase<PagedResources>
implements ContextualDeserializer {
private final JavaType contentType;
CollectionJsonPagedResourcesDeserializer() {
this(TypeFactory.defaultInstance().constructType(CollectionJson.class));
}
CollectionJsonPagedResourcesDeserializer(JavaType contentType) {
super(contentType);
this.contentType = contentType;
}
@Override
public JavaType getContentType() {
return this.contentType;
}
@Override
public JsonDeserializer<Object> getContentDeserializer() {
return null;
}
@Override
public PagedResources deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
JavaType rootType = JacksonHelper.findRootType(this.contentType);
JavaType wrappedType = ctxt.getTypeFactory().constructParametricType(CollectionJsonDocument.class, rootType);
CollectionJsonDocument<?> document = jp.getCodec().readValue(jp, wrappedType);
List<Object> items = new ArrayList<>();
document.getCollection().getItems().forEach(item -> {
Object data = item.toRawData(rootType);
List<Link> links = item.getLinks() == null ? Collections.EMPTY_LIST : item.getLinks();
if (this.contentType.hasGenericTypes()) {
if (this.contentType.containedType(0).hasRawClass(Resource.class)) {
items.add(new Resource<>(data, potentiallyAddSelfLink(links, item.getHref())));
} else {
items.add(data);
}
}
});
PagedResources.PageMetadata pageMetadata = null;
return new PagedResources(items, pageMetadata,
potentiallyAddSelfLink(document.getCollection().getLinks(), document.getCollection().getHref()));
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
if (property != null) {
JavaType vc = property.getType().getContentType();
CollectionJsonPagedResourcesDeserializer des = new CollectionJsonPagedResourcesDeserializer(vc);
return des;
} else {
return new CollectionJsonPagedResourcesDeserializer(ctxt.getContextualType());
}
}
}
public static class CollectionJsonHandlerInstantiator extends HandlerInstantiator {
private final Map<Class<?>, Object> instanceMap = new HashMap<Class<?>, Object>();
public CollectionJsonHandlerInstantiator(MessageSourceAccessor messageSource) {
this.instanceMap.put(CollectionJsonPagedResourcesSerializer.class, new CollectionJsonPagedResourcesSerializer());
this.instanceMap.put(CollectionJsonResourcesSerializer.class, new CollectionJsonResourcesSerializer());
this.instanceMap.put(CollectionJsonResourceSerializer.class, new CollectionJsonResourceSerializer());
this.instanceMap.put(CollectionJsonResourceSupportSerializer.class, new CollectionJsonResourceSupportSerializer());
this.instanceMap.put(CollectionJsonLinkListSerializer.class, new CollectionJsonLinkListSerializer(messageSource));
}
private Object findInstance(Class<?> type) {
Object result = instanceMap.get(type);
return result != null ? result : BeanUtils.instantiateClass(type);
}
@Override
public JsonDeserializer<?> deserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> deserClass) {
return (JsonDeserializer<?>) findInstance(deserClass);
}
@Override
public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> keyDeserClass) {
return (KeyDeserializer) findInstance(keyDeserClass);
}
@Override
public JsonSerializer<?> serializerInstance(SerializationConfig config, Annotated annotated, Class<?> serClass) {
return (JsonSerializer<?>) findInstance(serClass);
}
@Override
public TypeResolverBuilder<?> typeResolverBuilderInstance(MapperConfig<?> config, Annotated annotated, Class<?> builderClass) {
return (TypeResolverBuilder<?>) findInstance(builderClass);
}
@Override
public TypeIdResolver typeIdResolverInstance(MapperConfig<?> config, Annotated annotated, Class<?> resolverClass) {
return (TypeIdResolver) findInstance(resolverClass);
}
}
/**
* Return a list of {@link Link}s that includes a "self" link.
*
* @param links - base set of {@link Link}s.
* @param href - the URI of the "self" link
* @return
*/
private static List<Link> potentiallyAddSelfLink(List<Link> links, String href) {
if (links == null) {
if (href == null) {
return Collections.emptyList();
}
return Collections.singletonList(new Link(href));
}
if (href == null || links.stream().map(Link::getRel).anyMatch(s -> s.equals(Link.REL_SELF))) {
return links;
}
// Clone and add the self link
List<Link> newLinks = new ArrayList<>();
newLinks.add(new Link(href));
newLinks.addAll(links);
return newLinks;
}
private static List<Link> withoutSelfLink(List<Link> links) {
return links.stream()
.filter(link -> !link.getRel().equals(Link.REL_SELF))
.collect(Collectors.toList());
}
private static List<CollectionJsonItem<?>> resourcesToCollectionJsonItems(Resources<?> resources) {
return resources.getContent().stream()
.map(content -> {
if (ClassUtils.isAssignableValue(Resource.class, content)) {
Resource resource = (Resource) content;
return new CollectionJsonItem<>()
.withHref(resource.getRequiredLink(Link.REL_SELF).getHref())
.withLinks(withoutSelfLink(resource.getLinks()))
.withRawData(resource.getContent());
} else {
return new CollectionJsonItem<>().withRawData(content);
}
})
.collect(Collectors.toList());
}
/**
* Scan through the {@link Affordance}s and find any {@literal GET} calls against non-self URIs.
*
* @param resource
* @return
*/
private static List<CollectionJsonQuery> findQueries(ResourceSupport resource) {
List<CollectionJsonQuery> queries = new ArrayList<>();
if (resource.hasLink(Link.REL_SELF)) {
Link selfLink = resource.getRequiredLink(Link.REL_SELF);
selfLink.getAffordances().forEach(affordance -> {
CollectionJsonAffordanceModel model = affordance.getAffordanceModel(MediaTypes.COLLECTION_JSON);
/**
* For Collection+JSON, "queries" are only collected for GET affordances where the URI is NOT a self link.
*/
if (affordance.getHttpMethod().equals(HttpMethod.GET) && !model.getUri().equals(selfLink.getHref())) {
queries.add(new CollectionJsonQuery()
.withRel(model.getRel())
.withHref(model.getUri())
.withData(model.getQueryProperties()));
}
});
}
return queries;
}
/**
* Scan through the {@link Affordance}s and
* @param resource
* @return
*/
private static CollectionJsonTemplate findTemplate(ResourceSupport resource) {
List<CollectionJsonTemplate> templates = new ArrayList<>();
if (resource.hasLink(Link.REL_SELF)) {
resource.getRequiredLink(Link.REL_SELF).getAffordances().forEach(affordance -> {
CollectionJsonAffordanceModel model = affordance.getAffordanceModel(MediaTypes.COLLECTION_JSON);
/**
* For Collection+JSON, "templates" are made of any non-GET affordances.
*/
if (!affordance.getHttpMethod().equals(HttpMethod.GET)) {
CollectionJsonTemplate template = new CollectionJsonTemplate() //
.withData(model.getInputProperties());
templates.add(template);
}
});
}
/**
* Collection+JSON can only have one template, so grab the first one.
*/
return templates.stream()
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2015 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.collectionjson;
import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.*;
import org.springframework.hateoas.PagedResources;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Jackson 2 mixin to handle {@link PagedResources}.
*
* @author Greg Turnquist
*/
@JsonSerialize(using = CollectionJsonPagedResourcesSerializer.class)
@JsonDeserialize(using = CollectionJsonPagedResourcesDeserializer.class)
abstract class PagedResourcesMixin<T> extends PagedResources<T> {
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2015 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.collectionjson;
import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.*;
import org.springframework.hateoas.Resource;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Jackson 2 mixin to invoke the related serializer/deserizer.
*
* @author Greg Turnquist
*/
@JsonSerialize(using = CollectionJsonResourceSerializer.class)
@JsonDeserialize(using = CollectionJsonResourceDeserializer.class)
abstract class ResourceMixin<T> extends Resource<T> {
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2015 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.collectionjson;
import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.*;
import java.util.List;
import javax.xml.bind.annotation.XmlElement;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.ResourceSupport;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Jackson 2 mixin to invoke the related serializer/deserializer.
*
* @author Greg Turnquist
*/
@JsonSerialize(using = CollectionJsonResourceSupportSerializer.class)
@JsonDeserialize(using = CollectionJsonResourceSupportDeserializer.class)
abstract class ResourceSupportMixin extends ResourceSupport {
@Override
@XmlElement(name = "collection")
@JsonProperty("collection")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonSerialize(using = CollectionJsonLinkListSerializer.class)
@JsonDeserialize(using = CollectionJsonLinkListDeserializer.class)
public abstract List<Link> getLinks();
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2015 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.collectionjson;
import static org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule.*;
import org.springframework.hateoas.Resources;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* Jackson 2 mixin to invoke the related serializer/deserizer.
*
* @author Greg Turnquist
*/
@JsonSerialize(using = CollectionJsonResourcesSerializer.class)
@JsonDeserialize(using = CollectionJsonResourcesDeserializer.class)
abstract class ResourcesMixin<T> extends Resources<T> {
}

View File

@@ -33,6 +33,7 @@ import org.springframework.context.annotation.ImportSelector;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.LinkDiscoverer;
import org.springframework.hateoas.collectionjson.CollectionJsonWebMvcConfigurer;
import org.springframework.hateoas.hal.forms.HalFormsWebMvcConfigurer;
/**
@@ -85,7 +86,15 @@ public @interface EnableHypermediaSupport {
*
* @see https://rwcbook.github.io/hal-forms/
*/
HAL_FORMS(HalFormsWebMvcConfigurer.class);
HAL_FORMS(HalFormsWebMvcConfigurer.class),
/**
* Collection+JSON
*
* @see http://amundsen.com/media-types/collection/format/
*/
COLLECTION_JSON(CollectionJsonWebMvcConfigurer.class);
private final List<Class<?>> configurations;

View File

@@ -48,6 +48,8 @@ import org.springframework.hateoas.LinkDiscoverer;
import org.springframework.hateoas.LinkDiscoverers;
import org.springframework.hateoas.RelProvider;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.collectionjson.CollectionJsonLinkDiscoverer;
import org.springframework.hateoas.collectionjson.Jackson2CollectionJsonModule;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.core.AnnotationRelProvider;
import org.springframework.hateoas.core.DefaultRelProvider;
@@ -88,6 +90,7 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
private static final String LINK_DISCOVERER_REGISTRY_BEAN_NAME = "_linkDiscovererRegistry";
private static final String HAL_OBJECT_MAPPER_BEAN_NAME = "_halObjectMapper";
private static final String HAL_FORMS_OBJECT_MAPPER_BEAN_NAME = "_halFormsObjectMapper";
private static final String COLLECTION_JSON_OBJECT_MAPPER_BEAN_NAME = "_collectionJsonObjectMapper";
private static final String MESSAGE_SOURCE_BEAN_NAME = "linkRelationMessageSource";
private static final boolean JACKSON2_PRESENT = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper",
@@ -129,6 +132,10 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
registerHypermediaComponents(metadata, registry, HAL_FORMS_OBJECT_MAPPER_BEAN_NAME);
}
if (types.contains(HypermediaType.COLLECTION_JSON)) {
registerHypermediaComponents(metadata, registry, COLLECTION_JSON_OBJECT_MAPPER_BEAN_NAME);
}
if (!types.isEmpty()) {
BeanDefinitionBuilder linkDiscoverersRegistryBuilder = BeanDefinitionBuilder
@@ -214,6 +221,9 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
case HAL_FORMS:
definition = new RootBeanDefinition(HalFormsLinkDiscoverer.class);
break;
case COLLECTION_JSON:
definition = new RootBeanDefinition(CollectionJsonLinkDiscoverer.class);
break;
default:
throw new IllegalStateException(String.format("Unsupported hypermedia type %s!", type));
}
@@ -316,7 +326,7 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME,
MessageSourceAccessor.class);
MessageSourceAccessor.class);
halObjectMapper.registerModule(new Jackson2HalModule());
@@ -340,7 +350,7 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
ObjectMapper halFormsObjectMapper = beanFactory.getBean(HAL_FORMS_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME,
MessageSourceAccessor.class);
MessageSourceAccessor.class);
halFormsObjectMapper.registerModule(new Jackson2HalFormsModule());
@@ -354,12 +364,30 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
}
MappingJackson2HttpMessageConverter halFormsConverter = new TypeConstrainedMappingJackson2HttpMessageConverter(
ResourceSupport.class);
ResourceSupport.class);
halFormsConverter.setSupportedMediaTypes(Arrays.asList(HAL_FORMS_JSON));
halFormsConverter.setObjectMapper(halFormsObjectMapper);
result.add(halFormsConverter);
}
if (beanFactory.containsBean(COLLECTION_JSON_OBJECT_MAPPER_BEAN_NAME)) {
ObjectMapper collectionJsonObjectMapper = beanFactory.getBean(COLLECTION_JSON_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);
MessageSourceAccessor linkRelationMessageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME,
MessageSourceAccessor.class);
collectionJsonObjectMapper.registerModule(new Jackson2CollectionJsonModule());
collectionJsonObjectMapper.setHandlerInstantiator(
new Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator(linkRelationMessageSource));
MappingJackson2HttpMessageConverter jsonCollectionConverter = new TypeConstrainedMappingJackson2HttpMessageConverter(
ResourceSupport.class);
jsonCollectionConverter.setSupportedMediaTypes(Arrays.asList(COLLECTION_JSON));
jsonCollectionConverter.setObjectMapper(collectionJsonObjectMapper);
result.add(jsonCollectionConverter);
}
result.addAll(converters);
return result;
@@ -390,7 +418,7 @@ class HypermediaSupportBeanDefinitionRegistrar implements ImportBeanDefinitionRe
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (HAL_OBJECT_MAPPER_BEAN_NAME.equals(beanName) || HAL_FORMS_OBJECT_MAPPER_BEAN_NAME.equals(beanName)) {
if (HAL_OBJECT_MAPPER_BEAN_NAME.equals(beanName) || HAL_FORMS_OBJECT_MAPPER_BEAN_NAME.equals(beanName) || COLLECTION_JSON_OBJECT_MAPPER_BEAN_NAME.equals(beanName)) {
ObjectMapper mapper = (ObjectMapper) bean;
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
return mapper;

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.hateoas.core;
import java.util.Optional;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.core.DummyInvocationUtils.MethodInvocation;
@@ -30,6 +32,15 @@ import org.springframework.web.util.UriComponents;
*/
public interface AffordanceModelFactory extends Plugin<MediaType> {
/**
* Declare the {@link MediaType} this factory supports.
*
* @return
*/
default MediaType getMediaType() {
return null;
};
/**
* Look up the {@link AffordanceModel} for this factory.
*
@@ -39,4 +50,17 @@ public interface AffordanceModelFactory extends Plugin<MediaType> {
* @return
*/
AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components);
/**
* Returns if a plugin should be invoked according to the given delimiter.
*
* @param delimiter
* @return if the plugin should be invoked
*/
@Override
default boolean supports(MediaType delimiter) {
return Optional.ofNullable(getMediaType())
.map(mediaType -> mediaType.equals(delimiter))
.orElse(false);
}
}

View File

@@ -82,8 +82,8 @@ public class JsonPathLinkDiscoverer implements LinkDiscoverer {
public JsonPathLinkDiscoverer(String pathTemplate, MediaType mediaType, MediaType... others) {
Assert.hasText(pathTemplate, "Path template must not be null!");
Assert.isTrue(StringUtils.countOccurrencesOf(pathTemplate, "%s") == 1,
"Path template must contain a single placeholder!");
// Assert.isTrue(StringUtils.countOccurrencesOf(pathTemplate, "%s") == 1,
// "Path template must contain a single placeholder!");
Assert.notNull(mediaType, "Primary MediaType must not be null!");
Assert.notNull(others, "Other MediaTypes must not be null!");

View File

@@ -15,29 +15,21 @@
*/
package org.springframework.hateoas.hal.forms;
import lombok.extern.slf4j.Slf4j;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.springframework.beans.BeanUtils;
import org.springframework.core.MethodParameter;
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.DummyInvocationUtils.MethodInvocation;
import org.springframework.hateoas.core.MethodParameters;
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.bind.annotation.RequestBody;
import org.springframework.web.util.UriComponents;
/**
@@ -45,40 +37,41 @@ import org.springframework.web.util.UriComponents;
*
* @author Greg Turnquist
*/
@Slf4j
class HalFormsAffordanceModel implements AffordanceModel {
private static final List<HttpMethod> METHODS_FOR_INPUT_DETECTTION = Arrays.asList(HttpMethod.POST, HttpMethod.PUT,
HttpMethod.PATCH);
private final Affordance affordance;
private final UriComponents components;
private final boolean required;
private final Map<String, Class<?>> properties;
private final List<String> properties;
public HalFormsAffordanceModel(Affordance affordance, MethodInvocation invocationValue, UriComponents components) {
public HalFormsAffordanceModel(Affordance affordance, UriComponents components) {
this.affordance = affordance;
this.components = components;
this.required = determineRequired(affordance.getHttpMethod());
this.properties = METHODS_FOR_INPUT_DETECTTION.contains(affordance.getHttpMethod()) //
? determineAffordanceInputs(invocationValue.getMethod()) //
: Collections.<String, Class<?>> emptyMap();
? determineAffordanceInputs() //
: Collections.emptyList();
}
/**
* Transform the details of the Spring MVC method's {@link RequestBody} into a collection of
* Transform the details of the REST method's {@link MethodParameters} into
* {@link HalFormsProperty}s.
*
* @return
*/
public List<HalFormsProperty> getProperties() {
return properties.entrySet().stream() //
.map(entry -> entry.getKey()) //
.map(key -> HalFormsProperty.named(key).withRequired(required)).collect(Collectors.toList());
return properties.stream() //
.map(name -> HalFormsProperty.named(name).withRequired(required)) //
.collect(Collectors.toList());
}
public String getPath() {
return components.getPath();
public String getURI() {
return components.toUriString();
}
/**
@@ -91,7 +84,7 @@ class HalFormsAffordanceModel implements AffordanceModel {
Assert.notNull(path, "Path must not be null!");
return getPath().equals(path);
return getURI().equals(path);
}
/*
@@ -115,39 +108,15 @@ class HalFormsAffordanceModel implements AffordanceModel {
/**
* 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 Map<String, Class<?>> determineAffordanceInputs(Method method) {
private List<String> determineAffordanceInputs() {
if (method == null) {
return Collections.emptyMap();
}
LOG.debug("Gathering details about " + method.getDeclaringClass().getCanonicalName() + "." + method.getName());
Map<String, Class<?>> properties = new TreeMap<>();
MethodParameters parameters = new MethodParameters(method);
for (MethodParameter parameter : parameters.getParametersWith(RequestBody.class)) {
Class<?> parameterType = parameter.getParameterType();
LOG.debug("\tRequest body: " + parameterType.getCanonicalName() + "(");
for (PropertyDescriptor descriptor : BeanUtils.getPropertyDescriptors(parameterType)) {
if (!descriptor.getName().equals("class")) {
LOG.debug("\t\t" + descriptor.getPropertyType().getCanonicalName() + " " + descriptor.getName());
properties.put(descriptor.getName(), descriptor.getPropertyType());
}
}
LOG.debug(")");
}
LOG.debug("Assembled " + this.toString());
return properties;
return this.affordance.getInputMethodParameters().stream()
.findFirst()
.map(methodParameter -> {
ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter);
return PropertyUtils.findProperties(resolvableType);
})
.orElse(Collections.emptyList());
}
}

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.hateoas.hal.forms;
import lombok.Getter;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.MediaTypes;
@@ -31,6 +33,8 @@ import org.springframework.web.util.UriComponents;
*/
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)
@@ -38,15 +42,6 @@ class HalFormsAffordanceModelFactory implements AffordanceModelFactory {
@Override
public AffordanceModel getAffordanceModel(Affordance affordance, MethodInvocation invocationValue,
UriComponents components) {
return new HalFormsAffordanceModel(affordance, invocationValue, components);
}
/*
* (non-Javadoc)
* @see org.springframework.plugin.core.Plugin#supports(java.lang.Object)
*/
@Override
public boolean supports(MediaType mediaType) {
return MediaTypes.HAL_FORMS_JSON.equals(mediaType);
return new HalFormsAffordanceModel(affordance, components);
}
}

View File

@@ -16,12 +16,9 @@
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;
@@ -224,18 +221,12 @@ class HalFormsSerializers {
*/
private static void validate(ResourceSupport resource, Affordance affordance, HalFormsAffordanceModel model) {
try {
String affordanceUri = model.getURI();
String selfLinkUri = resource.getRequiredLink(Link.REL_SELF).getHref();
Optional<Link> selfLink = resource.getLink(Link.REL_SELF);
URI selfLinkUri = new URI(selfLink.map(link -> link.expand().getHref()).orElse(""));
if (!model.hasPath(selfLinkUri.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);
if (!affordanceUri.equals(selfLinkUri)) {
throw new IllegalStateException("Affordance's URI " + affordanceUri + " doesn't match self link "
+ selfLinkUri + " as expected in HAL-FORMS");
}
}
}

View File

@@ -306,7 +306,7 @@ public class ControllerLinkBuilder extends LinkBuilderSupport<ControllerLinkBuil
*
* @return
*/
static UriComponentsBuilder getBuilder() {
public static UriComponentsBuilder getBuilder() {
if (RequestContextHolder.getRequestAttributes() == null) {
return UriComponentsBuilder.fromPath("/");

View File

@@ -21,14 +21,21 @@ import lombok.Value;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.core.MethodParameter;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.AffordanceModel;
import org.springframework.hateoas.QueryParameter;
import org.springframework.hateoas.core.MethodParameters;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
/**
* Spring MVC-based representation of an {@link Affordance}.
@@ -89,4 +96,30 @@ class SpringMvcAffordance implements Affordance {
this.affordanceModels.put(mediaType, affordanceModel);
}
}
/**
* Get a listing of {@link MethodParameter}s based on Spring MVC's {@link RequestBody}s.
*
* @return
*/
@Override
public List<MethodParameter> getInputMethodParameters() {
return new MethodParameters(this.method).getParametersWith(RequestBody.class);
}
/**
* Get a listing of {@link MethodParameter}s based on Spring MVC's {@link RequestParam}s.
*
* @return
*/
@Override
public List<QueryParameter> getQueryMethodParameters() {
MethodParameters parameters = new MethodParameters(this.method);
return parameters.getParametersWith(RequestParam.class).stream()
.map(methodParameter -> methodParameter.getParameterAnnotation(RequestParam.class))
.map(requestParam -> new QueryParameter(requestParam.name(), requestParam.required(), requestParam.value()))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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.
* 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.support;
import com.fasterxml.jackson.databind.JavaType;
/**
* Jackson utility methods.
*/
public final class JacksonHelper {
/**
* Navigate a chain of parametric types (e.g. Resources&lt;Resource&lt;String&gt;&gt;) until you find the innermost type (String).
*
* @param contentType
* @return
*/
public static JavaType findRootType(JavaType contentType) {
if (contentType.hasGenericTypes()) {
return findRootType(contentType.containedType(0));
} else {
return contentType;
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* 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.support;
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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.BeanUtils;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.hateoas.Resource;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* @author Greg Turnquist
*/
public class PropertyUtils {
private final static HashSet<String> FIELDS_TO_IGNORE = new HashSet<String>() {{
add("class");
add("links");
}};
public static Map<String, Object> findProperties(Object object) {
if (object.getClass().equals(Resource.class)) {
return findProperties(((Resource<?>) object).getContent());
}
return Arrays.asList(BeanUtils.getPropertyDescriptors(object.getClass())).stream()
.filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName()))
.filter(descriptor -> hasJsonIgnoreOnTheField(object.getClass(), descriptor))
.filter(PropertyUtils::hasJsonIgnoreOnTheReader)
.collect(Collectors.toMap(
FeatureDescriptor::getName,
descriptor -> {
try {
return descriptor.getReadMethod().invoke(object);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}));
}
public static List<String> findProperties(ResolvableType resolvableType) {
if (resolvableType.getRawClass().equals(Resource.class)) {
return findProperties(resolvableType.resolveGeneric(0));
} else {
return findProperties(resolvableType.getRawClass());
}
}
public static List<String> findProperties(Class<?> clazz) {
return Arrays.asList(BeanUtils.getPropertyDescriptors(clazz)).stream()
.filter(descriptor -> !FIELDS_TO_IGNORE.contains(descriptor.getName()))
.filter(descriptor -> hasJsonIgnoreOnTheField(clazz, descriptor))
.filter(PropertyUtils::hasJsonIgnoreOnTheReader)
.map(FeatureDescriptor::getName)
.collect(Collectors.toList());
}
public static Object createObjectFromProperties(Class<?> clazz, Map<String, Object> properties) {
Object obj = BeanUtils.instantiateClass(clazz);
properties.entrySet().stream().forEach(entry -> {
Optional<PropertyDescriptor> possibleProperty = Optional.ofNullable(BeanUtils.getPropertyDescriptor(clazz, entry.getKey()));
possibleProperty.ifPresent(property -> {
try {
Method writeMethod = property.getWriteMethod();
ReflectionUtils.makeAccessible(writeMethod);
writeMethod.invoke(obj, entry.getValue());
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
});
});
return obj;
}
/**
* Check if a given {@link PropertyDescriptor} has {@link JsonIgnore} applied to the field declaration.
*
* @param object
* @param descriptor
* @return
*/
private static boolean hasJsonIgnoreOnTheField(Class<?> clazz, PropertyDescriptor descriptor) {
Field descriptorField = ReflectionUtils.findField(clazz, descriptor.getName());
return isToBeIgnored(AnnotationUtils.getAnnotations(descriptorField));
}
/**
* Check if a given {@link PropertyDescriptor} has {@link JsonIgnore} on the getter.
*
* @param object
* @param descriptor
* @return
*/
private static boolean hasJsonIgnoreOnTheReader(PropertyDescriptor descriptor) {
return isToBeIgnored(AnnotationUtils.getAnnotations(descriptor.getReadMethod()));
}
/**
* Scan a list of {@link Annotation}s for {@link JsonIgnore} annotations.
*
* @param annotations
* @return
*/
private static boolean isToBeIgnored(Annotation[] annotations) {
if (annotations != null) {
for (Annotation annotation : annotations) {
if (annotation.annotationType().equals(JsonIgnore.class)) {
return !(Boolean) AnnotationUtils.getAnnotationAttributes(annotation).get("value");
}
}
}
return true;
}
}