#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:
Oliver Drotbohm
2019-12-11 21:33:29 +01:00
parent 0d25b2e66e
commit 17644464d9
18 changed files with 688 additions and 267 deletions

View File

@@ -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);
}

View File

@@ -82,6 +82,8 @@ public @interface EnableHypermediaSupport {
*/
HAL_FORMS(MediaTypes.HAL_FORMS_JSON),
HTTP_PROBLEM_DETAILS(MediaTypes.HTTP_PROBLEM_DETAILS_JSON),
/**
* Collection+JSON
*

View File

@@ -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()}.

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}