From cba5d0480f9b637cd145d3bd4ce5976f794d2876 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Fri, 30 Aug 2019 14:25:47 -0500 Subject: [PATCH] #786 - Implement vendor neutral error handling with RFC-7807. Supercedes: #775, #718, #651, #546, #478, #345 --- .../springframework/hateoas/MediaTypes.java | 10 + .../hateoas/mediatype/problem/Problem.java | 153 +++++++++++ .../mediatype/problem/package-info.java | 5 + .../problem/JacksonSerializationTest.java | 245 ++++++++++++++++++ ...entationModelProcessorIntegrationTest.java | 15 ++ .../support/WebMvcEmployeeController.java | 13 + .../mediatype/problem/detail-only.json | 3 + .../hateoas/mediatype/problem/extension.json | 8 + .../problem/http-status-problem.json | 5 + .../mediatype/problem/instance-only.json | 3 + .../mediatype/problem/reference-1.json | 9 + .../mediatype/problem/reference-2.json | 12 + .../mediatype/problem/status-only.json | 3 + .../hateoas/mediatype/problem/title-only.json | 3 + .../hateoas/mediatype/problem/type-only.json | 3 + 15 files changed, 490 insertions(+) create mode 100644 src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java create mode 100644 src/main/java/org/springframework/hateoas/mediatype/problem/package-info.java create mode 100644 src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/detail-only.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/extension.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/http-status-problem.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/instance-only.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/reference-1.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/reference-2.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/status-only.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/title-only.json create mode 100644 src/test/resources/org/springframework/hateoas/mediatype/problem/type-only.json diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 5d1531cc5..e5182605d 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -76,4 +76,14 @@ 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#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); } diff --git a/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java b/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java new file mode 100644 index 000000000..fda3d56dc --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java @@ -0,0 +1,153 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * 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> { + + 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; + } + + /** + * 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 + // + '}'; + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/problem/package-info.java b/src/main/java/org/springframework/hateoas/mediatype/problem/package-info.java new file mode 100644 index 000000000..8f6e0ebb6 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/package-info.java @@ -0,0 +1,5 @@ +/** + * Value objects to build Collection+JSON representations. + */ +@org.springframework.lang.NonNullApi +package org.springframework.hateoas.mediatype.problem; diff --git a/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java b/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java new file mode 100644 index 000000000..f39a46730 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java @@ -0,0 +1,245 @@ +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 httpStatusProblem() 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 typeOnly() 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 titleOnly() 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 statusOnly() 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 detailOnly() 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 instanceOnly() 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 extension() 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 reference1() 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 reference2() 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 { + + 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 { + + private List invalidParameters; + + @JsonCreator + public InvalidParameters(@JsonProperty("invalid-params") List invalidParameters) { + + super(); + this.invalidParameters = invalidParameters; + } + + public List 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 + '\'' + // + '}'; + } + } +} diff --git a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java index 033467ce5..034d2820e 100644 --- a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java @@ -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.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 void collectionModelProcessorShouldWork() throws Exception { 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 { diff --git a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java index 55a678752..3ed9636e5 100644 --- a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java @@ -17,6 +17,7 @@ import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; +import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -29,6 +30,8 @@ import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; +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; @@ -174,4 +177,14 @@ public ResponseEntity partiallyUpdateEmployee(@RequestBody EntityModel 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")); + } } diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/detail-only.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/detail-only.json new file mode 100644 index 000000000..0b51bae26 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/detail-only.json @@ -0,0 +1,3 @@ +{ + "detail" : "test detail" +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/extension.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/extension.json new file mode 100644 index 000000000..f6f6cd693 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/extension.json @@ -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" ] +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/http-status-problem.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/http-status-problem.json new file mode 100644 index 000000000..fab2401a8 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/http-status-problem.json @@ -0,0 +1,5 @@ +{ + "type" : "about:blank", + "title" : "Not Found", + "status" : 404 +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/instance-only.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/instance-only.json new file mode 100644 index 000000000..f228fc60d --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/instance-only.json @@ -0,0 +1,3 @@ +{ + "instance" : "http://example.com/employees/1471" +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-1.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-1.json new file mode 100644 index 000000000..065f4ae8a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-1.json @@ -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"] +} diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-2.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-2.json new file mode 100644 index 000000000..e7be208c2 --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/reference-2.json @@ -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'"} + ] +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/status-only.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/status-only.json new file mode 100644 index 000000000..087cedffc --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/status-only.json @@ -0,0 +1,3 @@ +{ + "status" : 502 +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/title-only.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/title-only.json new file mode 100644 index 000000000..396734bff --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/title-only.json @@ -0,0 +1,3 @@ +{ + "title" : "test title" +} \ No newline at end of file diff --git a/src/test/resources/org/springframework/hateoas/mediatype/problem/type-only.json b/src/test/resources/org/springframework/hateoas/mediatype/problem/type-only.json new file mode 100644 index 000000000..91992f89a --- /dev/null +++ b/src/test/resources/org/springframework/hateoas/mediatype/problem/type-only.json @@ -0,0 +1,3 @@ +{ + "type" : "http://example.com/problem-details" +} \ No newline at end of file