diff --git a/src/main/java/org/springframework/hateoas/MediaTypes.java b/src/main/java/org/springframework/hateoas/MediaTypes.java index 98ce7718..4d86d874 100644 --- a/src/main/java/org/springframework/hateoas/MediaTypes.java +++ b/src/main/java/org/springframework/hateoas/MediaTypes.java @@ -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); } diff --git a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java index 2f4038e0..c333bed5 100644 --- a/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java +++ b/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java @@ -82,6 +82,8 @@ public @interface EnableHypermediaSupport { */ HAL_FORMS(MediaTypes.HAL_FORMS_JSON), + HTTP_PROBLEM_DETAILS(MediaTypes.HTTP_PROBLEM_DETAILS_JSON), + /** * Collection+JSON * diff --git a/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformation.java b/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformation.java index 3751eb3c..c087d52d 100644 --- a/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformation.java +++ b/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformation.java @@ -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 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()}. diff --git a/src/main/java/org/springframework/hateoas/config/WebConverters.java b/src/main/java/org/springframework/hateoas/config/WebConverters.java index 281e3e8e..9feb581e 100644 --- a/src/main/java/org/springframework/hateoas/config/WebConverters.java +++ b/src/main/java/org/springframework/hateoas/config/WebConverters.java @@ -38,8 +38,7 @@ class WebConverters { private final List> 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)); } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsConfigurationProvider.java b/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsConfigurationProvider.java new file mode 100644 index 00000000..c1e7af5e --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsConfigurationProvider.java @@ -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 getConfiguration() { + return HttpProblemDetailsMappingInformation.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.config.HyperMediaTypeProvider#supportsAny(java.util.Collection) + */ + @Override + public boolean supportsAny(Collection mediaTypes) { + return mediaTypes.contains(MediaTypes.HTTP_PROBLEM_DETAILS_JSON); + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsMappingInformation.java b/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsMappingInformation.java new file mode 100644 index 00000000..ee388d84 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsMappingInformation.java @@ -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 getMediaTypes() { + return Collections.singletonList(MediaTypes.HTTP_PROBLEM_DETAILS_JSON); + } +} diff --git a/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java b/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java index 71e19cee..e8112611 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java +++ b/src/main/java/org/springframework/hateoas/mediatype/problem/Problem.java @@ -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> { +@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 + * @param payload must not be {@literal null}. + * @return + */ + public static ExtendedProblem 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 + * RFC7807. + * + * @param status must not be {@literal null}. + * @return + * @see RFC7807 + */ + 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 + * @param payload must not be {@literal null}. + * @return + */ + public ExtendedProblem 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} populated by the given consumer as payload. + * + * @param consumer must not be {@literal null}. + * @return + */ + public ExtendedProblem> withProperties(Consumer> consumer) { + + Assert.notNull(consumer, "Consumer must not be null!"); + + Map 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> withProperties(Map properties) { + + Assert.notNull(properties, "Properties must not be null!"); + + return new ExtendedProblem>(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 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 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 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 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 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 ExtendedProblem 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 getPropertiesAsMap() { + return Map.class.isInstance(extendedProperties) ? (Map) extendedProperties : null; + } + + // Map based deserialization + + @JsonAnySetter + void setPropertiesAsMap(String key, Object value) { + getOrInitAsMap().put(key, value); + } + + @SuppressWarnings("unchecked") + private Map getOrInitAsMap() { + + if (this.extendedProperties == null) { + this.extendedProperties = (T) new LinkedHashMap<>(); + } + + return (Map) this.extendedProperties; + } } } diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 66583057..5484758a 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -7,7 +7,8 @@ org.springframework.hateoas.config.MediaTypeConfigurationProvider=\ org.springframework.hateoas.mediatype.collectionjson.CollectionJsonMediaTypeConfigurationProvider,\ org.springframework.hateoas.mediatype.hal.HalMediaTypeConfigurationProvider,\ org.springframework.hateoas.mediatype.hal.forms.HalFormsMediaTypeConfigurationProvider,\ - org.springframework.hateoas.mediatype.uber.UberMediaTypeConfigurationProvider + org.springframework.hateoas.mediatype.uber.UberMediaTypeConfigurationProvider,\ + org.springframework.hateoas.mediatype.problem.HttpProblemDetailsConfigurationProvider org.springframework.hateoas.client.TraversonDefaults=\ org.springframework.hateoas.mediatype.hal.HalTraversonDefaults diff --git a/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java b/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java index 53fffadd..21dc52d7 100755 --- a/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/AbstractJackson2MarshallingIntegrationTest.java @@ -25,7 +25,6 @@ import org.springframework.hateoas.mediatype.hal.HalConfiguration; import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalHandlerInstantiator; import org.springframework.hateoas.server.core.AnnotationLinkRelationProvider; -import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; /** @@ -35,18 +34,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; * @author Jon Brisbin * @author Greg Turnquist */ +@Deprecated public abstract class AbstractJackson2MarshallingIntegrationTest { protected ObjectMapper mapper; @BeforeEach void setUp() { - mapper = new ObjectMapper(); - mapper.disable(MapperFeature.AUTO_DETECT_CREATORS) // - .disable(MapperFeature.AUTO_DETECT_FIELDS) // - .disable(MapperFeature.AUTO_DETECT_GETTERS) // - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) // - .disable(MapperFeature.AUTO_DETECT_SETTERS); + mapper = MappingTestUtils.defaultObjectMapper(); } protected ObjectMapper with(HalConfiguration configuration) { diff --git a/src/test/java/org/springframework/hateoas/MappingTestUtils.java b/src/test/java/org/springframework/hateoas/MappingTestUtils.java new file mode 100644 index 00000000..dccafafa --- /dev/null +++ b/src/test/java/org/springframework/hateoas/MappingTestUtils.java @@ -0,0 +1,41 @@ +/* + * Copyright 2019-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; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Oliver Drotbohm + */ +public class MappingTestUtils { + + public static ObjectMapper defaultObjectMapper() { + + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + // Disable auto-detection to make sure our model classes work in that scenario + mapper.disable(MapperFeature.AUTO_DETECT_CREATORS) // + .disable(MapperFeature.AUTO_DETECT_FIELDS) // + .disable(MapperFeature.AUTO_DETECT_GETTERS) // + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) // + .disable(MapperFeature.AUTO_DETECT_SETTERS); + + return mapper; + } +} diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/HalTestUtils.java b/src/test/java/org/springframework/hateoas/mediatype/hal/HalTestUtils.java index 4879f8dc..683a43e9 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/HalTestUtils.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/HalTestUtils.java @@ -16,13 +16,54 @@ package org.springframework.hateoas.mediatype.hal; import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.MappingTestUtils; +import org.springframework.hateoas.mediatype.MessageResolver; +import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalHandlerInstantiator; import org.springframework.hateoas.server.LinkRelationProvider; +import org.springframework.hateoas.server.core.AnnotationLinkRelationProvider; +import org.springframework.hateoas.server.core.DelegatingLinkRelationProvider; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.ObjectMapper; /** + * Test utilities for HAL. + * * @author Oliver Drotbohm */ public class HalTestUtils { + /** + * Returns a default HAL {@link ObjectMapper} using a default {@link HalConfiguration}. + * + * @return + */ + public static ObjectMapper halObjectMapper() { + return halObjectMapper(new HalConfiguration()); + } + + /** + * Returns a default HAL {@link ObjectMapper} using the given {@link HalConfiguration}. + * + * @param configuration must not be {@literal null}. + * @return + */ + public static ObjectMapper halObjectMapper(HalConfiguration configuration) { + + Assert.notNull(configuration, "HalConfiguration must not be null!"); + + ObjectMapper mapper = MappingTestUtils.defaultObjectMapper(); + + LinkRelationProvider provider = new DelegatingLinkRelationProvider(new AnnotationLinkRelationProvider(), + HalTestUtils.DefaultLinkRelationProvider.INSTANCE); + + mapper.registerModule(new Jackson2HalModule()); + mapper.setHandlerInstantiator( + new HalHandlerInstantiator(provider, CurieProvider.NONE, MessageResolver.DEFAULTS_ONLY, configuration)); + + return mapper; + } + public enum DefaultLinkRelationProvider implements LinkRelationProvider { INSTANCE; @@ -54,5 +95,4 @@ public class HalTestUtils { return delimiter.isCollectionRelationLookup(); } } - } diff --git a/src/test/java/org/springframework/hateoas/mediatype/hal/Jackson2HalIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/hal/Jackson2HalIntegrationTest.java index 65edcdd5..31840c9e 100755 --- a/src/test/java/org/springframework/hateoas/mediatype/hal/Jackson2HalIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/hal/Jackson2HalIntegrationTest.java @@ -34,14 +34,20 @@ import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.context.support.StaticMessageSource; -import org.springframework.hateoas.*; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.Links; +import org.springframework.hateoas.PagedModel; import org.springframework.hateoas.PagedModel.PageMetadata; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.UriTemplate; import org.springframework.hateoas.mediatype.MessageResolver; import org.springframework.hateoas.mediatype.hal.HalConfiguration.RenderSingleLinks; import org.springframework.hateoas.mediatype.hal.Jackson2HalModule.HalHandlerInstantiator; -import org.springframework.hateoas.server.LinkRelationProvider; import org.springframework.hateoas.server.core.AnnotationLinkRelationProvider; -import org.springframework.hateoas.server.core.DelegatingLinkRelationProvider; import org.springframework.hateoas.server.core.EmbeddedWrappers; import org.springframework.hateoas.server.core.Relation; import org.springframework.lang.Nullable; @@ -64,7 +70,7 @@ import com.jayway.jsonpath.JsonPath; * @author Greg Turnquist * @author Jeffrey Walraven */ -class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationTest { +class Jackson2HalIntegrationTest { static final String SINGLE_LINK_REFERENCE = "{\"_links\":{\"self\":{\"href\":\"localhost\"}}}"; static final String LIST_LINK_REFERENCE = "{\"_links\":{\"self\":[{\"href\":\"localhost\"},{\"href\":\"localhost2\"}]}}"; @@ -93,15 +99,11 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT static final String SINGLE_WITH_ONE_EXTRA_ATTRIBUTES = "{\"_links\":{\"self\":{\"href\":\"localhost\",\"title\":\"the title\"}}}"; static final String SINGLE_WITH_ALL_EXTRA_ATTRIBUTES = "{\"_links\":{\"self\":{\"href\":\"localhost\",\"hreflang\":\"en\",\"title\":\"the title\",\"type\":\"the type\",\"deprecation\":\"/customers/deprecated\"}}}"; + private ObjectMapper mapper; + @BeforeEach void setUpModule() { - - LinkRelationProvider provider = new DelegatingLinkRelationProvider(new AnnotationLinkRelationProvider(), - HalTestUtils.DefaultLinkRelationProvider.INSTANCE); - - mapper.registerModule(new Jackson2HalModule()); - mapper.setHandlerInstantiator(new HalHandlerInstantiator(provider, CurieProvider.NONE, - MessageResolver.DEFAULTS_ONLY, new HalConfiguration())); + this.mapper = HalTestUtils.halObjectMapper(); } /** @@ -113,7 +115,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT RepresentationModel resourceSupport = new RepresentationModel<>(); resourceSupport.add(new Link("localhost")); - assertThat(write(resourceSupport)).isEqualTo(SINGLE_LINK_REFERENCE); + assertThat(mapper.writeValueAsString(resourceSupport)).isEqualTo(SINGLE_LINK_REFERENCE); } /** @@ -130,7 +132,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT .withMedia("the media") // .withDeprecation("/customers/deprecated")); - assertThat(write(resourceSupport)).isEqualTo(SINGLE_WITH_ALL_EXTRA_ATTRIBUTES); + assertThat(mapper.writeValueAsString(resourceSupport)).isEqualTo(SINGLE_WITH_ALL_EXTRA_ATTRIBUTES); } /** @@ -148,7 +150,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT .withType("the type") // .withDeprecation("/customers/deprecated")); - assertThat(read(SINGLE_WITH_ALL_EXTRA_ATTRIBUTES, RepresentationModel.class)).isEqualTo(expected); + assertThat(mapper.readValue(SINGLE_WITH_ALL_EXTRA_ATTRIBUTES, RepresentationModel.class)).isEqualTo(expected); } @Test @@ -157,7 +159,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT RepresentationModel resourceSupport = new RepresentationModel<>(); resourceSupport.add(new Link("localhost", "self").withTitle("the title")); - assertThat(write(resourceSupport)).isEqualTo(SINGLE_WITH_ONE_EXTRA_ATTRIBUTES); + assertThat(mapper.writeValueAsString(resourceSupport)).isEqualTo(SINGLE_WITH_ONE_EXTRA_ATTRIBUTES); } /** @@ -169,14 +171,14 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT RepresentationModel expected = new RepresentationModel<>(); expected.add(new Link("localhost", "self").withTitle("the title")); - assertThat(read(SINGLE_WITH_ONE_EXTRA_ATTRIBUTES, RepresentationModel.class)).isEqualTo(expected); + assertThat(mapper.readValue(SINGLE_WITH_ONE_EXTRA_ATTRIBUTES, RepresentationModel.class)).isEqualTo(expected); } @Test void deserializeSingleLink() throws Exception { RepresentationModel expected = new RepresentationModel<>(); expected.add(new Link("localhost")); - assertThat(read(SINGLE_LINK_REFERENCE, RepresentationModel.class)).isEqualTo(expected); + assertThat(mapper.readValue(SINGLE_LINK_REFERENCE, RepresentationModel.class)).isEqualTo(expected); } /** @@ -189,7 +191,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT resourceSupport.add(new Link("localhost")); resourceSupport.add(new Link("localhost2")); - assertThat(write(resourceSupport)).isEqualTo(LIST_LINK_REFERENCE); + assertThat(mapper.writeValueAsString(resourceSupport)).isEqualTo(LIST_LINK_REFERENCE); } @Test @@ -199,7 +201,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT expected.add(new Link("localhost")); expected.add(new Link("localhost2")); - assertThat(read(LIST_LINK_REFERENCE, RepresentationModel.class)).isEqualTo(expected); + assertThat(mapper.readValue(LIST_LINK_REFERENCE, RepresentationModel.class)).isEqualTo(expected); } @Test @@ -212,7 +214,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel resources = new CollectionModel<>(content); resources.add(new Link("localhost")); - assertThat(write(resources)).isEqualTo(SIMPLE_EMBEDDED_RESOURCE_REFERENCE); + assertThat(mapper.writeValueAsString(resources)).isEqualTo(SIMPLE_EMBEDDED_RESOURCE_REFERENCE); } @Test @@ -241,7 +243,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel> resources = new CollectionModel<>(content); resources.add(new Link("localhost")); - assertThat(write(resources)).isEqualTo(SINGLE_EMBEDDED_RESOURCE_REFERENCE); + assertThat(mapper.writeValueAsString(resources)).isEqualTo(SINGLE_EMBEDDED_RESOURCE_REFERENCE); } @Test @@ -253,9 +255,10 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel> expected = new CollectionModel<>(content); expected.add(new Link("localhost")); + TypeFactory typeFactory = mapper.getTypeFactory(); CollectionModel> result = mapper.readValue(SINGLE_EMBEDDED_RESOURCE_REFERENCE, - mapper.getTypeFactory().constructParametricType(CollectionModel.class, - mapper.getTypeFactory().constructParametricType(EntityModel.class, SimplePojo.class))); + typeFactory.constructParametricType(CollectionModel.class, + typeFactory.constructParametricType(EntityModel.class, SimplePojo.class))); assertThat(result).isEqualTo(expected); @@ -267,7 +270,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel> resources = setupResources(); resources.add(new Link("localhost")); - assertThat(write(resources)).isEqualTo(LIST_EMBEDDED_RESOURCE_REFERENCE); + assertThat(mapper.writeValueAsString(resources)).isEqualTo(LIST_EMBEDDED_RESOURCE_REFERENCE); } @Test @@ -295,7 +298,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel> resources = new CollectionModel<>(content); resources.add(new Link("localhost")); - assertThat(write(resources)).isEqualTo(ANNOTATED_EMBEDDED_RESOURCE_REFERENCE); + assertThat(mapper.writeValueAsString(resources)).isEqualTo(ANNOTATED_EMBEDDED_RESOURCE_REFERENCE); } /** @@ -322,7 +325,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT */ @Test void serializesMultipleAnnotatedResourceResourcesAsEmbedded() throws Exception { - assertThat(write(setupAnnotatedResources())).isEqualTo(ANNOTATED_EMBEDDED_RESOURCES_REFERENCE); + assertThat(mapper.writeValueAsString(setupAnnotatedResources())).isEqualTo(ANNOTATED_EMBEDDED_RESOURCES_REFERENCE); } /** @@ -343,7 +346,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT */ @Test void serializesPagedResource() throws Exception { - assertThat(write(setupAnnotatedPagedResources())).isEqualTo(ANNOTATED_PAGED_RESOURCES); + assertThat(mapper.writeValueAsString(setupAnnotatedPagedResources())).isEqualTo(ANNOTATED_PAGED_RESOURCES); } /** @@ -401,7 +404,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT RepresentationModel support = new RepresentationModel<>(); support.add(new Link("/foo{?bar}", "search")); - assertThat(write(support)).isEqualTo(LINK_TEMPLATE); + assertThat(mapper.writeValueAsString(support)).isEqualTo(LINK_TEMPLATE); } /** @@ -436,7 +439,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel resources = new CollectionModel<>(values); - assertThat(write(resources)).isEqualTo("{\"_embedded\":{\"pojos\":[]}}"); + assertThat(mapper.writeValueAsString(resources)).isEqualTo("{\"_embedded\":{\"pojos\":[]}}"); } /** @@ -458,7 +461,8 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT @Test void rendersSingleLinkAsArrayWhenConfigured() throws Exception { - ObjectMapper mapper = with(new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_ARRAY)); + ObjectMapper mapper = HalTestUtils + .halObjectMapper(new HalConfiguration().withRenderSingleLinks(RenderSingleLinks.AS_ARRAY)); RepresentationModel resourceSupport = new RepresentationModel<>(); resourceSupport.add(new Link("localhost").withSelfRel()); @@ -477,6 +481,7 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT original.add(new Link("/orders{?id}", "order")); String serialized = mapper.writeValueAsString(original); + assertThat(serialized).isEqualTo("{\"_links\":{\"order\":{\"href\":\"/orders{?id}\",\"templated\":true}}}"); RepresentationModel deserialized = mapper.readValue(serialized, RepresentationModel.class); @@ -487,7 +492,8 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT @Test // #811 void rendersSpecificRelWithSingleLinkAsArrayIfConfigured() throws Exception { - ObjectMapper mapper = with(new HalConfiguration().withRenderSingleLinksFor("foo", RenderSingleLinks.AS_ARRAY)); + ObjectMapper mapper = HalTestUtils + .halObjectMapper(new HalConfiguration().withRenderSingleLinksFor("foo", RenderSingleLinks.AS_ARRAY)); RepresentationModel resource = new RepresentationModel<>(); resource.add(new Link("/some-href", "foo")); @@ -545,7 +551,8 @@ class Jackson2HalIntegrationTest extends AbstractJackson2MarshallingIntegrationT CollectionModel model = new CollectionModel<>(Arrays.asList(new SomeSample())); model.add(new Link("/foo", LinkRelation.of("someSample"))); - ObjectMapper mapper = with(new HalConfiguration().withApplyPropertyNamingStrategy(false)) // + ObjectMapper mapper = HalTestUtils.halObjectMapper(new HalConfiguration() // + .withApplyPropertyNamingStrategy(false)) // .setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE) // .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); 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 c390fef9..721fe970 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 @@ -133,20 +133,20 @@ class HalFormsWebFluxIntegrationTest { .expectHeader().valueEquals(HttpHeaders.LOCATION, "http://localhost/employees/2"); } - @Test + @Test // #786 void problemReturningControllerMethod() { - Problem problem = this.testClient.get().uri("http://localhost/employees/problem").accept(MediaTypes.PROBLEM_JSON) // + Problem problem = this.testClient.get().uri("http://localhost/employees/problem").accept(MediaTypes.HTTP_PROBLEM_DETAILS_JSON) // .exchange() // .expectStatus().isBadRequest() // - .expectHeader().contentType(MediaTypes.PROBLEM_JSON) // + .expectHeader().contentType(MediaTypes.HTTP_PROBLEM_DETAILS_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.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(problem.getDetail()).isEqualTo("This is a test case"); } diff --git a/src/test/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsIntegrationTest.java b/src/test/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsIntegrationTest.java new file mode 100644 index 00000000..69108b56 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mediatype/problem/HttpProblemDetailsIntegrationTest.java @@ -0,0 +1,96 @@ +/* + * 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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Integration tests for our support for HTTP Problem Details. + * + * @author Oliver Drotbohm + */ +@TestInstance(Lifecycle.PER_CLASS) +public class HttpProblemDetailsIntegrationTest { + + MockMvc mvc; + + @BeforeAll + void setUp() { + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(Config.class); + context.register(TestController.class); + context.refresh(); + + this.mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test // #786 + void returnsSimpleProblemDetails() throws Exception { + + mvc.perform(get("/problem")) // + .andExpect(status().isOk()) // + .andExpect(jsonPath("$.title").value("Title")) + .andExpect(content().contentType(MediaTypes.HTTP_PROBLEM_DETAILS_JSON)); // + } + + @Test // #786 + void returnsProblemWrappedInResponseEntity() throws Exception { + + mvc.perform(get("/problemInEntity")) // + .andExpect(status().isIAmATeapot()) // + .andExpect(jsonPath("$.title").value("WithinResponseEntity")) // + .andExpect(content().contentType(MediaTypes.HTTP_PROBLEM_DETAILS_JSON)); // + } + + @EnableWebMvc + @EnableHypermediaSupport(type = HypermediaType.HTTP_PROBLEM_DETAILS) + static class Config {} + + @RestController + static class TestController { + + @GetMapping("/problem") + Problem produceProblem() { + return Problem.create().withTitle("Title"); + } + + @GetMapping("/problemInEntity") + ResponseEntity produceEntityOfProblem() { + return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT) // + .body(Problem.create().withTitle("WithinResponseEntity")); + } + } +} diff --git a/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java b/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java index 06da4eca..0c38e7ea 100644 --- a/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java +++ b/src/test/java/org/springframework/hateoas/mediatype/problem/JacksonSerializationTest.java @@ -1,26 +1,61 @@ +/* + * Copyright 2019-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 static org.assertj.core.api.Assertions.*; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.experimental.Wither; + import java.io.IOException; import java.net.URI; +import java.util.Arrays; import java.util.List; -import java.util.Objects; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; +import org.springframework.hateoas.MappingTestUtils; +import org.springframework.hateoas.mediatype.problem.Problem.ExtendedProblem; import org.springframework.hateoas.support.MappingUtils; import org.springframework.http.HttpStatus; +import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; /** + * Jackson serialization tests for the HTTP Problem support. + * * @author Greg Turnquist + * @author Oliver Drotbohm */ class JacksonSerializationTest { @@ -29,35 +64,36 @@ class JacksonSerializationTest { @BeforeEach void setUp() { - this.mapper = new ObjectMapper(); + this.mapper = MappingTestUtils.defaultObjectMapper(); this.mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); this.mapper.configure(SerializationFeature.INDENT_OUTPUT, true); } - @Test + @Test // #786 void httpStatusProblemSerialize() throws IOException { - Problem problem = new Problem(HttpStatus.NOT_FOUND); + Problem problem = Problem.statusOnly(HttpStatus.NOT_FOUND); String actual = this.mapper.writeValueAsString(problem); + assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("http-status-problem.json", getClass()))); } - @Test + @Test // #786 void httpStatusProblemDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("http-status-problem.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + 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.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(actual.getDetail()).isNull(); assertThat(actual.getInstance()).isNull(); } - @Test + @Test // #786 void typeOnlySerialize() throws IOException { Problem problem = new Problem().withType(URI.create("http://example.com/problem-details")); @@ -66,12 +102,12 @@ class JacksonSerializationTest { assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("type-only.json", getClass()))); } - @Test + @Test // #786 void typeOnlyDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("type-only.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + Problem actual = this.mapper.readValue(expected, Problem.class); assertThat(actual.getType()).isEqualTo(URI.create("http://example.com/problem-details")); assertThat(actual.getTitle()).isNull(); @@ -80,7 +116,7 @@ class JacksonSerializationTest { assertThat(actual.getInstance()).isNull(); } - @Test + @Test // #786 void titleOnlySerialize() throws IOException { Problem problem = new Problem().withTitle("test title"); @@ -89,12 +125,12 @@ class JacksonSerializationTest { assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("title-only.json", getClass()))); } - @Test + @Test // #786 void titleOnlyDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("title-only.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + Problem actual = this.mapper.readValue(expected, Problem.class); assertThat(actual.getType()).isNull(); assertThat(actual.getTitle()).isEqualTo("test title"); @@ -103,7 +139,7 @@ class JacksonSerializationTest { assertThat(actual.getInstance()).isNull(); } - @Test + @Test // #786 void statusOnlySerialize() throws IOException { Problem problem = new Problem().withStatus(HttpStatus.BAD_GATEWAY); @@ -112,35 +148,35 @@ class JacksonSerializationTest { assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("status-only.json", getClass()))); } - @Test + @Test // #786 void statusOnlyDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("status-only.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + Problem actual = this.mapper.readValue(expected, Problem.class); assertThat(actual.getType()).isNull(); assertThat(actual.getTitle()).isNull(); - assertThat(actual.getStatus()).isEqualTo(502); + assertThat(actual.getStatus()).isEqualTo(HttpStatus.BAD_GATEWAY); assertThat(actual.getDetail()).isNull(); assertThat(actual.getInstance()).isNull(); } - @Test + @Test // #786 void detailOnlySerialize() throws IOException { - Problem problem = new Problem().withDetail("test detail"); + Problem problem = Problem.create().withDetail("test detail"); - String actual = this.mapper.writeValueAsString(problem); - assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("detail-only.json", getClass()))); + assertThat(this.mapper.writeValueAsString(problem)) // + .isEqualTo(MappingUtils.read(new ClassPathResource("detail-only.json", getClass()))); } - @Test + @Test // #786 void detailOnlyDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("detail-only.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + Problem actual = this.mapper.readValue(expected, Problem.class); assertThat(actual.getType()).isNull(); assertThat(actual.getTitle()).isNull(); @@ -149,21 +185,22 @@ class JacksonSerializationTest { assertThat(actual.getInstance()).isNull(); } - @Test + @Test // #786 void instanceOnlySerialize() throws IOException { - Problem problem = new Problem().withInstance(URI.create("http://example.com/employees/1471")); + Problem problem = Problem.create() // + .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()))); + assertThat(mapper.writeValueAsString(problem)) // + .isEqualTo(MappingUtils.read(new ClassPathResource("instance-only.json", getClass()))); } - @Test + @Test // #786 void instanceOnlyDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("instance-only.json", getClass())); - Problem actual = this.mapper.readValue(expected, Problem.class); + Problem actual = mapper.readValue(expected, Problem.class); assertThat(actual.getType()).isNull(); assertThat(actual.getTitle()).isNull(); @@ -172,65 +209,119 @@ class JacksonSerializationTest { assertThat(actual.getInstance()).isEqualTo(URI.create("http://example.com/employees/1471")); } - @Test + @Test // #786 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")) // + AccountProblemDetails details = AccountProblemDetails.empty() // .withBalance(30) // .withAccounts("/account/12345", "/account/67890"); + ExtendedProblem problem = Problem.create(details) + .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")); + String actual = this.mapper.writeValueAsString(problem); + assertThat(actual).isEqualTo(MappingUtils.read(new ClassPathResource("extension.json", getClass()))); } - @Test + @Test // #786 void extensionDeserialize() throws IOException { String expected = MappingUtils.read(new ClassPathResource("extension.json", getClass())); - AccountProblem actual = this.mapper.readValue(expected, AccountProblem.class); + JavaType type = this.mapper.getTypeFactory().constructParametricType(ExtendedProblem.class, + AccountProblemDetails.class); + + ExtendedProblem actual = this.mapper.readValue(expected, type); 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"); + assertThat(actual.getProperties().getBalance()).isEqualTo(30); + assertThat(actual.getProperties().getAccounts()).containsExactlyInAnyOrder("/account/12345", "/account/67890"); } - @Test + @Test // #786 void reference1Deserialize() throws IOException { - AccountProblem accountProblem = this.mapper - .readValue(MappingUtils.read(new ClassPathResource("reference-1.json", getClass())), AccountProblem.class); + JavaType type = mapper.getTypeFactory().constructParametricType(ExtendedProblem.class, AccountProblemDetails.class); + + ExtendedProblem accountProblem = this.mapper + .readValue(MappingUtils.read(new ClassPathResource("reference-1.json", getClass())), type); 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"); + + AccountProblemDetails details = accountProblem.getProperties(); + + assertThat(details.getBalance()).isEqualTo(30); + assertThat(details.getAccounts()).containsExactlyInAnyOrder("/account/12345", "/account/67890"); } - @Test + @Test // #786 void reference2Deserialize() throws IOException { - InvalidParameters invalidParameters = this.mapper - .readValue(MappingUtils.read(new ClassPathResource("reference-2.json", getClass())), InvalidParameters.class); + JavaType type = mapper.getTypeFactory().constructParametricType(ExtendedProblem.class, InvalidParameters.class); + + ExtendedProblem invalidParameters = this.mapper + .readValue(MappingUtils.read(new ClassPathResource("reference-2.json", getClass())), type); 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'")); + + List parameters = invalidParameters.getProperties().getInvalidParameters(); + + assertThat(parameters).hasSize(2); + assertThat(parameters).containsExactly( // + new InvalidParameter("age", "must be a positive integer"), // + new InvalidParameter("color", "must be 'green', 'red' or 'blue'") // + ); + } + + @Test // #786 + public void addsPropertiesViaCallback() throws JsonProcessingException { + + ExtendedProblem> problem = Problem.create() // + .withStatus(HttpStatus.BAD_GATEWAY) // + .withProperties(map -> { + map.put("key", "value"); + }); + + DocumentContext parse = JsonPath.parse(mapper.writeValueAsString(problem)); + + assertThat(parse.read("$.status", int.class)).isEqualTo(502); + assertThat(parse.read("$.key", String.class)).isEqualTo("value"); + } + + @Test // #786 + void deserializesIntoExtendedProblemWithMap() throws Exception { + + TypeFactory factory = mapper.getTypeFactory(); + + JavaType mapType = factory.constructParametricType(Map.class, String.class, Object.class); + JavaType problemType = factory.constructParametricType(ExtendedProblem.class, mapType); + + ExtendedProblem> result = mapper + .readValue("{ \"balance\" : 30, \"accounts\" : [ \"/first\", \"/second\" ] }", problemType); + + assertThat(result.getProperties()).containsEntry("balance", 30); + assertThat(result.getProperties()).containsEntry("accounts", Arrays.asList("/first", "/second")); + } + + @Value + @Getter(onMethod = @__(@JsonProperty)) + @NoArgsConstructor(force = true) + static class Sample { + String name; } /** @@ -238,33 +329,18 @@ class JacksonSerializationTest { * * @see https://tools.ietf.org/html/rfc7807#section-3 */ - private static class AccountProblem extends Problem { + @Value + @Getter(onMethod = @__(@JsonProperty)) + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + @NoArgsConstructor(staticName = "empty", force = true) + @Wither + private static class AccountProblemDetails { - private int balance; - private String[] accounts; + int balance; + 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; + public AccountProblemDetails withAccounts(String... accounts) { + return new AccountProblemDetails(balance, accounts); } } @@ -273,7 +349,8 @@ class JacksonSerializationTest { * * @see https://tools.ietf.org/html/rfc7807#section-3 */ - private static class InvalidParameters extends Problem { + @JsonAutoDetect + private static class InvalidParameters { private List invalidParameters; @@ -289,57 +366,18 @@ class JacksonSerializationTest { } } + @Data + @JsonAutoDetect private static class InvalidParameter { private String name; private String reason; - InvalidParameter() {} - - InvalidParameter(String name, String reason) { + @JsonCreator + InvalidParameter(@JsonProperty("name") String name, @JsonProperty("reason") 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 b75a4c5f..1e393472 100644 --- a/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/server/mvc/RepresentationModelProcessorIntegrationTest.java @@ -110,8 +110,8 @@ public class RepresentationModelProcessorIntegrationTest { @Test public void problemReturningControllerMethod() throws Exception { - this.mockMvc.perform(get("/employees/problem").accept(PROBLEM_JSON)) // - .andExpect(content().contentType(PROBLEM_JSON)) // + this.mockMvc.perform(get("/employees/problem").accept(HTTP_PROBLEM_DETAILS_JSON)) // + .andExpect(content().contentType(HTTP_PROBLEM_DETAILS_JSON)) // .andExpect(status().is(HttpStatus.BAD_REQUEST.value())) // .andExpect(jsonPath("$.type", is("http://example.com/problem"))) // .andExpect(jsonPath("$.title", is("Employee-based problem"))) // diff --git a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java index 927edaea..5d86d9aa 100644 --- a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java @@ -218,7 +218,7 @@ public class WebFluxEmployeeController { @GetMapping("/employees/problem") public ResponseEntity problem() { - return ResponseEntity.badRequest().body(new Problem() // + return ResponseEntity.badRequest().body(Problem.create() // .withType(URI.create("http://example.com/problem")) // .withTitle("Employee-based problem") // .withStatus(HttpStatus.BAD_REQUEST) // diff --git a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java index 420b4f6c..7913169b 100644 --- a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java @@ -218,7 +218,7 @@ public class WebMvcEmployeeController { @GetMapping("/employees/problem") public ResponseEntity problem() { - return ResponseEntity.badRequest().body(new Problem<>() // + return ResponseEntity.badRequest().body(Problem.create() // .withType(URI.create("http://example.com/problem")) // .withTitle("Employee-based problem") // .withStatus(HttpStatus.BAD_REQUEST) //