#786 - Implement vendor neutral error handling with RFC-7807.

This commit is contained in:
Greg Turnquist
2019-08-30 14:25:47 -05:00
parent 4f3be119c0
commit 20bff3e26f
17 changed files with 637 additions and 4 deletions

View File

@@ -76,8 +76,7 @@ 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}.
*/
@@ -87,4 +86,14 @@ public class MediaTypes {
* Public constant media type for {@code application/vnd.error+json}.
*/
public static final MediaType VND_ERROR_JSON = MediaType.valueOf(VND_ERROR_JSON_VALUE);
/**
* A String equivalent of {@link MediaTypes#PROBLEM_JSON_VALUE}.
*/
public static final String PROBLEM_JSON_VALUE = "application/problem+json";
/**
* Public constant media type for {@code application/problem+json}.
*/
public static final MediaType PROBLEM_JSON = MediaType.parseMediaType(PROBLEM_JSON_VALUE);
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2019 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
*
* https://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.mediatype.problem;
import java.net.URI;
import java.util.Objects;
import org.springframework.http.HttpStatus;
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;
/**
* Encapsulation of an RFC-7807 {@literal Problem} code. While it complies out-of-the-box, it may also be extended to
* support domain-specific details.
*
* @author Greg Turnquist
*/
public class Problem<T extends Problem<? extends T>> {
private URI type;
private String title;
private HttpStatus status;
private String detail;
private URI instance;
public Problem() {
this(null, null, null, null, null);
}
public Problem(URI type, String title, HttpStatus status, String detail, URI instance) {
this.type = type;
this.title = title;
this.status = status;
this.detail = detail;
this.instance = instance;
}
@JsonCreator
public Problem(@JsonProperty("type") URI type, @JsonProperty("title") String title,
@JsonProperty("status") int status, @JsonProperty("detail") String detail,
@JsonProperty("instance") URI instance) {
this(type, title, HttpStatus.resolve(status), detail, instance);
}
/**
* A {@link Problem} that reflects an {@link HttpStatus} code.
*
* @see https://tools.ietf.org/html/rfc7807#section-4.2
*/
public Problem(HttpStatus httpStatus) {
this(URI.create("about:blank"), httpStatus.getReasonPhrase(), httpStatus, null, null);
}
@SuppressWarnings("unchecked")
public T withType(URI type) {
this.type = type;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withTitle(String title) {
this.title = title;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withStatus(HttpStatus status) {
this.status = status;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withDetail(String detail) {
this.detail = detail;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withInstance(URI instance) {
this.instance = instance;
return (T) this;
}
@JsonInclude(Include.NON_NULL)
public URI getType() {
return this.type;
}
@JsonInclude(Include.NON_NULL)
public String getTitle() {
return this.title;
}
@JsonInclude(Include.NON_NULL)
public Integer getStatus() {
if (status != null) {
return status.value();
}
return null;
}
@JsonInclude(Include.NON_NULL)
public String getDetail() {
return detail;
}
@JsonInclude(Include.NON_NULL)
public URI getInstance() {
return instance;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Problem problem = (Problem) o;
return Objects.equals(type, problem.type) && //
Objects.equals(title, problem.title) && //
status == problem.status && //
Objects.equals(detail, problem.detail) && //
Objects.equals(instance, problem.instance); //
}
@Override
public int hashCode() {
return Objects.hash(type, title, status, detail, instance);
}
@Override
public String toString() {
return "Problem{" + //
"type=" + type + //
", title='" + title + '\'' + //
", status=" + status + //
", detail='" + detail + '\'' + //
", instance=" + instance + //
'}';
}
}

View File

@@ -0,0 +1,5 @@
/**
* Value objects to build Problem representations.
*/
@org.springframework.lang.NonNullApi
package org.springframework.hateoas.mediatype.problem;

View File

@@ -15,10 +15,13 @@
*/
package org.springframework.hateoas.mediatype.hal.forms;
import static org.assertj.core.api.Assertions.*;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.springframework.hateoas.support.JsonPathUtils.*;
import java.net.URI;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,9 +34,11 @@ import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.hateoas.config.WebClientConfigurer;
import org.springframework.hateoas.mediatype.problem.Problem;
import org.springframework.hateoas.support.MappingUtils;
import org.springframework.hateoas.support.WebFluxEmployeeController;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
@@ -128,6 +133,23 @@ class HalFormsWebFluxIntegrationTest {
.expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2");
}
@Test
void problemReturningControllerMethod() {
Problem<?> problem = this.testClient.get().uri("http://localhost/employees/problem").accept(MediaTypes.PROBLEM_JSON) //
.exchange() //
.expectStatus().isBadRequest() //
.expectHeader().contentType(MediaTypes.PROBLEM_JSON) //
.expectBody(Problem.class) //
.returnResult().getResponseBody();
assertThat(problem).isNotNull();
assertThat(problem.getType()).isEqualTo(URI.create("http://example.com/problem"));
assertThat(problem.getTitle()).isEqualTo("Employee-based problem");
assertThat(problem.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
assertThat(problem.getDetail()).isEqualTo("This is a test case");
}
@Configuration
@EnableWebFlux
@EnableHypermediaSupport(type = { HypermediaType.HAL_FORMS })

View File

@@ -0,0 +1,345 @@
package org.springframework.hateoas.mediatype.problem;
import static org.assertj.core.api.Assertions.*;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.hateoas.support.MappingUtils;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* @author Greg Turnquist
*/
class JacksonSerializationTest {
ObjectMapper mapper;
@BeforeEach
void setUp() {
this.mapper = new ObjectMapper();
this.mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
}
@Test
void httpStatusProblemSerialize() throws IOException {
Problem problem = new Problem(HttpStatus.NOT_FOUND);
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("http-status-problem.json", getClass())));
}
@Test
void httpStatusProblemDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("http-status-problem.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isEqualTo(URI.create("about:blank"));
assertThat(actual.getTitle()).isEqualTo("Not Found");
assertThat(actual.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
assertThat(actual.getDetail()).isNull();
assertThat(actual.getInstance()).isNull();
}
@Test
void typeOnlySerialize() throws IOException {
Problem problem = new Problem().withType(URI.create("http://example.com/problem-details"));
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("type-only.json", getClass())));
}
@Test
void typeOnlyDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("type-only.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isEqualTo(URI.create("http://example.com/problem-details"));
assertThat(actual.getTitle()).isNull();
assertThat(actual.getStatus()).isNull();
assertThat(actual.getDetail()).isNull();
assertThat(actual.getInstance()).isNull();
}
@Test
void titleOnlySerialize() throws IOException {
Problem problem = new Problem().withTitle("test title");
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("title-only.json", getClass())));
}
@Test
void titleOnlyDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("title-only.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isNull();
assertThat(actual.getTitle()).isEqualTo("test title");
assertThat(actual.getStatus()).isNull();
assertThat(actual.getDetail()).isNull();
assertThat(actual.getInstance()).isNull();
}
@Test
void statusOnlySerialize() throws IOException {
Problem problem = new Problem().withStatus(HttpStatus.BAD_GATEWAY);
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("status-only.json", getClass())));
}
@Test
void statusOnlyDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("status-only.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isNull();
assertThat(actual.getTitle()).isNull();
assertThat(actual.getStatus()).isEqualTo(502);
assertThat(actual.getDetail()).isNull();
assertThat(actual.getInstance()).isNull();
}
@Test
void detailOnlySerialize() throws IOException {
Problem problem = new Problem().withDetail("test detail");
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("detail-only.json", getClass())));
}
@Test
void detailOnlyDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("detail-only.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isNull();
assertThat(actual.getTitle()).isNull();
assertThat(actual.getStatus()).isNull();
assertThat(actual.getDetail()).isEqualTo("test detail");
assertThat(actual.getInstance()).isNull();
}
@Test
void instanceOnlySerialize() throws IOException {
Problem problem = new Problem().withInstance(URI.create("http://example.com/employees/1471"));
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("instance-only.json", getClass())));
}
@Test
void instanceOnlyDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("instance-only.json", getClass()));
Problem<?> actual = this.mapper.readValue(expected, Problem.class);
assertThat(actual.getType()).isNull();
assertThat(actual.getTitle()).isNull();
assertThat(actual.getStatus()).isNull();
assertThat(actual.getDetail()).isNull();
assertThat(actual.getInstance()).isEqualTo(URI.create("http://example.com/employees/1471"));
}
@Test
void extensionSerialize() throws IOException {
AccountProblem problem = new AccountProblem() //
.withType(URI.create("https://example.com/probs/out-of-credit")) //
.withTitle("You do not have enough credit.") //
.withDetail("Your current balance is 30, but that costs 50.") //
.withInstance(URI.create("/account/12345/msgs/abc")) //
.withBalance(30) //
.withAccounts("/account/12345", "/account/67890");
String actual = this.mapper.writeValueAsString(problem);
assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("extension.json", getClass())));
}
@Test
void extensionDeserialize() throws IOException {
String expected = MappingUtils.read(new ClassPathResource("extension.json", getClass()));
AccountProblem actual = this.mapper.readValue(expected, AccountProblem.class);
assertThat(actual.getType()).isEqualTo(URI.create("https://example.com/probs/out-of-credit"));
assertThat(actual.getTitle()).isEqualTo("You do not have enough credit.");
assertThat(actual.getStatus()).isNull();
assertThat(actual.getDetail()).isEqualTo("Your current balance is 30, but that costs 50.");
assertThat(actual.getInstance()).isEqualTo(URI.create("/account/12345/msgs/abc"));
assertThat(actual.getBalance()).isEqualTo(30);
assertThat(actual.getAccounts()).containsExactlyInAnyOrder("/account/12345", "/account/67890");
}
@Test
void reference1Deserialize() throws IOException {
AccountProblem accountProblem = this.mapper
.readValue(MappingUtils.read(new ClassPathResource("reference-1.json", getClass())), AccountProblem.class);
assertThat(accountProblem.getType()).isEqualTo(URI.create("https://example.com/probs/out-of-credit"));
assertThat(accountProblem.getTitle()).isEqualTo("You do not have enough credit.");
assertThat(accountProblem.getDetail()).isEqualTo("Your current balance is 30, but that costs 50.");
assertThat(accountProblem.getInstance()).isEqualTo(URI.create("/account/12345/msgs/abc"));
assertThat(accountProblem.getBalance()).isEqualTo(30);
assertThat(accountProblem.getAccounts()).containsExactlyInAnyOrder("/account/12345", "/account/67890");
}
@Test
void reference2Deserialize() throws IOException {
InvalidParameters invalidParameters = this.mapper
.readValue(MappingUtils.read(new ClassPathResource("reference-2.json", getClass())), InvalidParameters.class);
assertThat(invalidParameters.getType()).isEqualTo(URI.create("https://example.net/validation-error"));
assertThat(invalidParameters.getTitle()).isEqualTo("Your request parameters didn't validate.");
assertThat(invalidParameters.getDetail()).isNull();
assertThat(invalidParameters.getInstance()).isNull();
assertThat(invalidParameters.getInvalidParameters()).hasSize(2);
assertThat(invalidParameters.getInvalidParameters()).containsExactly(
new InvalidParameter("age", "must be a positive integer"),
new InvalidParameter("color", "must be 'green', 'red' or 'blue'"));
}
/**
* First reference domain definition.
*
* @see https://tools.ietf.org/html/rfc7807#section-3
*/
private static class AccountProblem extends Problem<AccountProblem> {
private int balance;
private String[] accounts;
AccountProblem() {
super();
}
AccountProblem withBalance(int balance) {
this.balance = balance;
return this;
}
AccountProblem withAccounts(String... accounts) {
this.accounts = accounts;
return this;
}
public int getBalance() {
return this.balance;
}
public String[] getAccounts() {
return this.accounts;
}
}
/**
* Second reference domain definition.
*
* @see https://tools.ietf.org/html/rfc7807#section-3
*/
private static class InvalidParameters extends Problem<InvalidParameters> {
private List<InvalidParameter> invalidParameters;
@JsonCreator
public InvalidParameters(@JsonProperty("invalid-params") List<InvalidParameter> invalidParameters) {
super();
this.invalidParameters = invalidParameters;
}
public List<InvalidParameter> getInvalidParameters() {
return invalidParameters;
}
}
private static class InvalidParameter {
private String name;
private String reason;
InvalidParameter() {}
InvalidParameter(String name, String reason) {
this.name = name;
this.reason = reason;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
InvalidParameter that = (InvalidParameter) o;
return Objects.equals(name, that.name) && Objects.equals(reason, that.reason);
}
@Override
public int hashCode() {
return Objects.hash(name, reason);
}
@Override
public String toString() {
return "InvalidParameter{" + //
"name='" + name + '\'' + //
", reason='" + reason + '\'' + //
'}';
}
}
}

