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