diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 5f5e02b3..6ae9a567 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -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); } 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 00000000..cf41f076 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java @@ -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> { + + 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 + // + '}'; + } +} 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 00000000..c41f1fee --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/package-info.java @@ -0,0 +1,5 @@ +/** + * Value objects to build Problem representations. + */ +@org.springframework.lang.NonNullApi +package org.springframework.hateoas.mediatype.problem; diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java index bd2e6550..e83af388 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/forms/HalFormsWebFluxIntegrationTest.java @@ -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 }) 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 00000000..06da4eca --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java @@ -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 { + + 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 033467ce..034d2820 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.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 { diff --git a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java index 4bd0dc99..cde1a13c 100644 --- a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java @@ -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")); + } } diff --git a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java index 2d0c50fe..6616a61b 100644 --- a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java @@ -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")); + } } 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 00000000..0b51bae2 --- /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 00000000..f6f6cd69 --- /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 00000000..fab2401a --- /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 00000000..f228fc60 --- /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 00000000..065f4ae8 --- /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 00000000..e7be208c --- /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 00000000..087cedff --- /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 00000000..396734bf --- /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 00000000..91992f89 --- /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