View File

@@ -16,6 +16,7 @@
package org.springframework.hateoas.server.mvc;
import static org.assertj.core.api.AssertionsForClassTypes.*;
import static org.hamcrest.CoreMatchers.*;
import static org.springframework.hateoas.MediaTypes.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -42,6 +43,7 @@ import org.springframework.hateoas.server.RepresentationModelProcessor;
import org.springframework.hateoas.support.Employee;
import org.springframework.hateoas.support.WebMvcEmployeeController;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
@@ -105,6 +107,19 @@ public class RepresentationModelProcessorIntegrationTest {
assertThat(wildcardProcessor.isTriggered()).isTrue();
}
@Test
public void problemReturningControllerMethod() throws Exception {
this.mockMvc.perform(get("/employees/problem").accept(PROBLEM_JSON)) //
.andExpect(content().contentType(PROBLEM_JSON)) //
.andExpect(status().is(HttpStatus.BAD_REQUEST.value())) //
.andExpect(jsonPath("$.type", is("http://example.com/problem"))) //
.andExpect(jsonPath("$.title", is("Employee-based problem"))) //
.andExpect(jsonPath("$.status", is(HttpStatus.BAD_REQUEST.value()))) //
.andExpect(jsonPath("$.detail", is("This is a test case")));
}
@Test
public void entityModelProcessorShouldWork() throws Exception {

View File

@@ -21,14 +21,16 @@ import static org.springframework.hateoas.mediatype.alps.Alps.*;
import static org.springframework.hateoas.server.reactive.WebFluxLinkBuilder.*;
import static reactor.function.TupleUtils.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
@@ -40,7 +42,9 @@ import org.springframework.hateoas.mediatype.alps.Descriptor;
import org.springframework.hateoas.mediatype.alps.Ext;
import org.springframework.hateoas.mediatype.alps.Format;
import org.springframework.hateoas.mediatype.alps.Type;
import org.springframework.hateoas.mediatype.problem.Problem;
import org.springframework.hateoas.server.reactive.WebFluxLinkBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -210,4 +214,14 @@ public class WebFluxEmployeeController {
.collect(Collectors.toList()))
.build();
}
@GetMapping("/employees/problem")
public ResponseEntity<?> problem() {
return ResponseEntity.badRequest().body(new Problem() //
.withType(URI.create("http://example.com/problem")) //
.withTitle("Employee-based problem") //
.withStatus(HttpStatus.BAD_REQUEST) //
.withDetail("This is a test case"));
}
}

