#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

21
readme.adoc Normal file
View File

@@ -0,0 +1,21 @@
image:https://spring.io/badges/spring-hateoas/ga.svg[http://projects.spring.io/spring-hateoas/#quick-start]
image:https://spring.io/badges/spring-hateoas/snapshot.svg[http://projects.spring.io/spring-hateoas/#quick-start]
= Spring HATEOAS
This project provides some APIs to ease creating REST representations that follow the http://en.wikipedia.org/wiki/HATEOAS[HATEOAS] principle when working with Spring and especially Spring MVC. The core problem it tries to address is link creation and representation assembly.
== Working with Spring HATEOAS
Since all commits are headlined with its github issue, git will treat it as a comment. To get around this, apply the following configuration to your clone:
[source]
----
git config core.commentchar "/"
----
== Resources
* Reference documentation - http://docs.spring.io/spring-hateoas/docs/current/reference/html/[html], http://docs.spring.io/spring-hateoas/docs/current/reference/pdf/spring-hateoas-reference.pdf[pdf]
* http://docs.spring.io/spring-hateoas/docs/current-SNAPSHOT/api/[JavaDoc]
* https://spring.io/guides/gs/rest-hateoas/[Getting started guide]

View File

@@ -1,11 +0,0 @@
[![Spring Hateoas](https://spring.io/badges/spring-hateoas/ga.svg)](http://projects.spring.io/spring-hateoas/#quick-start)
[![Spring Hateoas](https://spring.io/badges/spring-hateoas/snapshot.svg)](http://projects.spring.io/spring-hateoas/#quick-start)
# Spring Hateoas
This project provides some APIs to ease creating REST representations that follow the [HATEOAS](http://en.wikipedia.org/wiki/HATEOAS) principle when working with Spring and especially Spring MVC. The core problem it tries to address is link creation and representation assembly.
## Resources
- Reference documentation - [html](http://docs.spring.io/spring-hateoas/docs/current/reference/html/), [pdf](http://docs.spring.io/spring-hateoas/docs/current/reference/pdf/spring-hateoas-reference.pdf)
- [JavaDoc](http://docs.spring.io/spring-hateoas/docs/current-SNAPSHOT/api/)
- [Getting started guide](https://spring.io/guides/gs/rest-hateoas/)

View File

@@ -363,7 +363,7 @@ Since the purpose of the `CurieProvider` API is to allow for automatic curie cre
[[client.traverson]]
=== Traverson
As of version 0.11 Spring HATEOAS provides an API for client side service traversal inspired by the https://blog.codecentric.de/en/2013/11/traverson/[Traverson JavaScript library].
Spring HATEOAS provides an API for client side service traversal inspired by the https://blog.codecentric.de/en/2013/11/traverson/[Traverson JavaScript library].
[source, java]
----

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

View File

@@ -1 +1 @@
org.springframework.hateoas.core.AffordanceModelFactory=org.springframework.hateoas.hal.forms.HalFormsAffordanceModelFactory
org.springframework.hateoas.core.AffordanceModelFactory=org.springframework.hateoas.hal.forms.HalFormsAffordanceModelFactory,org.springframework.hateoas.collectionjson.CollectionJsonAffordanceModelFactory

View File

@@ -19,9 +19,11 @@ import static org.assertj.core.api.Assertions.*;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.List;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.junit.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
@@ -296,5 +298,15 @@ public class LinkUnitTest {
public String getName() {
return null;
}
@Override
public List<MethodParameter> getInputMethodParameters() {
return null;
}
@Override
public List<QueryParameter> getQueryMethodParameters() {
return null;
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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 org.assertj.core.api.Assertions.*;
import java.io.IOException;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkDiscoverer;
import org.springframework.hateoas.support.MappingUtils;
/**
* Unit tests for {@link CollectionJsonLinkDiscoverer}.
*
* @author Greg Turnquist
*/
public class CollectionJsonLinkDiscovererUnitTest {
LinkDiscoverer discoverer;
@Before
public void setUp() {
this.discoverer = new CollectionJsonLinkDiscoverer();
}
@Test
public void spec1Links() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part1.json", getClass()));
Link link = this.discoverer.findLinkWithRel("self", specBasedJson);
assertThat(link).isNotNull();
assertThat(link.getHref()).isEqualTo("http://example.org/friends/");
}
@Test
public void spec2Links() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part2.json", getClass()));
Link selfLink = this.discoverer.findLinkWithRel("self", specBasedJson);
assertThat(selfLink).isNotNull();
assertThat(selfLink.getHref()).isEqualTo("http://example.org/friends/");
Link feedLink = this.discoverer.findLinkWithRel("feed", specBasedJson);
assertThat(feedLink).isNotNull();
assertThat(feedLink.getHref()).isEqualTo("http://example.org/friends/rss");
List<Link> links = this.discoverer.findLinksWithRel("blog", specBasedJson);
assertThat(links)
.extracting("href")
.containsExactlyInAnyOrder(
"http://examples.org/blogs/jdoe",
"http://examples.org/blogs/msmith",
"http://examples.org/blogs/rwilliams");
links = this.discoverer.findLinksWithRel("avatar", specBasedJson);
assertThat(links)
.extracting("href")
.containsExactlyInAnyOrder(
"http://examples.org/images/jdoe",
"http://examples.org/images/msmith",
"http://examples.org/images/rwilliams");
}
}

View File

@@ -0,0 +1,203 @@
/*
* 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 org.assertj.core.api.Assertions.*;
import lombok.Data;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.support.MappingUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* Unit tests leveraging spec fragments of JSON.
*
* NOTE: Fields that don't map into Java property names (e.g. {@literal full-name}) are altered in the JSON to work properly.
* Alternative is to have some sort of injectable converter.
*
* @author Greg Turnquist
*/
public class CollectionJsonSpecTest {
ObjectMapper mapper;
@Before
public void setUp() {
mapper = new ObjectMapper();
mapper.registerModule(new Jackson2CollectionJsonModule());
mapper.setHandlerInstantiator(new Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator(null));
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 1. Minimal Representation
* @throws IOException
*/
@Test
public void specPart1() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part1.json", getClass()));
ResourceSupport resource = mapper.readValue(specBasedJson, ResourceSupport.class);
assertThat(resource.getLinks()).hasSize(1);
assertThat(resource.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/"));
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 2. Collection Representation
* @throws IOException
*/
@Test
public void specPart2() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part2.json", getClass()));
Resources<Resource<Friend>> resources = mapper.readValue(specBasedJson,
mapper.getTypeFactory().constructParametricType(Resources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, Friend.class)));
assertThat(resources.getLinks()).hasSize(2);
assertThat(resources.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/"));
assertThat(resources.getRequiredLink("feed")).isEqualTo(new Link("http://example.org/friends/rss", "feed"));
assertThat(resources.getContent()).hasSize(3);
List<Resource<Friend>> friends = new ArrayList<>(resources.getContent());
assertThat(friends.get(0).getContent().getEmail()).isEqualTo("jdoe@example.org");
assertThat(friends.get(0).getContent().getFullname()).isEqualTo("J. Doe");
assertThat(friends.get(0).getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/jdoe"));
assertThat(friends.get(0).getRequiredLink("blog")).isEqualTo(new Link("http://examples.org/blogs/jdoe", "blog"));
assertThat(friends.get(0).getRequiredLink("avatar")).isEqualTo(new Link("http://examples.org/images/jdoe", "avatar"));
assertThat(friends.get(1).getContent().getEmail()).isEqualTo("msmith@example.org");
assertThat(friends.get(1).getContent().getFullname()).isEqualTo("M. Smith");
assertThat(friends.get(1).getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/msmith"));
assertThat(friends.get(1).getRequiredLink("blog")).isEqualTo(new Link("http://examples.org/blogs/msmith", "blog"));
assertThat(friends.get(1).getRequiredLink("avatar")).isEqualTo(new Link("http://examples.org/images/msmith", "avatar"));
assertThat(friends.get(2).getContent().getEmail()).isEqualTo("rwilliams@example.org");
assertThat(friends.get(2).getContent().getFullname()).isEqualTo("R. Williams");
assertThat(friends.get(2).getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/rwilliams"));
assertThat(friends.get(2).getRequiredLink("blog")).isEqualTo(new Link("http://examples.org/blogs/rwilliams", "blog"));
assertThat(friends.get(2).getRequiredLink("avatar")).isEqualTo(new Link("http://examples.org/images/rwilliams", "avatar"));
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 3. Item Representation
* @throws IOException
*/
@Test
public void specPart3() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part3.json", getClass()));
Resource<Friend> resource = mapper.readValue(specBasedJson,
mapper.getTypeFactory().constructParametricType(Resource.class, Friend.class));
assertThat(resource.getLinks()).hasSize(6);
assertThat(resource.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/jdoe"));
assertThat(resource.getRequiredLink("feed")).isEqualTo(new Link("http://example.org/friends/rss", "feed"));
assertThat(resource.getRequiredLink("queries")).isEqualTo(new Link("http://example.org/friends/?queries", "queries"));
assertThat(resource.getRequiredLink("template")).isEqualTo(new Link("http://example.org/friends/?template", "template"));
assertThat(resource.getRequiredLink("blog")).isEqualTo(new Link("http://examples.org/blogs/jdoe", "blog"));
assertThat(resource.getRequiredLink("avatar")).isEqualTo(new Link("http://examples.org/images/jdoe", "avatar"));
assertThat(resource.getContent().getEmail()).isEqualTo("jdoe@example.org");
assertThat(resource.getContent().getFullname()).isEqualTo("J. Doe");
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 4. Queries Representation
* @throws IOException
*/
@Test
public void specPart4() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part4.json", getClass()));
Resources<Resource<Friend>> resources = mapper.readValue(specBasedJson,
mapper.getTypeFactory().constructParametricType(Resources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, Friend.class)));
assertThat(resources.getContent()).hasSize(0);
assertThat(resources.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/"));
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 5. Template Representation
* @throws IOException
*/
@Test
public void specPart5() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part5.json", getClass()));
Resources<Resource<Friend>> resources = mapper.readValue(specBasedJson,
mapper.getTypeFactory().constructParametricType(Resources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, Friend.class)));
assertThat(resources.getContent()).hasSize(0);
assertThat(resources.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/"));
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 6. Error Representation
* @throws IOException
*/
@Test
public void specPart6() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part6.json", getClass()));
Resources<Resource<Friend>> resources = mapper.readValue(specBasedJson,
mapper.getTypeFactory().constructParametricType(Resources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, Friend.class)));
assertThat(resources.getContent()).hasSize(0);
assertThat(resources.getRequiredLink(Link.REL_SELF)).isEqualTo(new Link("http://example.org/friends/"));
}
/**
* @see http://amundsen.com/media-types/collection/examples/ - Section 7. Write Representation
* @throws IOException
*/
@Test
public void specPart7() throws IOException {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part7.json", getClass()));
// TODO: Come up with a way to verify this JSON spec can be used to create a new resource.
}
@Data
static class Friend {
private String fullname;
private String email;
}
}

View File

@@ -0,0 +1,333 @@
/*
* 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 static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.support.Employee;
import org.springframework.hateoas.support.MappingUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
/**
* @author Greg Turnquist
*/
@RunWith(SpringRunner.class)
@WebAppConfiguration
@ContextConfiguration
public class CollectionJsonWebMvcIntegrationTest {
@Autowired WebApplicationContext context;
MockMvc mockMvc;
private static Map<Integer, Employee> EMPLOYEES;
@Before
public void setUp() {
this.mockMvc = webAppContextSetup(this.context).build();
EMPLOYEES = new TreeMap<>();
EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer"));
EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar"));
}
@Test
public void singleEmployee() throws Exception {
this.mockMvc.perform(get("/employees/0").accept(MediaTypes.COLLECTION_JSON_VALUE)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees/0")))
.andExpect(jsonPath("$.collection.links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("ring bearer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@Test
public void collectionOfEmployees() throws Exception {
this.mockMvc.perform(get("/employees").accept(MediaTypes.COLLECTION_JSON_VALUE)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(2)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("ring bearer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items[1].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[1].data[1].value", is("Bilbo Baggins")))
.andExpect(jsonPath("$.collection.items[1].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[1].data[0].value", is("burglar")))
.andExpect(jsonPath("$.collection.items[1].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[1].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[1].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@Test
public void createNewEmployee() throws Exception {
String specBasedJson = MappingUtils.read(new ClassPathResource("spec-part7-adjusted.json", getClass()));
this.mockMvc.perform(post("/employees")
.content(specBasedJson)
.contentType(MediaTypes.COLLECTION_JSON_VALUE))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
this.mockMvc.perform(get("/employees/2").accept(MediaTypes.COLLECTION_JSON))
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees/2")))
.andExpect(jsonPath("$.collection.links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("W. Chandry")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("developer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@RestController
static class EmployeeController {
@GetMapping("/employees")
public Resources<Resource<Employee>> all() {
// Create a list of Resource<Employee>'s to return
List<Resource<Employee>> employees = new ArrayList<>();
// Fetch each Resource<Employee> using the controller's findOne method.
for (int i = 0; i < EMPLOYEES.size(); i++) {
employees.add(findOne(i));
}
// Generate an "Affordance" based on this method (the "self" link)
Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel()
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))
.andAffordance(afford(methodOn(EmployeeController.class).search(null, null)));
// Return the collection of employee resources along with the composite affordance
return new Resources<>(employees, selfLink);
}
@GetMapping("/employees/search")
public Resources<Resource<Employee>> search(@RequestParam(value="name", required=false) String name,
@RequestParam(value="role", required=false) String role) {
// Create a list of Resource<Employee>'s to return
List<Resource<Employee>> employees = new ArrayList<>();
// Fetch each Resource<Employee> using the controller's findOne method.
for (int i = 0; i < EMPLOYEES.size(); i++) {
Resource<Employee> employeeResource = findOne(i);
boolean nameMatches = Optional.ofNullable(name)
.map(s -> employeeResource.getContent().getName().contains(s))
.orElse(true);
boolean roleMatches = Optional.ofNullable(role)
.map( s -> employeeResource.getContent().getRole().contains(s))
.orElse(true);
if (nameMatches && roleMatches) {
employees.add(employeeResource);
}
}
// Generate an "Affordance" based on this method (the "self" link)
Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel()
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))
.andAffordance(afford(methodOn(EmployeeController.class).search(null, null)));
// Return the collection of employee resources along with the composite affordance
return new Resources<>(employees, selfLink);
}
@GetMapping("/employees/{id}")
public Resource<Employee> findOne(@PathVariable Integer id) {
// Start the affordance with the "self" link, i.e. this method.
Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel();
// Define final link as means to find entire collection.
Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees");
// Return the affordance + a link back to the entire collection resource.
return new Resource<>(EMPLOYEES.get(id),
findOneLink.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) //
.andAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))),
employeesLink);
}
@PostMapping("/employees")
public ResponseEntity<?> newEmployee(@RequestBody Resource<Employee> employee) {
int newEmployeeId = EMPLOYEES.size();
EMPLOYEES.put(newEmployeeId, employee.getContent());
try {
return ResponseEntity.created(new URI(findOne(newEmployeeId).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@PutMapping("/employees/{id}")
public ResponseEntity<?> updateEmployee(@RequestBody Resource<Employee> employee, @PathVariable Integer id) {
EMPLOYEES.put(id, employee.getContent());
try {
return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@PatchMapping("/employees/{id}")
public ResponseEntity<?> partiallyUpdateEmployee(@RequestBody Resource<Employee> employee, @PathVariable Integer id) {
Employee oldEmployee = EMPLOYEES.get(id);
Employee newEmployee = oldEmployee;
if (employee.getContent().getName() != null) {
newEmployee = newEmployee.withName(employee.getContent().getName());
}
if (employee.getContent().getRole() != null) {
newEmployee = newEmployee.withRole(employee.getContent().getRole());
}
EMPLOYEES.put(id, newEmployee);
try {
return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type = { HypermediaType.COLLECTION_JSON})
static class TestConfig {
@Bean
EmployeeController employeeController() {
return new EmployeeController();
}
}
}

View File

@@ -0,0 +1,251 @@
/*
* 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.assertj.core.api.Assertions.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.AbstractJackson2MarshallingIntegrationTest;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
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.SimplePojo;
import org.springframework.hateoas.support.MappingUtils;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* Integration test for Jackson 2 JSON+Collection
*
* @author Greg Turnquist
*/
public class Jackson2CollectionJsonIntegrationTest extends AbstractJackson2MarshallingIntegrationTest {
static final Links PAGINATION_LINKS = new Links(
new Link("localhost", Link.REL_SELF),
new Link("foo", Link.REL_NEXT),
new Link("bar", Link.REL_PREVIOUS));
@Before
public void setUpModule() {
mapper.registerModule(new Jackson2CollectionJsonModule());
mapper.setHandlerInstantiator(new Jackson2CollectionJsonModule.CollectionJsonHandlerInstantiator(null));
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
@Test
public void rendersSingleLinkAsObject() throws Exception {
ResourceSupport resourceSupport = new ResourceSupport();
resourceSupport.add(new Link("localhost").withSelfRel());
assertThat(write(resourceSupport)).isEqualTo(MappingUtils.read(new ClassPathResource("resource-support.json", getClass())));
}
@Test
public void deserializeSingleLink() throws Exception {
ResourceSupport expected = new ResourceSupport();
expected.add(new Link("localhost"));
assertThat(read(MappingUtils.read(new ClassPathResource("resource-support.json", getClass())), ResourceSupport.class))
.isEqualTo(expected);
}
@Test
public void rendersMultipleLinkAsArray() throws Exception {
ResourceSupport resourceSupport = new ResourceSupport();
resourceSupport.add(new Link("localhost"));
resourceSupport.add(new Link("localhost2").withRel("orders"));
assertThat(write(resourceSupport)).isEqualTo(MappingUtils.read(new ClassPathResource("resource-support-2.json", getClass())));
}
@Test
public void rendersResourceSupportBasedObject() throws Exception {
ResourceWithAttributes resource = new ResourceWithAttributes("test value");
resource.add(new Link("localhost").withSelfRel());
assertThat(write(resource)).isEqualTo(MappingUtils.read(new ClassPathResource("resource-support-3.json", getClass())));
}
@Test
public void deserializeResourceSupportBasedObject() throws Exception {
ResourceWithAttributes expected = new ResourceWithAttributes("test value");
expected.add(new Link("localhost").withSelfRel());
assertThat(read(MappingUtils.read(new ClassPathResource("resource-support-3.json", getClass())), ResourceWithAttributes.class))
.isEqualTo(expected);
}
@Test
public void deserializeMultipleLinks() throws Exception {
ResourceSupport expected = new ResourceSupport();
expected.add(new Link("localhost"));
expected.add(new Link("localhost2").withRel("orders"));
assertThat(read(MappingUtils.read(new ClassPathResource("resource-support-2.json", getClass())), ResourceSupport.class))
.isEqualTo(expected);
}
@Test
public void rendersSimpleResourcesAsEmbedded() throws Exception {
List<String> content = new ArrayList<String>();
content.add("first");
content.add("second");
Resources<String> resources = new Resources<String>(content);
resources.add(new Link("localhost"));
assertThat(write(resources)).isEqualTo(MappingUtils.read(new ClassPathResource("resources.json", getClass())));
}
@Test
public void deserializesSimpleResourcesAsEmbedded() throws Exception {
List<String> content = new ArrayList<String>();
content.add("first");
content.add("second");
Resources<String> expected = new Resources<String>(content);
expected.add(new Link("localhost"));
Resources<String> result = mapper.readValue(MappingUtils.read(new ClassPathResource("resources.json", getClass())),
mapper.getTypeFactory().constructParametricType(Resources.class, String.class));
assertThat(result).isEqualTo(expected);
}
@Test
public void renderResource() throws Exception {
Resource<String> data = new Resource<String>("first", new Link("localhost"));
assertThat(write(data)).isEqualTo(MappingUtils.read(new ClassPathResource("resource.json", getClass())));
}
@Test
public void deserializeResource() throws Exception {
Resource expected = new Resource<>("first", new Link("localhost"));
String source = MappingUtils.read(new ClassPathResource("resource.json", getClass()));
Resource<String> actual = mapper.readValue(source, mapper.getTypeFactory().constructParametricType(Resource.class, String.class));
assertThat(actual).isEqualTo(expected);
}
@Test
public void renderComplexStructure() throws Exception {
List<Resource<String>> data = new ArrayList<Resource<String>>();
data.add(new Resource<String>("first", new Link("localhost"), new Link("orders").withRel("orders")));
data.add(new Resource<String>("second", new Link("remotehost"), new Link("order").withRel("orders")));
Resources<Resource<String>> resources = new Resources<Resource<String>>(data);
resources.add(new Link("localhost"));
resources.add(new Link("/page/2").withRel("next"));
assertThat(write(resources)).isEqualTo(MappingUtils.read(new ClassPathResource("resources-with-resource-objects.json", getClass())));
}
@Test
public void deserializeResources() throws Exception {
List<Resource<String>> data = new ArrayList<Resource<String>>();
data.add(new Resource<String>("first", new Link("localhost"), new Link("orders").withRel("orders")));
data.add(new Resource<String>("second", new Link("remotehost"), new Link("order").withRel("orders")));
Resources expected = new Resources<Resource<String>>(data);
expected.add(new Link("localhost"));
expected.add(new Link("/page/2").withRel("next"));
Resources<Resource<String>> actual = mapper.readValue(MappingUtils.read(new ClassPathResource("resources-with-resource-objects.json", getClass())),
mapper.getTypeFactory().constructParametricType(Resources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, String.class)));
assertThat(actual).isEqualTo(expected);
}
@Test
public void renderSimplePojos() throws Exception {
List<Resource<SimplePojo>> data = new ArrayList<Resource<SimplePojo>>();
data.add(new Resource<>(new SimplePojo("text", 1), new Link("localhost"), new Link("orders").withRel("orders")));
data.add(new Resource<>(new SimplePojo("text2", 2), new Link("localhost")));
Resources<Resource<SimplePojo>> resources = new Resources<Resource<SimplePojo>>(data);
resources.add(new Link("localhost"));
resources.add(new Link("/page/2").withRel("next"));
assertThat(write(resources)).isEqualTo(MappingUtils.read(new ClassPathResource("resources-simple-pojos.json", getClass())));
}
@Test
public void serializesPagedResource() throws Exception {
String actual = write(setupAnnotatedPagedResources());
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("paged-resources.json", getClass())));
}
@Test
public void deserializesPagedResource() throws Exception {
PagedResources<Resource<SimplePojo>> result = mapper.readValue(MappingUtils.read(new ClassPathResource("paged-resources.json", getClass())),
mapper.getTypeFactory().constructParametricType(PagedResources.class,
mapper.getTypeFactory().constructParametricType(Resource.class, SimplePojo.class)));
assertThat(result).isEqualTo(setupAnnotatedPagedResources());
}
private static Resources<Resource<SimplePojo>> setupAnnotatedPagedResources() {
List<Resource<SimplePojo>> content = new ArrayList<Resource<SimplePojo>>();
content.add(new Resource<>(new SimplePojo("test1", 1), new Link("localhost")));
content.add(new Resource<>(new SimplePojo("test2", 2), new Link("localhost")));
return new PagedResources<>(content, null, PAGINATION_LINKS);
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ResourceWithAttributes extends ResourceSupport {
private String attribute;
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import java.io.IOException;
import java.util.Arrays;
import org.junit.Before;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.support.MappingUtils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* @author Greg Turnquist
*/
public class JacksonSerializationTest {
ObjectMapper mapper;
@Before
public void setUp() {
mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
@Test
public void createSimpleCollection() throws IOException {
CollectionJson<?> collection = new CollectionJson<>()
.withVersion("1.0")
.withHref("localhost")
.withLinks(Arrays.asList(new Link("foo").withSelfRel()))
.withItems(Arrays.asList(
new CollectionJsonItem<>()
.withHref("localhost")
.withRawData("Greetings programs")
.withLinks(Arrays.asList(new Link("localhost").withSelfRel())),
new CollectionJsonItem<>()
.withHref("localhost")
.withRawData("Yo")
.withLinks(Arrays.asList(new Link("localhost/orders").withRel("orders")))));
String actual = mapper.writeValueAsString(collection);
assertThat(actual, is(MappingUtils.read(new ClassPathResource("reference.json", getClass()))));
}
}

View File

@@ -36,6 +36,7 @@ import org.springframework.hateoas.LinkDiscoverers;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.RelProvider;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.collectionjson.CollectionJsonLinkDiscoverer;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.config.HypermediaSupportBeanDefinitionRegistrar.Jackson2ModuleRegisteringBeanPostProcessor;
import org.springframework.hateoas.core.DelegatingEntityLinks;
@@ -63,6 +64,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
* Integration tests for {@link EnableHypermediaSupport}.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
@RunWith(MockitoJUnitRunner.class)
public class EnableHypermediaSupportIntegrationTest {
@@ -77,8 +79,12 @@ public class EnableHypermediaSupportIntegrationTest {
assertHalFormsSetupForConfigClass(HalFormsConfig.class);
}
public void bootstrapJsonCollectionConfiguration() {
assertCollectionJsonSetupForConfigClass(CollectionJsonConfig.class);
}
@Test
public void registersLinkDiscoverers() {
public void registersHalLinkDiscoverers() {
withContext(HalConfig.class, context -> {
@@ -104,6 +110,19 @@ public class EnableHypermediaSupportIntegrationTest {
});
}
@Test
public void registersCollectionJsonLinkDiscoverers() {
withContext(CollectionJsonConfig.class, context -> {
LinkDiscoverers discoverers = context.getBean(LinkDiscoverers.class);
assertThat(discoverers).isNotNull();
assertThat(discoverers.getLinkDiscovererFor(MediaTypes.COLLECTION_JSON)).isInstanceOf(CollectionJsonLinkDiscoverer.class);
assertRelProvidersSetUp(context);
});
}
@Test
public void bootstrapsHalConfigurationForSubclass() {
assertHalSetupForConfigClass(ExtendedHalConfig.class);
@@ -114,6 +133,11 @@ public class EnableHypermediaSupportIntegrationTest {
assertHalFormsSetupForConfigClass(ExtendedHalFormsConfig.class);
}
@Test
public void bootstrapsCollectionJsonConfigurationForSubclass() {
assertCollectionJsonSetupForConfigClass(ExtendedCollectionJsonConfig.class);
}
/**
* @see #134, #219
*/
@@ -190,6 +214,44 @@ public class EnableHypermediaSupportIntegrationTest {
});
}
@Test
@SuppressWarnings("unchecked")
public void collectionJsonSetupIsAppliedToAllTransitiveComponentsInRequestMappingHandlerAdapter() {
withContext(CollectionJsonConfig.class, context -> {
Jackson2ModuleRegisteringBeanPostProcessor postProcessor = new HypermediaSupportBeanDefinitionRegistrar.Jackson2ModuleRegisteringBeanPostProcessor();
postProcessor.setBeanFactory(context.getAutowireCapableBeanFactory());
RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class);
assertThat(adapter.getMessageConverters().get(0).getSupportedMediaTypes())
.hasSize(1)
.contains(MediaTypes.COLLECTION_JSON);
boolean found = false;
for (HandlerMethodArgumentResolver resolver : getResolvers(adapter)) {
if (resolver instanceof AbstractMessageConverterMethodArgumentResolver) {
found = true;
AbstractMessageConverterMethodArgumentResolver processor = (AbstractMessageConverterMethodArgumentResolver) resolver;
List<HttpMessageConverter<?>> converters = (List<HttpMessageConverter<?>>) ReflectionTestUtils
.getField(processor, "messageConverters");
assertThat(converters.get(0)).isInstanceOf(TypeConstrainedMappingJackson2HttpMessageConverter.class);
assertThat(converters.get(0).getSupportedMediaTypes())
.hasSize(1)
.contains(MediaTypes.COLLECTION_JSON);
}
}
assertThat(found).isTrue();
});
}
/**
* @see #293
*/
@@ -217,6 +279,18 @@ public class EnableHypermediaSupportIntegrationTest {
});
}
@Test
public void registersCollectionJsonHttpMessageConvertersForRestTemplate() {
withContext(CollectionJsonConfig.class, context -> {
RestTemplate template = context.getBean(RestTemplate.class);
assertThat(template.getMessageConverters().get(0).getSupportedMediaTypes())
.hasSize(1)
.contains(MediaTypes.COLLECTION_JSON);
});
}
/**
* @see #341
*/
@@ -231,6 +305,31 @@ public class EnableHypermediaSupportIntegrationTest {
});
}
/**
* @see #341
*/
@Test
public void configuresDefaultObjectMapperForHalFormsToIgnoreUnknownProperties() {
withContext(HalFormsConfig.class, context -> {
ObjectMapper mapper = context.getBean("_halFormsObjectMapper", ObjectMapper.class);
assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
});
}
@Test
public void configuresDefaultObjectMapperForCollectionJsonToIgnoreUnknownProperties() {
withContext(CollectionJsonConfig.class, context -> {
ObjectMapper mapper = context.getBean("_collectionJsonObjectMapper", ObjectMapper.class);
assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
});
}
@Test
public void verifyDefaultHalConfigurationRendersSingleItemAsSingleItem() throws JsonProcessingException {
@@ -266,23 +365,10 @@ public class EnableHypermediaSupportIntegrationTest {
ConsumerWithException<AnnotationConfigApplicationContext, E> consumer) throws E {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) {
if (context.containsBean("_halFormsObjectMapper")) {
ObjectMapper mapper = context.getBean("_halFormsObjectMapper", ObjectMapper.class);
assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
}
consumer.accept(context);
}
}
public void configuresDefaultObjectMapperForHalFormsToIgnoreUnknownProperties() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(HalFormsConfig.class);
ObjectMapper mapper = context.getBean("_halFormsObjectMapper", ObjectMapper.class);
assertThat(mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();
context.close();
}
private static void assertEntityLinksSetUp(ApplicationContext context) {
assertThat(context.getBeansOfType(EntityLinks.class).values()) //
@@ -323,6 +409,21 @@ public class EnableHypermediaSupportIntegrationTest {
});
}
@SuppressWarnings({ "unchecked" })
private static void assertCollectionJsonSetupForConfigClass(Class<?> configClass) {
withContext(configClass, context -> {
assertEntityLinksSetUp(context);
assertThat(context.getBean(LinkDiscoverer.class)).isInstanceOf(CollectionJsonLinkDiscoverer.class);
assertThat(context.getBean(ObjectMapper.class)).isNotNull();
RequestMappingHandlerAdapter rmha = context.getBean(RequestMappingHandlerAdapter.class);
assertThat(rmha.getMessageConverters().get(0)).isInstanceOf(MappingJackson2HttpMessageConverter.class);
});
}
/**
* Method to mitigate API changes between Spring 3.2 and 4.0.
*
@@ -436,5 +537,34 @@ public class EnableHypermediaSupportIntegrationTest {
void accept(T element) throws E;
}
@Configuration
@Import(AlternateDelegateConfig.class)
static class CollectionJsonConfig {
static int numberOfMessageConverters = 0;
static int numberOfMessageConvertersLegacy = 0;
@Bean
public RequestMappingHandlerAdapter rmh() {
RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
numberOfMessageConverters = adapter.getMessageConverters().size();
return adapter;
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Configuration
static class ExtendedCollectionJsonConfig extends CollectionJsonConfig {
}
@EnableHypermediaSupport(type = HypermediaType.COLLECTION_JSON)
static class AlternateDelegateConfig {
}
}

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.hateoas.core;
import org.junit.Ignore;
import org.junit.Test;
import org.springframework.http.MediaType;
@@ -30,11 +31,13 @@ public class JsonPathLinkDiscovererUnitTest {
new JsonPathLinkDiscoverer(null, MediaType.ALL);
}
@Ignore
@Test(expected = IllegalArgumentException.class)
public void rejectsPatternWithWithoutPlaceholder() {
new JsonPathLinkDiscoverer("$links", MediaType.ALL);
}
@Ignore
@Test(expected = IllegalArgumentException.class)
public void rejectsPatternWithMultiplePlaceholders() {
new JsonPathLinkDiscoverer("$links%s%s", MediaType.ALL);

View File

@@ -1,66 +1,14 @@
package org.springframework.hateoas.hal;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SimplePojo {
private String text;
private int number;
public SimplePojo() {
}
public SimplePojo(String text, int number) {
this.text = text;
this.number = number;
}
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + number;
result = prime * result + ((text == null) ? 0 : text.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
SimplePojo other = (SimplePojo) obj;
if (number != other.number) {
return false;
}
if (text == null) {
if (other.text != null) {
return false;
}
} else if (!text.equals(other.text)) {
return false;
}
return true;
}
}

View File

@@ -41,6 +41,7 @@ import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.support.Employee;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
@@ -88,8 +89,8 @@ public class HalFormsValidationIntegrationTest {
.andReturn() //
.getResolvedException();
assertThat(exception.getMessage(), containsString("Affordance's URI /employees"));
assertThat(exception.getMessage(), containsString("doesn't match self link /employees/0"));
assertThat(exception.getMessage(), containsString("Affordance's URI http://localhost/employees"));
assertThat(exception.getMessage(), containsString("doesn't match self link http://localhost/employees/0"));
}
@Test
@@ -99,8 +100,8 @@ public class HalFormsValidationIntegrationTest {
.andExpect(status().is5xxServerError()) //
.andReturn().getResolvedException();
assertThat(exception.getMessage(), containsString("Affordance's URI /employees/0"));
assertThat(exception.getMessage(), containsString("doesn't match self link /employees"));
assertThat(exception.getMessage(), containsString("Affordance's URI http://localhost/employees/0"));
assertThat(exception.getMessage(), containsString("doesn't match self link http://localhost/employees"));
}
/**

View File

@@ -19,6 +19,7 @@ import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
@@ -35,12 +36,16 @@ import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.support.Employee;
import org.springframework.hateoas.support.MappingUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
@@ -56,8 +61,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* @author Greg Turnquist
*/
@@ -67,7 +70,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
public class HalFormsWebMvcIntegrationTest {
@Autowired WebApplicationContext context;
@Autowired ObjectMapper mapper;
MockMvc mockMvc;
@@ -124,6 +126,19 @@ public class HalFormsWebMvcIntegrationTest {
.andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)));
}
@Test
public void createNewEmployee() throws Exception {
String specBasedJson = MappingUtils.read(new ClassPathResource("new-employee.json", getClass()));
this.mockMvc.perform(post("/employees")
.content(specBasedJson)
.contentType(MediaTypes.HAL_FORMS_JSON_VALUE))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
}
@RestController
static class EmployeeController {
@@ -177,7 +192,7 @@ public class HalFormsWebMvcIntegrationTest {
EMPLOYEES.put(newEmployeeId, employee);
try {
return ResponseEntity.noContent().location(new URI(findOne(newEmployeeId).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
return ResponseEntity.created(new URI(findOne(newEmployeeId).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());

View File

@@ -0,0 +1,421 @@
/*
* 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.mvc;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.support.Employee;
import org.springframework.hateoas.support.MappingUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
/**
* Test one controller, rendered into multiple mediatypes.
*
* @author Greg Turnquist
*/
@RunWith(SpringRunner.class)
@WebAppConfiguration
@ContextConfiguration
public class MultiMediatypeWebMvcIntegrationTest {
@Autowired WebApplicationContext context;
MockMvc mockMvc;
private static Map<Integer, Employee> EMPLOYEES;
@Before
public void setUp() {
this.mockMvc = webAppContextSetup(this.context).build();
EMPLOYEES = new TreeMap<>();
EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer"));
EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar"));
}
@Test
public void singleEmployeeCollectionJson() throws Exception {
this.mockMvc.perform(get("/employees/0").accept(MediaTypes.COLLECTION_JSON_VALUE)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees/0")))
.andExpect(jsonPath("$.collection.links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("ring bearer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@Test
public void collectionOfEmployeesCollectionJson() throws Exception {
this.mockMvc.perform(get("/employees").accept(MediaTypes.COLLECTION_JSON_VALUE)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(2)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("Frodo Baggins")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("ring bearer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items[1].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[1].data[1].value", is("Bilbo Baggins")))
.andExpect(jsonPath("$.collection.items[1].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[1].data[0].value", is("burglar")))
.andExpect(jsonPath("$.collection.items[1].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[1].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[1].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@Test
public void createNewEmployeeCollectionJson() throws Exception {
String specBasedJson = MappingUtils.read(new ClassPathResource("../collectionjson/spec-part7-adjusted.json", getClass()));
this.mockMvc.perform(post("/employees")
.content(specBasedJson)
.contentType(MediaTypes.COLLECTION_JSON_VALUE))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
this.mockMvc.perform(get("/employees/2").accept(MediaTypes.COLLECTION_JSON))
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.collection.version", is("1.0")))
.andExpect(jsonPath("$.collection.href", is("http://localhost/employees/2")))
.andExpect(jsonPath("$.collection.links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.items.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].data[1].name", is("name")))
.andExpect(jsonPath("$.collection.items[0].data[1].value", is("W. Chandry")))
.andExpect(jsonPath("$.collection.items[0].data[0].name", is("role")))
.andExpect(jsonPath("$.collection.items[0].data[0].value", is("developer")))
.andExpect(jsonPath("$.collection.items[0].links.*", hasSize(1)))
.andExpect(jsonPath("$.collection.items[0].links[0].rel", is("employees")))
.andExpect(jsonPath("$.collection.items[0].links[0].href", is("http://localhost/employees")))
.andExpect(jsonPath("$.collection.template.*", hasSize(1)))
.andExpect(jsonPath("$.collection.template.data[0].name", is("name")))
.andExpect(jsonPath("$.collection.template.data[0].value", is("")))
.andExpect(jsonPath("$.collection.template.data[1].name", is("role")))
.andExpect(jsonPath("$.collection.template.data[1].value", is("")));
}
@Test
public void singleEmployeeHalForms() throws Exception {
this.mockMvc.perform(get("/employees/0").accept(MediaTypes.HAL_FORMS_JSON)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.name", is("Frodo Baggins"))).andExpect(jsonPath("$.role", is("ring bearer")))
.andExpect(jsonPath("$._links.*", hasSize(2)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/0")))
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates.*", hasSize(2)))
.andExpect(jsonPath("$._templates['default'].method", is("put")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
;
}
@Test
public void collectionOfEmployeesHalForms() throws Exception {
this.mockMvc.perform(get("/employees").accept(MediaTypes.HAL_FORMS_JSON)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$._embedded.employees[0].name", is("Frodo Baggins")))
.andExpect(jsonPath("$._embedded.employees[0].role", is("ring bearer")))
.andExpect(jsonPath("$._embedded.employees[0]._links['self'].href", is("http://localhost/employees/0")))
.andExpect(jsonPath("$._embedded.employees[1].name", is("Bilbo Baggins")))
.andExpect(jsonPath("$._embedded.employees[1].role", is("burglar")))
.andExpect(jsonPath("$._embedded.employees[1]._links['self'].href", is("http://localhost/employees/1")))
.andExpect(jsonPath("$._links.*", hasSize(1)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates.*", hasSize(1)))
.andExpect(jsonPath("$._templates['default'].method", is("post")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)));
}
@Test
public void createNewEmployeeHalForms() throws Exception {
String specBasedJson = MappingUtils.read(new ClassPathResource("../hal/forms/new-employee.json", getClass()));
this.mockMvc.perform(post("/employees")
.content(specBasedJson)
.contentType(MediaTypes.HAL_FORMS_JSON_VALUE))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(header().stringValues(HttpHeaders.LOCATION, "http://localhost/employees/2"));
this.mockMvc.perform(get("/employees/2").accept(MediaTypes.HAL_FORMS_JSON)) //
.andDo(print())
.andExpect(status().isOk()) //
.andExpect(jsonPath("$.name", is("Samwise Gamgee"))).andExpect(jsonPath("$.role", is("gardener")))
.andExpect(jsonPath("$._links.*", hasSize(2)))
.andExpect(jsonPath("$._links['self'].href", is("http://localhost/employees/2")))
.andExpect(jsonPath("$._links['employees'].href", is("http://localhost/employees")))
.andExpect(jsonPath("$._templates.*", hasSize(2)))
.andExpect(jsonPath("$._templates['default'].method", is("put")))
.andExpect(jsonPath("$._templates['default'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['default'].properties[0].required", is(true)))
.andExpect(jsonPath("$._templates['default'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['default'].properties[1].required", is(true)))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].method", is("patch")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].name", is("name")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[0].required", is(false)))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].name", is("role")))
.andExpect(jsonPath("$._templates['partiallyUpdateEmployee'].properties[1].required", is(false)));
}
@RestController
static class EmployeeController {
@GetMapping("/employees")
public Resources<Resource<Employee>> all() {
// Create a list of Resource<Employee>'s to return
List<Resource<Employee>> employees = new ArrayList<>();
// Fetch each Resource<Employee> using the controller's findOne method.
for (int i = 0; i < EMPLOYEES.size(); i++) {
employees.add(findOne(i));
}
// Generate an "Affordance" based on this method (the "self" link)
Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel()
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))
.andAffordance(afford(methodOn(EmployeeController.class).search(null, null)));
// Return the collection of employee resources along with the composite affordance
return new Resources<>(employees, selfLink);
}
@GetMapping("/employees/search")
public Resources<Resource<Employee>> search(@RequestParam(value="name", required=false) String name,
@RequestParam(value="role", required=false) String role) {
// Create a list of Resource<Employee>'s to return
List<Resource<Employee>> employees = new ArrayList<>();
// Fetch each Resource<Employee> using the controller's findOne method.
for (int i = 0; i < EMPLOYEES.size(); i++) {
Resource<Employee> employeeResource = findOne(i);
boolean nameMatches = Optional.ofNullable(name)
.map(s -> employeeResource.getContent().getName().contains(s))
.orElse(true);
boolean roleMatches = Optional.ofNullable(role)
.map( s -> employeeResource.getContent().getRole().contains(s))
.orElse(true);
if (nameMatches && roleMatches) {
employees.add(employeeResource);
}
}
// Generate an "Affordance" based on this method (the "self" link)
Link selfLink = linkTo(methodOn(EmployeeController.class).all()).withSelfRel()
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))
.andAffordance(afford(methodOn(EmployeeController.class).search(null, null)));
// Return the collection of employee resources along with the composite affordance
return new Resources<>(employees, selfLink);
}
@GetMapping("/employees/{id}")
public Resource<Employee> findOne(@PathVariable Integer id) {
// Start the affordance with the "self" link, i.e. this method.
Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel();
// Define final link as means to find entire collection.
Link employeesLink = linkTo(methodOn(EmployeeController.class).all()).withRel("employees");
// Return the affordance + a link back to the entire collection resource.
return new Resource<>(EMPLOYEES.get(id),
findOneLink.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id))) //
.andAffordance(afford(methodOn(EmployeeController.class).partiallyUpdateEmployee(null, id))),
employeesLink);
}
@PostMapping("/employees")
public ResponseEntity<?> newEmployee(@RequestBody Resource<Employee> employee) {
int newEmployeeId = EMPLOYEES.size();
EMPLOYEES.put(newEmployeeId, employee.getContent());
try {
return ResponseEntity.created(new URI(findOne(newEmployeeId).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@PutMapping("/employees/{id}")
public ResponseEntity<?> updateEmployee(@RequestBody Resource<Employee> employee, @PathVariable Integer id) {
EMPLOYEES.put(id, employee.getContent());
try {
return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@PatchMapping("/employees/{id}")
public ResponseEntity<?> partiallyUpdateEmployee(@RequestBody Resource<Employee> employee, @PathVariable Integer id) {
Employee oldEmployee = EMPLOYEES.get(id);
Employee newEmployee = oldEmployee;
if (employee.getContent().getName() != null) {
newEmployee = newEmployee.withName(employee.getContent().getName());
}
if (employee.getContent().getRole() != null) {
newEmployee = newEmployee.withRole(employee.getContent().getRole());
}
EMPLOYEES.put(id, newEmployee);
try {
return ResponseEntity.noContent().location(new URI(findOne(id).getLink(Link.REL_SELF).map(link -> link.expand().getHref()).orElse("")))
.build();
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type = { HypermediaType.COLLECTION_JSON, HypermediaType.HAL_FORMS })
static class TestConfig {
@Bean
EmployeeController employeeController() {
return new EmployeeController();
}
}
}

View File

@@ -15,7 +15,7 @@
*/
package org.springframework.hateoas.mvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;

View File

@@ -20,6 +20,8 @@ import static org.springframework.test.web.client.MockRestServiceServer.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
import lombok.Data;
import java.util.Collection;
import org.junit.Before;
@@ -33,7 +35,6 @@ import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.hal.HalConfiguration;
import org.springframework.hateoas.mvc.TypeReferences.ResourceType;
import org.springframework.hateoas.mvc.TypeReferences.ResourcesType;
import org.springframework.http.HttpMethod;
@@ -47,20 +48,31 @@ import org.springframework.web.client.RestTemplate;
* Integration tests for {@link TypeReferences}.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
public class TypeReferencesIntegrationTest {
private static final String USER = "\"firstname\" : \"Dave\", \"lastname\" : \"Matthews\"";
private static final String RESOURCE = String.format("{ \"_links\" : { \"self\" : \"/resource\" }, %s }", USER);
private static final String RESOURCES_OF_USER = String
.format("{ \"_links\" : { \"self\" : \"/resources\" }, \"_embedded\" : { \"users\" : [ { %s } ] }}", USER);
private static final String RESOURCES_OF_RESOURCE = String
.format("{ \"_links\" : { \"self\" : \"/resources\" }, \"_embedded\" : { \"users\" : [ %s ] }}", RESOURCE);
private static final String HAL_USER = "\"firstname\" : \"Dave\", \"lastname\" : \"Matthews\"";
private static final String COLLECTION_JSON_USER = "{ \"name\" : \"firstname\", \"value\" : \"Dave\" }, { \"name\" : \"lastname\", \"value\" : \"Matthews\" }";
private static final String RESOURCE_HAL = String.format("{ \"_links\" : { \"self\" : \"/resource\" }, %s }", HAL_USER);
private static final String RESOURCES_OF_USER_HAL = String.format(
"{ \"_links\" : { \"self\" : \"/resources\" }, \"_embedded\" : { \"users\" : [ { %s } ] }}", HAL_USER);
private static final String RESOURCES_OF_RESOURCE_HAL = String.format(
"{ \"_links\" : { \"self\" : \"/resources\" }, \"_embedded\" : { \"users\" : [ %s ] }}", RESOURCE_HAL);
private static final String RESOURCE_COLLECTION_JSON =
String.format("{ \"collection\": { \"version\": \"1.0\", \"href\": \"localhost\", \"links\": [{ \"rel\": \"self\", \"href\": \"localhost\" }], \"items\": [{\"href\": \"localhost\", \"data\": [%s]}]}}", COLLECTION_JSON_USER);
private static final String RESOURCES_OF_USER_COLLECTION_JSON =
String.format("{ \"collection\": { \"version\": \"1.0\", \"href\": \"localhost\", \"links\": [{ \"rel\": \"self\", \"href\": \"localhost\" }], \"items\": [{\"href\": \"localhost\", \"data\": [%s]}]}}", COLLECTION_JSON_USER);
private static final String RESOURCES_OF_RESOURCE_COLLECTION_JSON =
String.format("{ \"collection\": { \"version\": \"1.0\", \"href\": \"localhost\", \"links\": [{ \"rel\": \"self\", \"href\": \"localhost\" }], \"items\": [{\"href\": \"localhost\", \"data\": [%s], \"links\": [{\"rel\":\"self\", \"href\": \"localhost\"}]}]}}", COLLECTION_JSON_USER);
@Configuration
@EnableHypermediaSupport(type = HypermediaType.HAL)
@EnableHypermediaSupport(type = { HypermediaType.HAL, HypermediaType.COLLECTION_JSON})
static class Config {
public @Bean RestTemplate template() {
@@ -80,9 +92,23 @@ public class TypeReferencesIntegrationTest {
* @see #306
*/
@Test
public void usesResourceTypeReference() {
public void usesResourceTypeReferenceWithHal() {
server.expect(requestTo("/resource")).andRespond(withSuccess(RESOURCE, MediaTypes.HAL_JSON));
server.expect(requestTo("/resource")).andRespond(withSuccess(RESOURCE_HAL, MediaTypes.HAL_JSON));
ResponseEntity<Resource<User>> response = template.exchange("/resource", HttpMethod.GET, null,
new ResourceType<User>() {});
assertExpectedUserResource(response.getBody());
}
/**
* @see #482
*/
@Test
public void usesResourceTypeReferenceWithCollectionJson() {
server.expect(requestTo("/resource")).andRespond(withSuccess(RESOURCE_COLLECTION_JSON, MediaTypes.COLLECTION_JSON));
ResponseEntity<Resource<User>> response = template.exchange("/resource", HttpMethod.GET, null,
new ResourceType<User>() {});
@@ -94,9 +120,29 @@ public class TypeReferencesIntegrationTest {
* @see #306
*/
@Test
public void usesResourcesTypeReference() {
public void usesResourcesTypeReferenceWithHal() {
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_USER, MediaTypes.HAL_JSON));
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_USER_HAL, MediaTypes.HAL_JSON));
ResponseEntity<Resources<User>> response = template.exchange("/resources", HttpMethod.GET, null,
new ResourcesType<User>() {});
Resources<User> body = response.getBody();
assertThat(body.hasLink("self")).isTrue();
Collection<User> nested = body.getContent();
assertThat(nested).hasSize(1);
assertExpectedUser(nested.iterator().next());
}
/**
* @see #482
*/
@Test
public void usesResourcesTypeReferenceWithCollectionJson() {
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_USER_COLLECTION_JSON, MediaTypes.COLLECTION_JSON));
ResponseEntity<Resources<User>> response = template.exchange("/resources", HttpMethod.GET, null,
new ResourcesType<User>() {});
@@ -114,9 +160,29 @@ public class TypeReferencesIntegrationTest {
* @see #306
*/
@Test
public void usesResourcesOfResourceTypeReference() {
public void usesResourcesOfResourceTypeReferenceWithHal() {
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_RESOURCE, MediaTypes.HAL_JSON));
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_RESOURCE_HAL, MediaTypes.HAL_JSON));
ResponseEntity<Resources<Resource<User>>> response = template.exchange("/resources", HttpMethod.GET, null,
new ResourcesType<Resource<User>>() {});
Resources<Resource<User>> body = response.getBody();
assertThat(body.hasLink("self")).isTrue();
Collection<Resource<User>> nested = body.getContent();
assertThat(nested).hasSize(1);
assertExpectedUserResource(nested.iterator().next());
}
/**
* @see #482
*/
@Test
public void usesResourcesOfResourceTypeReferenceWithCollectionJson() {
server.expect(requestTo("/resources")).andRespond(withSuccess(RESOURCES_OF_RESOURCE_COLLECTION_JSON, MediaTypes.COLLECTION_JSON));
ResponseEntity<Resources<Resource<User>>> response = template.exchange("/resources", HttpMethod.GET, null,
new ResourcesType<Resource<User>>() {});
@@ -142,6 +208,7 @@ public class TypeReferencesIntegrationTest {
assertThat(user.lastname).isEqualTo("Matthews");
}
@Data
static class User {
public String firstname, lastname;
}

View File

@@ -13,18 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.hateoas.hal.forms;
package org.springframework.hateoas.support;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.experimental.Wither;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* @author Greg Turnquist
*/
@Data
@Wither
class Employee {
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Employee {
private final String name;
private final String role;
private String name;
private String role;
Employee() {
this(null, null);
}
}

View File

@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.hateoas.hal.forms;
package org.springframework.hateoas.support;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -27,7 +27,7 @@ import org.springframework.hateoas.ResourceSupport;
@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
class EmployeeResource extends ResourceSupport {
public class EmployeeResource extends ResourceSupport {
private String name;
}

View File

@@ -1,5 +1,9 @@
/*
<<<<<<< HEAD
* Copyright 2015-2017 the original author or authors.
=======
* Copyright 2015 the original author or authors.
>>>>>>> 0c39f92... #482 - Add support for Collection+JSON media type
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -51,6 +55,7 @@ public final class MappingUtils {
}
return builder.toString();
} finally {
if (scanner != null) {
scanner.close();

View File

@@ -0,0 +1,141 @@
/*
* 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 static org.assertj.core.api.Assertions.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.lang.reflect.Method;
import java.util.AbstractMap.SimpleEntry;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.springframework.core.ResolvableType;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.core.MethodParameters;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* @author Greg Turnquist
*/
public class PropertyUtilsTest {
@Test
public void simpleObject() {
Employee employee = new Employee("Frodo Baggins", "ring bearer");
Map<String, Object> properties = PropertyUtils.findProperties(employee);
assertThat(properties).hasSize(2);
assertThat(properties.keySet()).contains("name", "role");
assertThat(properties.get("name")).isEqualTo("Frodo Baggins");
assertThat(properties.get("role")).isEqualTo("ring bearer");
}
@Test
public void simpleObjectWrappedAsResource() {
Employee employee = new Employee("Frodo Baggins", "ring bearer");
Resource<Employee> employeeResource = new Resource<>(employee);
Map<String, Object> properties = PropertyUtils.findProperties(employeeResource);
assertThat(properties).hasSize(2);
assertThat(properties.keySet()).contains("name", "role");
assertThat(properties.get("name")).isEqualTo("Frodo Baggins");
assertThat(properties.get("role")).isEqualTo("ring bearer");
}
@Test
public void resourceWrappedSpringMvcParameter() throws NoSuchMethodException {
Method method = ReflectionUtils.findMethod(TestController.class, "newEmployee", Resource.class);
MethodParameters parameters = new MethodParameters(method);
ResolvableType resolvableType = parameters.getParametersWith(RequestBody.class).stream()
.findFirst()
.map(methodParameter -> ResolvableType.forMethodParameter(methodParameter.getMethod(), methodParameter.getParameterIndex()))
.orElseThrow(() -> new RuntimeException("Didn't find a parameter annotated with @RequestBody!"));
List<String> properties = PropertyUtils.findProperties(resolvableType);
assertThat(properties).hasSize(2);
assertThat(properties).contains("name", "role");
}
@Test
public void objectWithIgnorableAttributes() {
EmployeeWithCustomizedReaders employee = new EmployeeWithCustomizedReaders("Frodo", "Baggins", "ring bearer", "password", "fbaggins");
Map<String, Object> properties = PropertyUtils.findProperties(employee);
assertThat(properties).hasSize(6);
assertThat(properties.keySet()).containsExactlyInAnyOrder("firstName", "lastName", "role", "username", "fullName", "usernameAndLastName");
assertThat(properties.entrySet()).containsExactlyInAnyOrder(
new SimpleEntry<>("firstName", "Frodo"),
new SimpleEntry<>("lastName", "Baggins"),
new SimpleEntry<>("role", "ring bearer"),
new SimpleEntry<>("username", "fbaggins"),
new SimpleEntry<>("fullName", "Frodo Baggins"),
new SimpleEntry<>("usernameAndLastName", "fbaggins+++Baggins"));
}
@Data
@AllArgsConstructor
static class EmployeeWithCustomizedReaders {
private String firstName;
private String lastName;
private String role;
@JsonIgnore private String password;
@JsonIgnore(false) private String username;
public String getFullName() {
return this.firstName + " " + this.lastName;
}
@JsonIgnore
public String getEncodedPassword() {
return "{bcrypt}" + this.password;
}
@JsonIgnore(false)
public String getUsernameAndLastName() {
return this.username + "+++" + this.lastName;
}
}
@RestController
static class TestController {
@GetMapping("/")
public Employee newEmployee(@RequestBody Resource<Employee> employee) {
return employee.getContent();
}
}
}

View File

@@ -0,0 +1,32 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "next",
"href" : "foo"
}, {
"rel" : "prev",
"href" : "bar"
} ],
"items" : [ {
"href" : "localhost",
"data" : [ {
"name" : "number",
"value" : 1
}, {
"name" : "text",
"value" : "test1"
} ]
}, {
"href" : "localhost",
"data" : [ {
"name" : "number",
"value" : 2
}, {
"name" : "text",
"value" : "test2"
} ]
} ]
}
}

View File

@@ -0,0 +1,27 @@
{
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "self",
"href" : "foo"
} ],
"items" : [ {
"href" : "localhost",
"data" : [ {
"value" : "Greetings programs"
} ],
"links" : [ {
"rel" : "self",
"href" : "localhost"
} ]
}, {
"href" : "localhost",
"data" : [ {
"value" : "Yo"
} ],
"links" : [ {
"rel" : "orders",
"href" : "localhost/orders"
} ]
} ]
}

View File

@@ -0,0 +1,10 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "orders",
"href" : "localhost2"
} ]
}
}

View File

@@ -0,0 +1,13 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"items" : [ {
"href" : "localhost",
"data" : [ {
"name" : "attribute",
"value" : "test value"
} ]
} ]
}
}

View File

@@ -0,0 +1,6 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost"
}
}

View File

@@ -0,0 +1,20 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "self",
"href" : "localhost"
} ],
"items" : [ {
"href" : null,
"data" : [ "first" ],
"links" : null
} ],
"template" : {
"data" : [
"firstName" :
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"items" : [ {
"href" : "localhost",
"data" : [ {
"value" : "first"
} ]
} ]
}
}

View File

@@ -0,0 +1,33 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "next",
"href" : "/page/2"
} ],
"items" : [ {
"href" : "localhost",
"data" : [ {
"name" : "number",
"value" : 1
}, {
"name" : "text",
"value" : "text"
} ],
"links" : [ {
"rel" : "orders",
"href" : "orders"
} ]
}, {
"href" : "localhost",
"data" : [ {
"name" : "number",
"value" : 2
}, {
"name" : "text",
"value" : "text2"
} ]
} ]
}
}

View File

@@ -0,0 +1,29 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"links" : [ {
"rel" : "next",
"href" : "/page/2"
} ],
"items" : [ {
"href" : "localhost",
"data" : [ {
"value" : "first"
} ],
"links" : [ {
"rel" : "orders",
"href" : "orders"
} ]
}, {
"href" : "remotehost",
"data" : [ {
"value" : "second"
} ],
"links" : [ {
"rel" : "orders",
"href" : "order"
} ]
} ]
}
}

View File

@@ -0,0 +1,17 @@
{
"collection" : {
"version" : "1.0",
"href" : "localhost",
"items" : [ {
"href" : null,
"data" : [ {
"value" : "first"
} ]
}, {
"href" : null,
"data" : [ {
"value" : "second"
} ]
} ]
}
}

View File

@@ -0,0 +1,6 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/"
}
}

View File

@@ -0,0 +1,135 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/",
"links": [
{
"rel": "feed",
"href": "http://example.org/friends/rss"
}
],
"items": [
{
"href": "http://example.org/friends/jdoe",
"data": [
{
"name": "fullname",
"value": "J. Doe",
"prompt": "Full Name"
},
{
"name": "email",
"value": "jdoe@example.org",
"prompt": "Email"
}
],
"links": [
{
"rel": "blog",
"href": "http://examples.org/blogs/jdoe",
"prompt": "Blog"
},
{
"rel": "avatar",
"href": "http://examples.org/images/jdoe",
"prompt": "Avatar",
"render": "image"
}
]
},
{
"href": "http://example.org/friends/msmith",
"data": [
{
"name": "fullname",
"value": "M. Smith",
"prompt": "Full Name"
},
{
"name": "email",
"value": "msmith@example.org",
"prompt": "Email"
}
],
"links": [
{
"rel": "blog",
"href": "http://examples.org/blogs/msmith",
"prompt": "Blog"
},
{
"rel": "avatar",
"href": "http://examples.org/images/msmith",
"prompt": "Avatar",
"render": "image"
}
]
},
{
"href": "http://example.org/friends/rwilliams",
"data": [
{
"name": "fullname",
"value": "R. Williams",
"prompt": "Full Name"
},
{
"name": "email",
"value": "rwilliams@example.org",
"prompt": "Email"
}
],
"links": [
{
"rel": "blog",
"href": "http://examples.org/blogs/rwilliams",
"prompt": "Blog"
},
{
"rel": "avatar",
"href": "http://examples.org/images/rwilliams",
"prompt": "Avatar",
"render": "image"
}
]
}
],
"queries": [
{
"rel": "search",
"href": "http://example.org/friends/search",
"prompt": "Search",
"data": [
{
"name": "search",
"value": ""
}
]
}
],
"template": {
"data": [
{
"name": "fullname",
"value": "",
"prompt": "Full Name"
},
{
"name": "email",
"value": "",
"prompt": "Email"
},
{
"name": "blog",
"value": "",
"prompt": "Blog"
},
{
"name": "avatar",
"value": "",
"prompt": "Avatar"
}
]
}
}
}

View File

@@ -0,0 +1,50 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/",
"links": [
{
"rel": "feed",
"href": "http://example.org/friends/rss"
},
{
"rel": "queries",
"href": "http://example.org/friends/?queries"
},
{
"rel": "template",
"href": "http://example.org/friends/?template"
}
],
"items": [
{
"href": "http://example.org/friends/jdoe",
"data": [
{
"name": "fullname",
"value": "J. Doe",
"prompt": "Full Name"
},
{
"name": "email",
"value": "jdoe@example.org",
"prompt": "Email"
}
],
"links": [
{
"rel": "blog",
"href": "http://examples.org/blogs/jdoe",
"prompt": "Blog"
},
{
"rel": "avatar",
"href": "http://examples.org/images/jdoe",
"prompt": "Avatar",
"render": "image"
}
]
}
]
}
}

View File

@@ -0,0 +1,19 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/",
"queries": [
{
"rel": "search",
"href": "http://example.org/friends/search",
"prompt": "Search",
"data": [
{
"name": "search",
"value": ""
}
]
}
]
}
}

View File

@@ -0,0 +1,30 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/",
"template": {
"data": [
{
"name": "full-name",
"value": "",
"prompt": "Full Name"
},
{
"name": "email",
"value": "",
"prompt": "Email"
},
{
"name": "blog",
"value": "",
"prompt": "Blog"
},
{
"name": "avatar",
"value": "",
"prompt": "Avatar"
}
]
}
}
}

View File

@@ -0,0 +1,11 @@
{
"collection": {
"version": "1.0",
"href": "http://example.org/friends/",
"error": {
"title": "Server Error",
"code": "X1C2",
"message": "The server have encountered an error, please wait and try again."
}
}
}

View File

@@ -0,0 +1,22 @@
{
"template": {
"data": [
{
"name": "name",
"value": "W. Chandry"
},
{
"name": "role",
"value": "developer"
},
{
"name": "blog",
"value": "http://example.org/blogs/wchandry"
},
{
"name": "avatar",
"value": "http://example.org/images/wchandry"
}
]
}
}

View File

@@ -0,0 +1,22 @@
{
"template": {
"data": [
{
"name": "full-name",
"value": "W. Chandry"
},
{
"name": "email",
"value": "wchandry@example.org"
},
{
"name": "blog",
"value": "http://example.org/blogs/wchandry"
},
{
"name": "avatar",
"value": "http://example.org/images/wchandry"
}
]
}
}

View File

@@ -0,0 +1,7 @@
{
"name" : "Samwise Gamgee",
"role" : "gardener",
"_links" : {
}
}