From 5dd7b322b65652f90717c4f8cb930c5e471ad483 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Thu, 11 Jan 2024 22:58:47 +0100 Subject: [PATCH] Support for simplified rendering of Page instances via PagedModel. This commits all necessary infrastructure to produce simplified JSON representation rendering for Page instances to make sure the representations stay stable and do not expose unnecessary implementation details. The support consists of the following elements: - PagedModel, a stripped down variant of the Spring HATEOAS counterpart to produce an equivalent JSON representation but without the hypermedia elements. This allows a gradual migration to Spring HATEOAS if needed. Page instances can be wrapped into PagedModel once and returned from controller methods to create the new, simplified JSON representation. - @EnableSpringDataWeb support now contains a pageSerializationMode attribute set to an enum with two possible values: DIRECT, which is the default for backwards compatibility reasons. It serializes Page instances directly but issues a warning that either the newly introduced support here or the Spring HATEOAS support should be used to avoid accidentally breaking representations. The other value, VIA_DTO causes all PageImpl instances to be rendered being wrapped in a PagedModel automatically by registering a Jackson StdConverter applying the wrapping transparently. Internally, the configuration of @EnableSpringDataWebSupport is translated into a bean definition of a newly introduced type SpringDataWebSettings and wired into the web configuration for consideration within a Jackson module, customizing the serialization for PageImpl. Fixes GH-3024. --- .../pages/repositories/core-extensions.adoc | 88 ++++++++++++++++- .../springframework/data/web/PagedModel.java | 94 +++++++++++++++++++ .../config/EnableSpringDataWebSupport.java | 78 ++++++++++++++- .../SpringDataJacksonConfiguration.java | 49 +++++++++- .../web/config/SpringDataWebSettings.java | 26 +++++ .../PageImplJsonSerializationUnitTests.java | 25 ++++- ...eSpringDataWebSupportIntegrationTests.java | 12 +++ 7 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/springframework/data/web/PagedModel.java create mode 100644 src/main/java/org/springframework/data/web/config/SpringDataWebSettings.java diff --git a/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc b/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc index 3683dbd2b..07a51ab5a 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc @@ -189,8 +189,92 @@ You have to populate `thing1_page`, `thing2_page`, and so on. The default `Pageable` passed into the method is equivalent to a `PageRequest.of(0, 20)`, but you can customize it by using the `@PageableDefault` annotation on the `Pageable` parameter. +[[core.web.page]] +=== Creating JSON representations for `Page` + +It's common for Spring MVC controllers to try to ultimately render a representation of a Spring Data page to clients. +While one could simply return `Page` instances from handler methods to let Jackson render them as is, we strongly recommend against this as the underlying implementation class `PageImpl` is a domain type. +This means we might want or have to change its API for unrelated reasons, and such changes might alter the resulting JSON representation in a breaking way. + +With Spring Data 3.1, we started hinting at the problem by issuing a warning log describing the problem. +We still ultimately recommend to leverage xref:repositories/core-extensions.adoc#core.web.pageables[the integration with Spring HATEOAS] for a fully stable and hypermedia-enabled way of rendering pages that easily allow clients to navigate them. +But as of version 3.3 Spring Data ships a page rendering mechanism that is convenient to use but does not require the inclusion of Spring HATEOAS. + +[[core.web.page.paged-model]] +==== Using Spring Data' `PagedModel` + +At its core, the support consists of a simplified version of Spring HATEOAS' `PagedModel` (the Spring Data one located in the `org.springframework.data.web` package). +It can be used to wrap `Page` instances and result in a simplified representation that reflects the structure established by Spring HATEOAS but omits the navigation links. + +[source, java] +---- +import org.springframework.data.web.PagedModel; + +@Controller +class MyController { + + private final MyRepository repository; + + // Constructor ommitted + + @GetMapping("/page") + PagedModel page(Pageable pageable) { + return new PagedModel<>(repository.findAll(pageable)); // <1> + } +} +---- +<1> Wraps the `Page` instance into a `PagedModel`. + +This will result in a JSON structure looking like this: + +[source, javascript] +---- +{ + "content" : [ + … // Page content rendered here + ], + "page" : { + "size" : 20, + "totalElements" : 30, + "totalPages" : 2, + "number" : 0 + } +} +---- + +Note how the document contains a `page` field exposing the essential pagination metadata. + +[[core.web.page.config]] +==== Globally enabling simplified `Page` rendering + +If you don't want to change all your existing controllers to add the mapping step to return `PagedModel` instead of `Page` you can enable the automatic translation of `PageImpl` instances into `PagedModel` by tweaking `@EnableSpringDataWebSupport` as follows: + +[source, java] +---- +@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) +class MyConfiguration { } +---- + +This will allow your controller to still return `Page` instances and they will automatically be rendered into the simplified representation: + +[source, java] +---- +@Controller +class MyController { + + private final MyRepository repository; + + // Constructor ommitted + + @GetMapping("/page") + Page page(Pageable pageable) { + return repository.findAll(pageable); + } +} +---- + [[core.web.pageables]] -=== Hypermedia Support for `Page` and `Slice` +==== Hypermedia Support for `Page` and `Slice` Spring HATEOAS ships with a representation model class (`PagedModel`/`SlicedModel`) that allows enriching the content of a `Page` or `Slice` instance with the necessary `Page`/`Slice` metadata as well as links to let the clients easily navigate the pages. The conversion of a `Page` to a `PagedModel` is done by an implementation of the Spring HATEOAS `RepresentationModelAssembler` interface, called the `PagedResourcesAssembler`. @@ -237,7 +321,7 @@ You can now trigger a request (`GET http://localhost:8080/people`) and see outpu "content" : [ … // 20 Person instances rendered here ], - "pageMetadata" : { + "page" : { "size" : 20, "totalElements" : 30, "totalPages" : 2, diff --git a/src/main/java/org/springframework/data/web/PagedModel.java b/src/main/java/org/springframework/data/web/PagedModel.java new file mode 100644 index 000000000..a7a891bf7 --- /dev/null +++ b/src/main/java/org/springframework/data/web/PagedModel.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024 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.data.web; + +import java.util.List; +import java.util.Objects; + +import org.springframework.data.domain.Page; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO to build stable JSON representations of a Spring Data {@link Page}. It can either be selectively used in + * controller methods by calling {@code new PagedModel<>(page)} or generally activated as representation model for + * {@link org.springframework.data.domain.PageImpl} instances by setting + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport}'s {@code pageSerializationMode} to + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode#VIA_DTO}. + * + * @author Oliver Drotbohm + * @author Greg Turnquist + * @since 3.3 + */ +public class PagedModel { + + private final Page page; + + /** + * Creates a new {@link PagedModel} for the given {@link Page}. + * + * @param page must not be {@literal null}. + */ + public PagedModel(Page page) { + + Assert.notNull(page, "Page must not be null"); + + this.page = page; + } + + @JsonProperty + public List getContent() { + return page.getContent(); + } + + @Nullable + @JsonProperty("page") + public PageMetadata getMetadata() { + return new PageMetadata(page.getSize(), page.getNumber(), page.getTotalElements(), + page.getTotalPages()); + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof PagedModel that)) { + return false; + } + + return Objects.equals(this.page, that.page); + } + + @Override + public int hashCode() { + return Objects.hash(page); + } + + public static record PageMetadata(long size, long number, long totalElements, long totalPages) { + + public PageMetadata { + Assert.isTrue(size > -1, "Size must not be negative!"); + Assert.isTrue(number > -1, "Number must not be negative!"); + Assert.isTrue(totalElements > -1, "Total elements must not be negative!"); + Assert.isTrue(totalPages > -1, "Total pages must not be negative!"); + } + } +} diff --git a/src/main/java/org/springframework/data/web/config/EnableSpringDataWebSupport.java b/src/main/java/org/springframework/data/web/config/EnableSpringDataWebSupport.java index d394e8032..4efb0f9e6 100644 --- a/src/main/java/org/springframework/data/web/config/EnableSpringDataWebSupport.java +++ b/src/main/java/org/springframework/data/web/config/EnableSpringDataWebSupport.java @@ -22,16 +22,21 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanNameGenerator; import org.springframework.context.ResourceLoaderAware; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; import org.springframework.context.annotation.ImportSelector; import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.core.type.AnnotationMetadata; import org.springframework.data.querydsl.QuerydslUtils; -import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.util.ClassUtils; /** @@ -68,10 +73,42 @@ import org.springframework.util.ClassUtils; @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) @Inherited -@Import({ EnableSpringDataWebSupport.SpringDataWebConfigurationImportSelector.class, - EnableSpringDataWebSupport.QuerydslActivator.class }) +@Import({ + EnableSpringDataWebSupport.SpringDataWebConfigurationImportSelector.class, + EnableSpringDataWebSupport.QuerydslActivator.class, + EnableSpringDataWebSupport.SpringDataWebSettingsRegistar.class +}) public @interface EnableSpringDataWebSupport { + /** + * Configures how to render {@link org.springframework.data.domain.PageImpl} instances. Defaults to + * {@link PageSerializationMode#DIRECT} for backward compatibility reasons. Prefer explicitly setting this to + * {@link PageSerializationMode#VIA_DTO}, or manually convert {@link org.springframework.data.domain.PageImpl} + * instances before handing them out of a controller method, either by manually calling {@code new PagedModel<>(page)} + * or using Spring HATEOAS {@link org.springframework.hateoas.PagedModel} abstraction. + * + * @return will never be {@literal null}. + * @since 3.3 + */ + PageSerializationMode pageSerializationMode() default PageSerializationMode.DIRECT; + + enum PageSerializationMode { + + /** + * {@link org.springframework.data.domain.PageImpl} instances will be rendered as is (discouraged, as there's no + * guarantee on the stability of the serialization result as we might need to change the type's API for unrelated + * reasons). + */ + DIRECT, + + /** + * Causes {@link org.springframework.data.domain.PageImpl} instances to be wrapped into + * {@link org.springframework.data.web.PagedModel} instances before rendering them as JSON to make sure the + * representation stays stable even if {@link org.springframework.data.domain.PageImpl} is changed. + */ + VIA_DTO; + } + /** * Import selector to import the appropriate configuration class depending on whether Spring HATEOAS is present on the * classpath. We need to register the HATEOAS specific class first as apparently only the first class implementing @@ -127,4 +164,39 @@ public @interface EnableSpringDataWebSupport { : new String[0]; } } + + /** + * Registers a bean definition for {@link SpringDataWebSettings} carrying the configuration values of + * {@link EnableSpringDataWebSupport}. + * + * @author Oliver Drotbohm + * @soundtrack Norah Jones - Chasing Pirates + * @since 3.3 + */ + static class SpringDataWebSettingsRegistar implements ImportBeanDefinitionRegistrar { + + /* + * (non-Javadoc) + * @see org.springframework.context.annotation.ImportBeanDefinitionRegistrar#registerBeanDefinitions(org.springframework.core.type.AnnotationMetadata, org.springframework.beans.factory.support.BeanDefinitionRegistry, org.springframework.beans.factory.support.BeanNameGenerator) + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry, + BeanNameGenerator importBeanNameGenerator) { + + Map attributes = importingClassMetadata + .getAnnotationAttributes(EnableSpringDataWebSupport.class.getName()); + + if (attributes == null) { + return; + } + + AbstractBeanDefinition definition = BeanDefinitionBuilder.rootBeanDefinition(SpringDataWebSettings.class) + .addConstructorArgValue(attributes.get("pageSerializationMode")) + .getBeanDefinition(); + + String beanName = importBeanNameGenerator.generateBeanName(definition, registry); + + registry.registerBeanDefinition(beanName, definition); + } + } } diff --git a/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java b/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java index 21c4b8716..42373509f 100644 --- a/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java +++ b/src/main/java/org/springframework/data/web/config/SpringDataJacksonConfiguration.java @@ -17,10 +17,13 @@ package org.springframework.data.web.config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.geo.GeoModule; +import org.springframework.data.web.PagedModel; +import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; @@ -36,6 +39,8 @@ import com.fasterxml.jackson.databind.util.StdConverter; */ public class SpringDataJacksonConfiguration implements SpringDataJacksonModules { + @Nullable @Autowired(required = false) SpringDataWebSettings settings; + @Bean public GeoModule jacksonGeoModule() { return new GeoModule(); @@ -43,9 +48,20 @@ public class SpringDataJacksonConfiguration implements SpringDataJacksonModules @Bean public PageModule pageModule() { - return new PageModule(); + return new PageModule(settings); } + /** + * A Jackson module customizing the serialization of {@link PageImpl} instances depending on the + * {@link SpringDataWebSettings} handed into the instance. In case of + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode#DIRECT} being + * configured, a no-op {@link StdConverter} is registered to issue a one-time warning about the mode being used (as + * it's not recommended). + * {@link org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode#VIA_DTO} would register + * a converter wrapping {@link PageImpl} instances into {@link PagedModel}. + * + * @author Oliver Drotbohm + */ public static class PageModule extends SimpleModule { private static final long serialVersionUID = 275254460581626332L; @@ -57,10 +73,20 @@ public class SpringDataJacksonConfiguration implements SpringDataJacksonModules UNPAGED_TYPE = ClassUtils.resolveClassName(UNPAGED_TYPE_NAME, PageModule.class.getClassLoader()); } - public PageModule() { + /** + * Creates a new {@link PageModule} for the given {@link SpringDataWebSettings}. + * + * @param settings can be {@literal null}. + */ + public PageModule(@Nullable SpringDataWebSettings settings) { addSerializer(UNPAGED_TYPE, new UnpagedAsInstanceSerializer()); - setMixInAnnotation(PageImpl.class, PageImplMixin.class); + + if (settings != null && settings.pageSerializationMode() == PageSerializationMode.DIRECT) { + setMixInAnnotation(PageImpl.class, WarningMixing.class); + } else { + setMixInAnnotation(PageImpl.class, WrappingMixing.class); + } } /** @@ -89,14 +115,27 @@ public class SpringDataJacksonConfiguration implements SpringDataJacksonModules * @author Oliver Drotbohm */ @JsonSerialize(converter = PlainPageSerializationWarning.class) - abstract class PageImplMixin {} + abstract class WarningMixing {} + + @JsonSerialize(converter = PageModelConverter.class) + abstract class WrappingMixing {} + + static class PageModelConverter extends StdConverter, PagedModel> { + + @Nullable + @Override + public PagedModel convert(@Nullable Page value) { + return value == null ? null : new PagedModel<>(value); + } + } static class PlainPageSerializationWarning extends StdConverter, Page> { private static final Logger LOGGER = LoggerFactory.getLogger(PlainPageSerializationWarning.class); private static final String MESSAGE = """ Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! - For a stable JSON structure, please use Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. + For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)) + or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables. """; private boolean warningRendered = false; diff --git a/src/main/java/org/springframework/data/web/config/SpringDataWebSettings.java b/src/main/java/org/springframework/data/web/config/SpringDataWebSettings.java new file mode 100644 index 000000000..e4072ef14 --- /dev/null +++ b/src/main/java/org/springframework/data/web/config/SpringDataWebSettings.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 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.data.web.config; + +import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; + +/** + * All Spring Data web-related settings (usually via {@link EnableSpringDataWebSupport}). + * + * @author Oliver Drotbohm + * @since 3.3 + */ +public record SpringDataWebSettings(PageSerializationMode pageSerializationMode) {} diff --git a/src/test/java/org/springframework/data/web/PageImplJsonSerializationUnitTests.java b/src/test/java/org/springframework/data/web/PageImplJsonSerializationUnitTests.java index 08db6b1a4..6a5251951 100644 --- a/src/test/java/org/springframework/data/web/PageImplJsonSerializationUnitTests.java +++ b/src/test/java/org/springframework/data/web/PageImplJsonSerializationUnitTests.java @@ -21,9 +21,12 @@ import java.util.Collections; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageImpl; +import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode; import org.springframework.data.web.config.SpringDataJacksonConfiguration; +import org.springframework.data.web.config.SpringDataWebSettings; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; /** * Unit tests for PageImpl serialization. @@ -32,14 +35,30 @@ import com.fasterxml.jackson.databind.ObjectMapper; */ class PageImplJsonSerializationUnitTests { - @Test + @Test // GH-3024 void serializesPageImplAsJson() { + assertJsonRendering(PageSerializationMode.DIRECT, "$.pageable", "$.last", "$.first"); + } + + @Test // GH-3024 + void serializesPageImplAsPagedModel() { + assertJsonRendering(PageSerializationMode.VIA_DTO, "$.content", "$.page"); + } + + private static void assertJsonRendering(PageSerializationMode mode, String... jsonPaths) { + + SpringDataWebSettings settings = new SpringDataWebSettings(mode); ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new SpringDataJacksonConfiguration.PageModule()); + mapper.registerModule(new SpringDataJacksonConfiguration.PageModule(settings)); assertThatNoException().isThrownBy(() -> { - mapper.writeValueAsString(new PageImpl<>(Collections.emptyList())); + + String result = mapper.writeValueAsString(new PageImpl<>(Collections.emptyList())); + + for (String jsonPath : jsonPaths) { + assertThat(JsonPath. read(result, jsonPath)).isNotNull(); + } }); } } diff --git a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java index 98b8f22bb..34b7330f4 100755 --- a/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java +++ b/src/test/java/org/springframework/data/web/config/EnableSpringDataWebSupportIntegrationTests.java @@ -38,6 +38,7 @@ import org.springframework.data.web.PagedResourcesAssemblerArgumentResolver; import org.springframework.data.web.ProxyingHandlerMethodArgumentResolver; import org.springframework.data.web.SortHandlerMethodArgumentResolver; import org.springframework.data.web.WebTestUtils; +import org.springframework.data.web.config.SpringDataJacksonConfiguration.PageModule; import org.springframework.hateoas.Link; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -261,6 +262,17 @@ class EnableSpringDataWebSupportIntegrationTests { .isEqualTo(CustomEntityPathResolver.resolver); } + @Test // GH-3024 + void registersSpringDataWebSettingsBean() { + + ApplicationContext context = WebTestUtils.createApplicationContext(SampleConfig.class); + + assertThatNoException().isThrownBy(() -> { + assertThat(context.getBean(SpringDataWebSettings.class)); + assertThat(context.getBean(PageModule.class)); + }); + } + private static void assertResolversRegistered(ApplicationContext context, Class... resolverTypes) { var adapter = context.getBean(RequestMappingHandlerAdapter.class);