View File

@@ -20,6 +20,7 @@ import static org.springframework.hateoas.mediatype.PropertyUtils.*;
import static org.springframework.hateoas.mediatype.alps.Alps.*;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -38,6 +39,8 @@ import org.springframework.hateoas.mediatype.alps.Descriptor;
import org.springframework.hateoas.mediatype.alps.Ext;
import org.springframework.hateoas.mediatype.alps.Format;
import org.springframework.hateoas.mediatype.alps.Type;
import org.springframework.hateoas.mediatype.problem.Problem;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -211,4 +214,14 @@ public class WebMvcEmployeeController {
.build();
}
// end::alps-profile[]
@GetMapping("/employees/problem")
public ResponseEntity<?> problem() {
return ResponseEntity.badRequest().body(new Problem<>() //
.withType(URI.create("http://example.com/problem")) //
.withTitle("Employee-based problem") //
.withStatus(HttpStatus.BAD_REQUEST) //
.withDetail("This is a test case"));
}
}

View File

@@ -0,0 +1,3 @@
{
"detail" : "test detail"
}

View File

@@ -0,0 +1,8 @@
{
"type" : "https://example.com/probs/out-of-credit",
"title" : "You do not have enough credit.",
"detail" : "Your current balance is 30, but that costs 50.",
"instance" : "/account/12345/msgs/abc",
"balance" : 30,
"accounts" : [ "/account/12345", "/account/67890" ]
}

View File

@@ -0,0 +1,5 @@
{
"type" : "about:blank",
"title" : "Not Found",
"status" : 404
}

View File

@@ -0,0 +1,3 @@
{
"instance" : "http://example.com/employees/1471"
}

View File

@@ -0,0 +1,9 @@
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345",
"/account/67890"]
}

View File

@@ -0,0 +1,12 @@
{
"type": "https://example.net/validation-error",
"title": "Your request parameters didn't validate.",
"invalid-params": [ {
"name": "age",
"reason": "must be a positive integer"
},
{
"name": "color",
"reason": "must be 'green', 'red' or 'blue'"}
]
}

View File

@@ -0,0 +1,3 @@
{
"status" : 502
}

View File

@@ -0,0 +1,3 @@
{
"title" : "test title"
}

View File

@@ -0,0 +1,3 @@
{
"type" : "http://example.com/problem-details"
}