#786 - Implement vendor neutral error handling with RFC-7807.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 + //
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Value objects to build Problem representations.
|
||||
*/
|
||||
@org.springframework.lang.NonNullApi
|
||||
package org.springframework.hateoas.mediatype.problem;
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 + '\'' + //
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"detail" : "test detail"
|
||||
}
|
||||
@@ -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" ]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type" : "about:blank",
|
||||
"title" : "Not Found",
|
||||
"status" : 404
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"instance" : "http://example.com/employees/1471"
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"status" : 502
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title" : "test title"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type" : "http://example.com/problem-details"
|
||||
}
|
||||
Reference in New Issue
Block a user