#775 - Clean up VndError implementation to match spec.

* Handle single, multiple, and nested errors.
* Retain backward compatibility with existing constructors.
This commit is contained in:
Greg Turnquist
2019-12-10 09:26:09 -06:00
parent 6bb2310682
commit 4f3be119c0
9 changed files with 366 additions and 256 deletions

View File

@@ -76,4 +76,15 @@ public class MediaTypes {
* Public constant media type for {@code application/vnd.amundsen-uber+json}.
*/
public static final MediaType UBER_JSON = MediaType.parseMediaType(UBER_JSON_VALUE);
/**
* A String equivalent of {@link MediaTypes#VND_ERROR_JSON}.
*/
public static final String VND_ERROR_JSON_VALUE = "application/vnd.error+json";
/**
* Public constant media type for {@code application/vnd.error+json}.
*/
public static final MediaType VND_ERROR_JSON = MediaType.valueOf(VND_ERROR_JSON_VALUE);
}

View File

@@ -15,42 +15,80 @@
*/
package org.springframework.hateoas.mediatype.vnderrors;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.lang.Nullable;
import org.springframework.hateoas.server.core.Relation;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
/**
* A representation model class to be rendered as specified for the media type {@code application/vnd.error}.
* A representation model class to be rendered as specified for the media type {@code application/vnd.error+json}.
*
* @see https://github.com/blongden/vnd.error
* @author Oliver Gierke
* @author Greg Turnquist
*/
public class VndErrors implements Iterable<VndErrors.VndError> {
@JsonPropertyOrder({ "message", "logref", "total", "_links", "_embedded" })
@JsonIgnoreProperties(ignoreUnknown = true)
@EqualsAndHashCode
public class VndErrors extends CollectionModel<VndErrors.VndError> {
private final List<VndError> vndErrors;
/**
* @deprecated Use {@link org.springframework.hateoas.IanaLinkRelations#HELP}
*/
@Deprecated public static final String REL_HELP = "help";
/**
* @deprecated Use {@link org.springframework.hateoas.IanaLinkRelations#DESCRIBES}
*/
@Deprecated public static final String REL_DESCRIBES = "describes";
/**
* @deprecated Use {@link org.springframework.hateoas.IanaLinkRelations#ABOUT}
*/
@Deprecated public static final String REL_ABOUT = "about";
private final List<VndError> errors;
@Getter //
@JsonInclude(value = JsonInclude.Include.NON_EMPTY) //
private final String message;
@Getter //
@JsonInclude(value = JsonInclude.Include.NON_EMPTY) //
private final Integer logref;
public VndErrors() {
this.errors = new ArrayList<>();
this.message = null;
this.logref = null;
}
/**
* Creates a new {@link VndErrors} instance containing a single {@link VndError} with the given logref, message and
* optional {@link Link}s.
*
* @param logref must not be {@literal null} or empty.
* @param message must not be {@literal null} or empty.
* @param links
*/
public VndErrors(String logref, String message, Link... links) {
this(new VndError(logref, message, links));
this(new VndError(message, null, Integer.parseInt(logref), links));
}
/**
@@ -62,9 +100,11 @@ public class VndErrors implements Iterable<VndErrors.VndError> {
Assert.notNull(error, "Error must not be null");
this.vndErrors = new ArrayList<>(errors.length + 1);
this.vndErrors.add(error);
this.vndErrors.addAll(Arrays.asList(errors));
this.errors = new ArrayList<>();
this.errors.add(error);
Collections.addAll(this.errors, errors);
this.message = null;
this.logref = null;
}
/**
@@ -73,38 +113,84 @@ public class VndErrors implements Iterable<VndErrors.VndError> {
* @param errors must not be {@literal null} or empty.
*/
@JsonCreator
public VndErrors(List<VndError> errors) {
public VndErrors(@JsonProperty("_embedded") List<VndError> errors, @JsonProperty("message") String message,
@JsonProperty("logref") Integer logref, @JsonProperty("_links") Links links) {
Assert.notNull(errors, "Errors must not be null!");
Assert.isTrue(!errors.isEmpty(), "Errors must not be empty!");
this.vndErrors = errors;
Assert.notNull(errors, "Errors must not be null!"); // Retain for compatibility
Assert.notEmpty(errors, "Errors must not be empty!");
this.errors = errors;
this.message = message;
this.logref = logref;
if (links != null && !links.isEmpty()) {
add(links);
}
}
public VndErrors withMessage(String message) {
return new VndErrors(this.errors, message, this.logref, this.getLinks());
}
public VndErrors withLogref(Integer logref) {
return new VndErrors(this.errors, this.message, logref, this.getLinks());
}
public VndErrors withErrors(List<VndError> errors) {
Assert.notNull(errors, "errors must not be null!");
Assert.notEmpty(errors, "errors must not empty!");
return new VndErrors(errors, this.message, this.logref, this.getLinks());
}
public VndErrors withError(VndError error) {
this.errors.add(error);
return new VndErrors(this.errors, this.message, this.logref, this.getLinks());
}
public VndErrors withLink(Link link) {
add(link);
return new VndErrors(this.errors, this.message, this.logref, this.getLinks());
}
public VndErrors withLinks(Link... links) {
add(links);
return new VndErrors(this.errors, this.message, this.logref, this.getLinks());
}
/**
* Protected default constructor to allow JAXB marshalling.
* Returns the underlying elements.
*
* @return the content will never be {@literal null}.
*/
protected VndErrors() {
this.vndErrors = new ArrayList<>();
@Override
public Collection<VndError> getContent() {
return this.errors;
}
/**
* Virtual attribute to generate JSON field of {@literal total}. Only generated when there are multiple errors.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public Integer getTotal() {
return this.errors.size() > 1 //
? this.errors.size() //
: null; //
}
/**
* Adds an additional {@link VndError} to the wrapper.
*
* @param error
* @deprecated Use {{@link #withError(VndError)}}
*/
@Deprecated
public VndErrors add(VndError error) {
this.vndErrors.add(error);
return this;
}
/**
* Dummy method to allow {@link JsonValue} to be configured.
*
* @return the vndErrors
*/
@JsonValue
private List<VndError> getErrors() {
return vndErrors;
return withError(error);
}
/*
@@ -113,142 +199,70 @@ public class VndErrors implements Iterable<VndErrors.VndError> {
*/
@Override
public Iterator<VndErrors.VndError> iterator() {
return this.vndErrors.iterator();
return this.errors.iterator();
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return String.format("VndErrors[%s]", StringUtils.collectionToCommaDelimitedString(vndErrors));
}
/*
* (non-Javadoc)
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
return vndErrors.hashCode();
}
/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof VndErrors)) {
return false;
}
VndErrors that = (VndErrors) obj;
return this.vndErrors.equals(that.vndErrors);
return String.format("VndErrors[%s]", StringUtils.collectionToCommaDelimitedString(this.errors));
}
/**
* A single {@link VndError}.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
@JsonPropertyOrder({ "message", "path", "logref" })
@Relation(collectionRelation = "errors")
@EqualsAndHashCode
public static class VndError extends RepresentationModel<VndError> {
@JsonProperty private final String logref;
@JsonProperty private final String message;
@Getter //
private final String message;
@Getter(onMethod = @__(@JsonInclude(JsonInclude.Include.NON_EMPTY))) //
private final String path;
@Getter(onMethod = @__(@JsonInclude(JsonInclude.Include.NON_EMPTY))) //
private final Integer logref;
/**
* Creates a new {@link VndError} with the given logref, a message as well as some {@link Link}s.
* Creates a new {@link VndError} with a message and optional a path and a logref.
*
* @param logref must not be {@literal null} or empty.
* @param message must not be {@literal null} or empty.
* @param path
* @param logref must not be {@literal null} or empty.
* @param links
*/
public VndError(String logref, String message, Link... links) {
@JsonCreator
public VndError(@JsonProperty("message") String message, @JsonProperty("path") String path,
@JsonProperty("logref") Integer logref, @JsonProperty("_links") List<Link> links) {
Assert.hasText(logref, "Logref must not be null or empty!");
Assert.hasText(message, "Message must not be null or empty!");
this.logref = logref;
this.message = message;
this.add(Arrays.asList(links));
this.path = path;
this.logref = logref;
this.add(links);
}
public VndError(String message, String path, Integer logref, Link... link) {
this(message, path, logref, Arrays.asList(link));
}
/**
* Protected default constructor to allow JAXB marshalling.
* @deprecated Use {@link #VndError(String, String, Integer, Link...)} (with proper ordering of arguments)
*/
protected VndError() {
this.logref = null;
this.message = null;
@Deprecated
public VndError(String logref, String message, Link... links) {
this(message, null, Integer.parseInt(logref), Arrays.asList(links));
}
/**
* Returns the logref of the error.
*
* @return the logref
*/
public String getLogref() {
return logref;
}
/**
* Returns the message of the error.
*
* @return the message
*/
public String getMessage() {
return message;
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.ResourceSupport#toString()
*/
@Override
public String toString() {
return String.format("VndError[logref: %s, message: %s, links: [%s]]", logref, message, getLinks().toString());
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.ResourceSupport#hashCode()
*/
@Override
public int hashCode() {
int result = 17;
result += 31 * logref.hashCode();
result += 31 * message.hashCode();
return result;
}
/*
* (non-Javadoc)
* @see org.springframework.hateoas.ResourceSupport#equals(java.lang.Object)
*/
@Override
public boolean equals(@Nullable Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof VndError)) {
return false;
}
VndError that = (VndError) obj;
return this.logref.equals(that.logref) && this.message.equals(that.message);
return String.format("VndError[logref: %s, message: %s, links: [%s]]", this.logref, this.message,
getLinks().toString());
}
}
}

View File

@@ -16,24 +16,23 @@
package org.springframework.hateoas.mediatype.vnderror;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.hateoas.support.MappingUtils.*;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.mediatype.MessageResolver;
import org.springframework.hateoas.mediatype.hal.CurieProvider;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalHandlerInstantiator;
import org.springframework.hateoas.mediatype.vnderrors.VndErrors;
import org.springframework.hateoas.mediatype.vnderrors.VndErrors.VndError;
import org.springframework.hateoas.server.LinkRelationProvider;
import org.springframework.hateoas.server.core.EvoInflectorLinkRelationProvider;
import org.springframework.hateoas.server.core.AnnotationLinkRelationProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -46,58 +45,92 @@ import com.fasterxml.jackson.databind.SerializationFeature;
*/
class VndErrorsMarshallingTest {
ObjectMapper jackson2Mapper;
LinkRelationProvider relProvider = new EvoInflectorLinkRelationProvider();
VndErrors errors;
String jsonReference;
String json2Reference;
public VndErrorsMarshallingTest() throws IOException {
jsonReference = readFile(new ClassPathResource("vnderror.json"));
json2Reference = readFile(new ClassPathResource("vnderror2.json"));
}
ObjectMapper mapper;
@BeforeEach
void setUp() {
jackson2Mapper = new com.fasterxml.jackson.databind.ObjectMapper();
jackson2Mapper.registerModule(new Jackson2HalModule());
jackson2Mapper.setHandlerInstantiator(
new Jackson2HalModule.HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY));
jackson2Mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
LinkRelationProvider relProvider = new AnnotationLinkRelationProvider();
VndError error = new VndError("42", "Validation failed!", //
new Link("http://...", "describes"), new Link("http://...", "help"));
errors = new VndErrors(error, error, error);
this.mapper = new ObjectMapper();
this.mapper.registerModule(new Jackson2HalModule());
this.mapper.setHandlerInstantiator(
new HalHandlerInstantiator(relProvider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY));
this.mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
/**
* @see #62
*/
@Test
void jackson2Marshalling() throws Exception {
@Test // #93, #94, #775
void singleItemVndErrorShouldDeserialize() throws IOException {
assertThat(jackson2Mapper.writeValueAsString(errors)) //
.isEqualToIgnoringWhitespace(json2Reference);
String expected = read(new ClassPathResource("vnderror-single-item.json", getClass()));
VndError actual = new VndError("Validation failed", "/username", 42,
new Link("http://path.to/user/resource/1", IanaLinkRelations.ABOUT),
new Link("http://path.to/describes", IanaLinkRelations.DESCRIBES),
new Link("http://path.to/help", IanaLinkRelations.HELP));
assertThat(this.mapper.readValue(expected, VndError.class)).isEqualTo(actual);
}
/**
* @see #93, #94
*/
@Test
void jackson2UnMarshalling() throws Exception {
assertThat(jackson2Mapper.readValue(jsonReference, VndErrors.class)).isEqualTo(errors);
@Test // #62, #775
public void singleItemVndErrorShouldSerialize() throws IOException {
VndError error = new VndError("Validation failed", "/username", 42, //
new Link("http://path.to/user/resource/1", IanaLinkRelations.ABOUT),
new Link("http://path.to/describes", IanaLinkRelations.DESCRIBES),
new Link("http://path.to/help", IanaLinkRelations.HELP));
String json = read(new ClassPathResource("vnderror-single-item.json", getClass()));
assertThat(mapper.writeValueAsString(error)).isEqualToIgnoringWhitespace(json);
}
private static String readFile(org.springframework.core.io.Resource resource) throws IOException {
@Test // #62, #775
public void multipleItemVndErrorsShouldDeserialize() throws IOException {
try (FileInputStream stream = new FileInputStream(resource.getFile())) {
FileChannel fc = stream.getChannel();
MappedByteBuffer bb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
return Charset.defaultCharset().decode(bb).toString();
}
String json = read(new ClassPathResource("vnderror-multiple-items.json", getClass()));
VndError error1 = new VndError("\"username\" field validation failed", null, 50, //
new Link("http://.../", IanaLinkRelations.HELP));
VndError error2 = new VndError("\"postcode\" field validation failed", null, 55, //
new Link("http://.../", IanaLinkRelations.HELP));
VndErrors vndErrors = new VndErrors().withError(error1).withError(error2);
assertThat(this.mapper.readValue(json, VndErrors.class)).isEqualTo(vndErrors);
}
@Test // #775
public void multipleItemVndErrorsShouldSerialize() throws IOException {
VndError error1 = new VndError("\"username\" field validation failed", null, 50, //
new Link("http://.../", IanaLinkRelations.HELP));
VndError error2 = new VndError("\"postcode\" field validation failed", null, 55, //
new Link("http://.../", IanaLinkRelations.HELP));
VndErrors vndErrors = new VndErrors().withError(error1).withError(error2);
String json = read(new ClassPathResource("vnderror-multiple-items.json", getClass()));
assertThat(this.mapper.writeValueAsString(vndErrors)).isEqualToIgnoringWhitespace(json);
}
@Test // #775
public void nestedVndErrorsShouldSerialize() throws IOException {
VndError error = new VndError("Username must contain at least three characters", "/username", (Integer) null, //
new Link("http://path.to/user/resource/1", IanaLinkRelations.ABOUT));
VndErrors vndErrors = new VndErrors().withError(error)
.withLink(new Link("http://path.to/describes").withRel(IanaLinkRelations.DESCRIBES))
.withLink(new Link("http://path.to/help").withRel(IanaLinkRelations.HELP))
.withLink(new Link("http://path.to/user/resource/1").withRel(IanaLinkRelations.ABOUT))
.withMessage("Validation failed").withLogref(42);
String json = read(new ClassPathResource("vnderror-nested.json", getClass()));
assertThat(mapper.writeValueAsString(vndErrors)).isEqualToIgnoringWhitespace(json);
}
}

View File

@@ -17,6 +17,8 @@ package org.springframework.hateoas.mediatype.vnderror;
import static org.assertj.core.api.Assertions.*;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.mediatype.vnderrors.VndErrors;
@@ -26,17 +28,66 @@ import org.springframework.hateoas.mediatype.vnderrors.VndErrors.VndError;
* Unit tests for {@link VndErrors}.
*
* @author Oliver Gierke
* @author Greg Turnquist
*/
class VndErrorsUnitTest {
/**
* @see #775
*/
@Test
void rendersToStringCorrectly() {
void vndErrorsDoesntTakeNull() {
assertThatIllegalArgumentException().isThrownBy(() -> new VndErrors().withErrors(null));
}
VndError error = new VndErrors.VndError("logref", "message", new Link("foo", "bar"));
assertThat(error.toString()).isEqualTo("VndError[logref: logref, message: message, links: [<foo>;rel=\"bar\"]]");
/**
* @see #775
*/
@Test
public void vndErrorsDoesntTakeEmptyCollection() {
assertThatIllegalArgumentException().isThrownBy(() -> new VndErrors().withErrors(new ArrayList<>()));
}
VndErrors errors = new VndErrors(error);
assertThat(errors.toString()) //
.isEqualTo("VndErrors[VndError[logref: logref, message: message, links: [<foo>;rel=\"bar\"]]]");
/**
* @see #775
*/
@Test
public void vndErrorsUsingSingleErrorArguments() {
VndErrors errors = new VndErrors().withError(new VndError("message", "/path", 50, new Link("/link").withSelfRel()));
assertThat(errors.getTotal()).isNull();
assertThat(errors.getContent()).hasSize(1);
assertThat(errors.getContent())
.containsExactly(new VndError("message", "/path", 50, new Link("/link").withSelfRel()));
}
/**
* @see #775
*/
@Test
public void appendingVndErrorsShouldWork() {
VndErrors errors = new VndErrors().withError(new VndError("message", "/path", 50, new Link("/link").withSelfRel()));
assertThat(errors.getContent()).hasSize(1);
errors.getContent().add(new VndError("message2", "/path2", 51, new Link("/link2", "link2")));
assertThat(errors.getContent()).hasSize(2);
}
@Test
void vndErrorRendersToStringCorrectly() {
VndError error = new VndError("message", "path", 42, new Link("foo", "bar"));
assertThat(error.toString()).isEqualTo("VndError[logref: 42, message: message, links: [<foo>;rel=\"bar\"]]");
}
@Test
void vndErrorsRendersToStringCorrectly() {
VndErrors errors = new VndErrors(new VndError("message", "path", 42, new Link("foo", "bar")));
assertThat(errors.toString())
.isEqualTo("VndErrors[VndError[logref: 42, message: message, links: [<foo>;rel=\"bar\"]]]");
}
}

View File

@@ -0,0 +1,25 @@
{
"total": 2,
"_embedded": {
"errors": [
{
"message": "\"username\" field validation failed",
"logref": 50,
"_links": {
"help": {
"href": "http://.../"
}
}
},
{
"message": "\"postcode\" field validation failed",
"logref": 55,
"_links": {
"help": {
"href": "http://.../"
}
}
}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"message": "Validation failed",
"logref": 42,
"_links": {
"describes": {
"href": "http://path.to/describes"
},
"help": {
"href": "http://path.to/help"
},
"about": {
"href": "http://path.to/user/resource/1"
}
},
"_embedded": {
"errors": [
{
"message": "Username must contain at least three characters",
"path": "/username",
"_links": {
"about": {
"href": "http://path.to/user/resource/1"
}
}
}
]
}
}

View File

@@ -0,0 +1,16 @@
{
"message": "Validation failed",
"path": "/username",
"logref": 42,
"_links": {
"about": {
"href": "http://path.to/user/resource/1"
},
"describes": {
"href": "http://path.to/describes"
},
"help": {
"href": "http://path.to/help"
}
}
}

View File

@@ -1,34 +0,0 @@
[ {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
}, {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
}, {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
} ]

View File

@@ -1,34 +0,0 @@
[ {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
}, {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
}, {
"logref" : "42",
"message" : "Validation failed!",
"_links" : {
"describes" : {
"href" : "http://..."
},
"help" : {
"href" : "http://..."
}
}
} ]