#786 - Redesign of the support for the HTTP Problem spec (RFC-7807).
Problem has been redesigned for immutability. Extensions to the payload body are now implemented by wrapping a either an object exposing the properties to add in turn or a plain Map. Added infrastructure configuration to make sure that Problem instances returned from controller methods (even if wrapped into a ResponseEntity<?>) cause the ContentType header to be set to application/problem+json. HyperMediaMappingInformation now allows to customize the root domain type to signal serialization support for. Few cleanups regarding the ObjectMapper setup in test cases.
This commit is contained in:
@@ -88,12 +88,12 @@ public class MediaTypes {
|
||||
public static final MediaType VND_ERROR_JSON = MediaType.valueOf(VND_ERROR_JSON_VALUE);
|
||||
|
||||
/**
|
||||
* A String equivalent of {@link MediaTypes#PROBLEM_JSON_VALUE}.
|
||||
* A String equivalent of {@link MediaTypes#HTTP_PROBLEM_DETAILS_JSON_VALUE}.
|
||||
*/
|
||||
public static final String PROBLEM_JSON_VALUE = "application/problem+json";
|
||||
public static final String HTTP_PROBLEM_DETAILS_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);
|
||||
public static final MediaType HTTP_PROBLEM_DETAILS_JSON = MediaType.parseMediaType(HTTP_PROBLEM_DETAILS_JSON_VALUE);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ public @interface EnableHypermediaSupport {
|
||||
*/
|
||||
HAL_FORMS(MediaTypes.HAL_FORMS_JSON),
|
||||
|
||||
HTTP_PROBLEM_DETAILS(MediaTypes.HTTP_PROBLEM_DETAILS_JSON),
|
||||
|
||||
/**
|
||||
* Collection+JSON
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.springframework.hateoas.config;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.hateoas.RepresentationModel;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -40,6 +41,17 @@ public interface HypermediaMappingInformation {
|
||||
*/
|
||||
List<MediaType> getMediaTypes();
|
||||
|
||||
/**
|
||||
* Return the type that this hypermedia type is represented by. Default implementation returns
|
||||
* {@link RepresentationModel} as it's the base class most media type serializations work with.
|
||||
*
|
||||
* @return the type that this hypermedia type is represented by.
|
||||
* @since 1.1
|
||||
*/
|
||||
default Class<?> getRootType() {
|
||||
return RepresentationModel.class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure an {@link ObjectMapper} and register custom serializers and deserializers for the supported media types.
|
||||
* If all you want to do is register a Jackson {@link Module}, prefer implementing {@link #getJacksonModule()}.
|
||||
|
||||
@@ -38,8 +38,7 @@ class WebConverters {
|
||||
private final List<HttpMessageConverter<?>> converters;
|
||||
|
||||
/**
|
||||
* Creates a new {@link WebConverters} from the given {@link ObjectMapper} and
|
||||
* {@link HypermediaMappingInformation}s.
|
||||
* Creates a new {@link WebConverters} from the given {@link ObjectMapper} and {@link HypermediaMappingInformation}s.
|
||||
*
|
||||
* @param mapper must not be {@literal null}.
|
||||
* @param mappingInformation must not be {@literal null}.
|
||||
@@ -52,8 +51,7 @@ class WebConverters {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link WebConverters} from the given {@link ObjectMapper} and
|
||||
* {@link HypermediaMappingInformation}s.
|
||||
* Creates a new {@link WebConverters} from the given {@link ObjectMapper} and {@link HypermediaMappingInformation}s.
|
||||
*
|
||||
* @param mapper must not be {@literal null}.
|
||||
* @param mappingInformations must not be {@literal null}.
|
||||
@@ -106,7 +104,7 @@ class WebConverters {
|
||||
private static AbstractJackson2HttpMessageConverter createMessageConverter(HypermediaMappingInformation type,
|
||||
ObjectMapper mapper) {
|
||||
|
||||
return new TypeConstrainedMappingJackson2HttpMessageConverter(RepresentationModel.class, type.getMediaTypes(),
|
||||
return new TypeConstrainedMappingJackson2HttpMessageConverter(type.getRootType(), type.getMediaTypes(),
|
||||
type.configureObjectMapper(mapper));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.springframework.hateoas.mediatype.problem;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.config.HypermediaMappingInformation;
|
||||
import org.springframework.hateoas.config.MediaTypeConfigurationProvider;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* {@link MediaTypeConfigurationProvider} for HAL.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
class HttpProblemDetailsConfigurationProvider implements MediaTypeConfigurationProvider {
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.config.HyperMediaTypeProvider#getConfiguration()
|
||||
*/
|
||||
@Override
|
||||
public Class<? extends HypermediaMappingInformation> getConfiguration() {
|
||||
return HttpProblemDetailsMappingInformation.class;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.config.HyperMediaTypeProvider#supportsAny(java.util.Collection)
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsAny(Collection<MediaType> mediaTypes) {
|
||||
return mediaTypes.contains(MediaTypes.HTTP_PROBLEM_DETAILS_JSON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2020 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.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.hateoas.config.HypermediaMappingInformation;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* {@link HypermediaMappingInformation} implementation to setup support for {@link Problem}.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
class HttpProblemDetailsMappingInformation implements HypermediaMappingInformation {
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.config.HypermediaMappingInformation#getRootType()
|
||||
*/
|
||||
@Override
|
||||
public Class<?> getRootType() {
|
||||
return Problem.class;
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.config.HypermediaMappingInformation#getMediaTypes()
|
||||
*/
|
||||
@Override
|
||||
public List<MediaType> getMediaTypes() {
|
||||
return Collections.singletonList(MediaTypes.HTTP_PROBLEM_DETAILS_JSON);
|
||||
}
|
||||
}
|
||||
@@ -15,147 +15,255 @@
|
||||
*/
|
||||
package org.springframework.hateoas.mediatype.problem;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
import lombok.experimental.Wither;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public class Problem<T extends Problem<? extends T>> {
|
||||
@Getter(onMethod = @__(@JsonProperty))
|
||||
@Wither
|
||||
@ToString
|
||||
@EqualsAndHashCode
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
@NoArgsConstructor(force = true, access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class Problem {
|
||||
|
||||
private URI type;
|
||||
private String title;
|
||||
private HttpStatus status;
|
||||
private String detail;
|
||||
private URI instance;
|
||||
private static Problem EMPTY = new Problem();
|
||||
|
||||
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;
|
||||
}
|
||||
private final @Nullable URI type;
|
||||
private final @Nullable String title;
|
||||
private final @Nullable @Getter(onMethod = @__(@JsonIgnore)) HttpStatus status;
|
||||
private final @Nullable String detail;
|
||||
private final @Nullable URI 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.
|
||||
* Returns an empty {@link Problem} instance.
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc7807#section-4.2
|
||||
* @return an empty {@link Problem} instance.
|
||||
*/
|
||||
public Problem(HttpStatus httpStatus) {
|
||||
this(URI.create("about:blank"), httpStatus.getReasonPhrase(), httpStatus, null, null);
|
||||
public static Problem create() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T withType(URI type) {
|
||||
this.type = type;
|
||||
return (T) this;
|
||||
/**
|
||||
* Returns an {@link ExtendedProblem} with the given payload as additional properties.
|
||||
*
|
||||
* @param <T>
|
||||
* @param payload must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public static <T> ExtendedProblem<T> create(T payload) {
|
||||
|
||||
Assert.notNull(payload, "Payload must not be null!");
|
||||
|
||||
return EMPTY.withProperties(payload);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T withTitle(String title) {
|
||||
this.title = title;
|
||||
return (T) this;
|
||||
/**
|
||||
* Returns a {@link Problem} instance with the given {@link HttpStatus} and defaults as defined in
|
||||
* <a href="https://tools.ietf.org/html/rfc7807#section-4.2">RFC7807</a>.
|
||||
*
|
||||
* @param status must not be {@literal null}.
|
||||
* @return
|
||||
* @see <a href="https://tools.ietf.org/html/rfc7807#section-4.2">RFC7807</a>
|
||||
*/
|
||||
public static Problem statusOnly(HttpStatus status) {
|
||||
|
||||
Assert.notNull(status, "HttpStatus must not be null!");
|
||||
|
||||
return new Problem(URI.create("about:blank"), status.getReasonPhrase(), status, null, null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T withStatus(HttpStatus status) {
|
||||
this.status = status;
|
||||
return (T) this;
|
||||
/**
|
||||
* Creates a new {@link ExtendedProblem} with the given payload as additional properties.
|
||||
*
|
||||
* @param <T>
|
||||
* @param payload must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public <T> ExtendedProblem<T> withProperties(T payload) {
|
||||
return new ExtendedProblem<>(type, title, status, detail, instance, payload);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T withDetail(String detail) {
|
||||
this.detail = detail;
|
||||
return (T) this;
|
||||
/**
|
||||
* Returns an {@link ExtendedProblem} with a {@link Map<String, Object>} populated by the given consumer as payload.
|
||||
*
|
||||
* @param consumer must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public ExtendedProblem<Map<String, Object>> withProperties(Consumer<Map<String, Object>> consumer) {
|
||||
|
||||
Assert.notNull(consumer, "Consumer must not be null!");
|
||||
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
consumer.accept(map);
|
||||
|
||||
return withProperties(map);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T withInstance(URI instance) {
|
||||
this.instance = instance;
|
||||
return (T) this;
|
||||
/**
|
||||
* Returns an {@link ExtendedProblem} with the given {@link Map} unwrapping as additional properties.
|
||||
*
|
||||
* @param properties must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public ExtendedProblem<Map<String, Object>> withProperties(Map<String, Object> properties) {
|
||||
|
||||
Assert.notNull(properties, "Properties must not be null!");
|
||||
|
||||
return new ExtendedProblem<Map<String, Object>>(type, title, status, detail, instance, properties);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@JsonProperty("status")
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public URI getType() {
|
||||
return this.type;
|
||||
Integer getStatusAsInteger() {
|
||||
return status != null ? status.value() : null;
|
||||
}
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public String getTitle() {
|
||||
return this.title;
|
||||
}
|
||||
@Value
|
||||
@Getter(onMethod = @__(@JsonIgnore))
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
|
||||
public static class ExtendedProblem<T> extends Problem {
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public Integer getStatus() {
|
||||
if (status != null) {
|
||||
return status.value();
|
||||
private @NonFinal T extendedProperties;
|
||||
|
||||
ExtendedProblem(@Nullable URI type, @Nullable String title, @Nullable HttpStatus status, @Nullable String detail,
|
||||
@Nullable URI instance, @Nullable T properties) {
|
||||
|
||||
super(type, title, status, detail, instance);
|
||||
|
||||
this.extendedProperties = properties;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.problem.Problem#withType(java.net.URI)
|
||||
*/
|
||||
@Override
|
||||
public ExtendedProblem<T> withType(@Nullable URI type) {
|
||||
return new ExtendedProblem<>(type, getTitle(), getStatus(), getDetail(), getInstance(), extendedProperties);
|
||||
}
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.problem.Problem#withTitle(java.lang.String)
|
||||
*/
|
||||
@Override
|
||||
public ExtendedProblem<T> withTitle(@Nullable String title) {
|
||||
return new ExtendedProblem<>(getType(), title, getStatus(), getDetail(), getInstance(), extendedProperties);
|
||||
}
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public URI getInstance() {
|
||||
return instance;
|
||||
}
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.problem.Problem#withDetail(java.lang.String)
|
||||
*/
|
||||
@Override
|
||||
public ExtendedProblem<T> withDetail(@Nullable String detail) {
|
||||
return new ExtendedProblem<>(getType(), getTitle(), getStatus(), detail, getInstance(), extendedProperties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.problem.Problem#withInstance(java.net.URI)
|
||||
*/
|
||||
@Override
|
||||
public ExtendedProblem<T> withInstance(@Nullable URI instance) {
|
||||
return new ExtendedProblem<>(getType(), getTitle(), getStatus(), getDetail(), instance, extendedProperties);
|
||||
}
|
||||
|
||||
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); //
|
||||
}
|
||||
/**
|
||||
* Returns the additional properties.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@JsonIgnore
|
||||
public T getProperties() {
|
||||
return extendedProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(type, title, status, detail, instance);
|
||||
}
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.hateoas.mediatype.problem.Problem#withProperties(java.lang.Object)
|
||||
*/
|
||||
@Override
|
||||
public <S> ExtendedProblem<S> withProperties(S payload) {
|
||||
return super.withProperties(payload);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// Payload type based serialization
|
||||
|
||||
return "Problem{" + //
|
||||
"type=" + type + //
|
||||
", title='" + title + '\'' + //
|
||||
", status=" + status + //
|
||||
", detail='" + detail + '\'' + //
|
||||
", instance=" + instance + //
|
||||
'}';
|
||||
@Nullable
|
||||
@JsonUnwrapped
|
||||
T getExtendedProperties() {
|
||||
return Map.class.isInstance(extendedProperties) ? null : extendedProperties;
|
||||
}
|
||||
|
||||
// Map based serialization
|
||||
|
||||
@Nullable
|
||||
@JsonAnyGetter
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> getPropertiesAsMap() {
|
||||
return Map.class.isInstance(extendedProperties) ? (Map<String, Object>) extendedProperties : null;
|
||||
}
|
||||
|
||||
// Map based deserialization
|
||||
|
||||
@JsonAnySetter
|
||||
void setPropertiesAsMap(String key, Object value) {
|
||||
getOrInitAsMap().put(key, value);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> getOrInitAsMap() {
|
||||
|
||||
if (this.extendedProperties == null) {
|
||||
this.extendedProperties = (T) new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
return (Map<String, Object>) this.extendedProperties;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user