Skip to content

Commit 736bed6

Browse files
author
adrien caubel
committed
Provide a SlicedModel DTO to render stable JSON representations of Slice.
Related tickets #3515 Signed-off-by: Adrien Caubel <adri.cbl@laposte.net> Signed-off-by: adrien caubel <adriencaubel@macbook-pro-de-adrien.home>
1 parent 0ba14c1 commit 736bed6

10 files changed

Lines changed: 493 additions & 22 deletions

File tree

src/main/antora/modules/ROOT/pages/aot.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,5 @@ These are in particular hints for:
157157
** Repository fragments
158158
** Querydsl `Q` classes
159159
** Kotlin Coroutine support
160-
* Web support (Jackson Hints for `PagedModel`)
160+
* Web support (Jackson Hints for `PagedModel` and `SlicedModel`)
161161

src/main/antora/modules/ROOT/pages/repositories/core-extensions-web.adoc

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,18 +195,63 @@ This will result in a JSON structure looking like this:
195195

196196
Note how the document contains a `page` field exposing the essential pagination metadata.
197197

198+
[[core.web.page.sliced-model]]
199+
=== Using Spring Data's `SlicedModel`
200+
201+
`Slice` instances have the same rendering-stability problem as `Page` instances, and `org.springframework.data.web.SlicedModel` is their equivalent DTO.
202+
It is used in exactly the same way:
203+
204+
[source,java]
205+
----
206+
import org.springframework.data.web.SlicedModel;
207+
208+
@Controller
209+
class MyController {
210+
211+
private final MyRepository repository;
212+
213+
// Constructor omitted
214+
215+
@GetMapping("/slice")
216+
SlicedModel<?> slice(Pageable pageable) {
217+
return new SlicedModel<>(repository.findAllBy(pageable)); // <1>
218+
}
219+
}
220+
----
221+
222+
<1> Wraps the `Slice` instance into a `SlicedModel`.
223+
224+
This will result in a JSON structure looking like this:
225+
226+
[source,javascript]
227+
----
228+
{
229+
"content" : [
230+
… // Slice content rendered here
231+
],
232+
"page" : {
233+
"size" : 20,
234+
"number" : 0,
235+
"hasNext" : true
236+
}
237+
}
238+
----
239+
240+
The metadata is exposed under the same `page` field as for `PagedModel`.
241+
As a `Slice` does not know the total number of elements, `totalElements` and `totalPages` are replaced by `hasNext`, which tells the client whether a further slice can be requested.
242+
198243
[[core.web.page.config]]
199-
=== Globally enabling simplified `Page` rendering
244+
=== Globally enabling simplified `Page` and `Slice` rendering
200245

201-
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:
246+
If you don't want to change all your existing controllers to add the mapping step to return `PagedModel` instead of `Page` (or `SlicedModel` instead of `Slice`) you can enable the automatic translation of `PageImpl` instances into `PagedModel` and of `SliceImpl` instances into `SlicedModel` by tweaking `@EnableSpringDataWebSupport` as follows:
202247

203248
[source,java]
204249
----
205250
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
206251
class MyConfiguration { }
207252
----
208253

209-
This will allow your controller to still return `Page` instances and they will automatically be rendered into the simplified representation:
254+
This will allow your controller to still return `Page` and `Slice` instances and they will automatically be rendered into the simplified representation:
210255

211256
[source,java]
212257
----
@@ -221,6 +266,11 @@ class MyController {
221266
Page<?> page(Pageable pageable) {
222267
return repository.findAll(pageable);
223268
}
269+
270+
@GetMapping("/slice")
271+
Slice<?> slice(Pageable pageable) {
272+
return repository.findAllBy(pageable);
273+
}
224274
}
225275
----
226276

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2026-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.web;
17+
18+
import java.util.List;
19+
import java.util.Objects;
20+
21+
import org.jspecify.annotations.Nullable;
22+
23+
import org.springframework.data.domain.Slice;
24+
import org.springframework.util.Assert;
25+
26+
import com.fasterxml.jackson.annotation.JsonProperty;
27+
28+
/**
29+
* DTO to build stable JSON representations of a Spring Data {@link Slice}. It can either be selectively used in
30+
* controller methods by calling {@code new SlicedModel<>(slice)} or generally activated as representation model for
31+
* {@link org.springframework.data.domain.SliceImpl} instances by setting
32+
* {@link org.springframework.data.web.config.EnableSpringDataWebSupport}'s {@code pageSerializationMode} to
33+
* {@link org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode#VIA_DTO}.
34+
*
35+
* @author Adrien Caubel
36+
* @since 4.2
37+
*/
38+
public class SlicedModel<T> {
39+
40+
private final Slice<T> slice;
41+
42+
/**
43+
* Creates a new {@link SlicedModel} for the given {@link Slice}.
44+
*
45+
* @param slice must not be {@literal null}.
46+
*/
47+
public SlicedModel(Slice<T> slice) {
48+
49+
Assert.notNull(slice, "Slice must not be null");
50+
51+
this.slice = slice;
52+
}
53+
54+
@JsonProperty
55+
public List<T> getContent() {
56+
return slice.getContent();
57+
}
58+
59+
@JsonProperty("page")
60+
public SliceMetadata getMetadata() {
61+
return new SliceMetadata(slice.getSize(), slice.getNumber(), slice.hasNext());
62+
}
63+
64+
@Override
65+
public boolean equals(@Nullable Object obj) {
66+
67+
if (this == obj) {
68+
return true;
69+
}
70+
71+
if (!(obj instanceof SlicedModel<?> that)) {
72+
return false;
73+
}
74+
75+
return Objects.equals(this.slice, that.slice);
76+
}
77+
78+
@Override
79+
public int hashCode() {
80+
return Objects.hash(slice);
81+
}
82+
83+
public record SliceMetadata(long size, long number, boolean hasNext) {
84+
85+
public SliceMetadata {
86+
Assert.isTrue(size > -1, "Size must not be negative!");
87+
Assert.isTrue(number > -1, "Number must not be negative!");
88+
}
89+
}
90+
}

src/main/java/org/springframework/data/web/aot/WebRuntimeHints.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.aot.hint.RuntimeHintsRegistrar;
2323
import org.springframework.aot.hint.TypeReference;
2424
import org.springframework.data.web.PagedModel;
25+
import org.springframework.data.web.SlicedModel;
2526
import org.springframework.data.web.config.EnableSpringDataWebSupport;
2627
import org.springframework.data.web.config.SpringDataJackson3Configuration;
2728
import org.springframework.data.web.config.SpringDataJacksonConfiguration.PageModule;
@@ -32,6 +33,7 @@
3233
*
3334
* @author Christoph Strobl
3435
* @author Mark Paluch
36+
* @author Adrien Caubel
3537
* @since 3.2.3
3638
*/
3739
class WebRuntimeHints implements RuntimeHintsRegistrar {
@@ -57,6 +59,13 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader)
5759
hints.reflection().registerType(PagedModel.PageMetadata.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
5860
MemberCategory.INVOKE_PUBLIC_METHODS);
5961

62+
// Slice Model for Jackson Rendering
63+
hints.reflection().registerType(org.springframework.data.web.SlicedModel.class,
64+
MemberCategory.INVOKE_PUBLIC_METHODS);
65+
66+
hints.reflection().registerType(SlicedModel.SliceMetadata.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
67+
MemberCategory.INVOKE_PUBLIC_METHODS);
68+
6069
hints.reflection().registerType(TypeReference.of("org.springframework.data.domain.Unpaged"));
6170

6271
if (JACKSON2_PRESENT) {
@@ -80,8 +89,15 @@ private static void contributeJackson2Hints(RuntimeHints hints) {
8089
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
8190
hint.onReachableType(PageModule.class);
8291
});
92+
hints.reflection().registerType(
93+
TypeReference
94+
.of("org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$SlicedModelConverter"),
95+
hint -> {
96+
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
97+
hint.onReachableType(PageModule.class);
98+
});
8399
hints.reflection().registerType(TypeReference.of(
84-
"org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$PlainPageSerializationWarning"),
100+
"org.springframework.data.web.config.SpringDataJacksonConfiguration$PageModule$WarningLoggingModifier"),
85101
hint -> {
86102
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
87103
hint.onReachableType(PageModule.class);
@@ -98,8 +114,15 @@ private static void contributeJackson3Hints(RuntimeHints hints) {
98114
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
99115
hint.onReachableType(SpringDataJackson3Configuration.PageModule.class);
100116
});
117+
hints.reflection().registerType(
118+
TypeReference
119+
.of("org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$SlicedModelConverter"),
120+
hint -> {
121+
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
122+
hint.onReachableType(SpringDataJackson3Configuration.PageModule.class);
123+
});
101124
hints.reflection().registerType(TypeReference.of(
102-
"org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$PlainPageSerializationWarning"),
125+
"org.springframework.data.web.config.SpringDataJackson3Configuration$PageModule$WarningLoggingModifier"),
103126
hint -> {
104127
hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS);
105128
hint.onReachableType(SpringDataJackson3Configuration.PageModule.class);

src/main/java/org/springframework/data/web/config/SpringDataJackson3Configuration.java

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@
3333
import org.springframework.context.annotation.Bean;
3434
import org.springframework.data.domain.Page;
3535
import org.springframework.data.domain.PageImpl;
36+
import org.springframework.data.domain.Slice;
37+
import org.springframework.data.domain.SliceImpl;
3638
import org.springframework.data.geo.GeoJacksonModule;
3739
import org.springframework.data.web.PagedModel;
40+
import org.springframework.data.web.SlicedModel;
3841
import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode;
3942
import org.springframework.util.ClassUtils;
4043

@@ -43,6 +46,7 @@
4346
*
4447
* @author Oliver Gierke
4548
* @author Mark Paluch
49+
* @author Adrien Caubel
4650
* @since 4.0
4751
*/
4852
public class SpringDataJackson3Configuration implements SpringDataJackson3Modules {
@@ -62,13 +66,14 @@ public PageModule jackson3pageModule() {
6266
}
6367

6468
/**
65-
* A Jackson module customizing the serialization of {@link PageImpl} instances depending on the
69+
* A Jackson module customizing the serialization of {@link PageImpl} and {@link SliceImpl} instances depending on the
6670
* {@link SpringDataWebSettings} handed into the instance. In case of {@link PageSerializationMode#DIRECT} being
6771
* configured, a no-op {@link StdConverter} is registered to issue a one-time warning about the mode being used (as
68-
* it's not recommended). {@link PageSerializationMode#VIA_DTO} would register a converter wrapping {@link PageImpl}
69-
* instances into {@link PagedModel}.
72+
* it's not recommended). {@link PageSerializationMode#VIA_DTO} would register converters wrapping {@link PageImpl}
73+
* instances into {@link PagedModel} and {@link SliceImpl} instances into {@link SlicedModel}.
7074
*
7175
* @author Oliver Drotbohm
76+
* @author Adrien Caubel
7277
*/
7378
public static class PageModule extends SimpleModule {
7479

@@ -95,6 +100,7 @@ public PageModule(@Nullable SpringDataWebSettings settings) {
95100

96101
} else {
97102
setMixInAnnotation(PageImpl.class, WrappingMixing.class);
103+
setMixInAnnotation(SliceImpl.class, SliceWrappingMixin.class);
98104
}
99105
}
100106

@@ -127,18 +133,30 @@ static class PageModelConverter extends StdConverter<Page<?>, PagedModel<?>> {
127133
}
128134
}
129135

136+
@JsonSerialize(converter = SlicedModelConverter.class)
137+
abstract static class SliceWrappingMixin {}
138+
139+
static class SlicedModelConverter extends StdConverter<Slice<?>, SlicedModel<?>> {
140+
141+
@Override
142+
public @Nullable SlicedModel<?> convert(@Nullable Slice<?> value) {
143+
return value == null ? null : new SlicedModel<>(value);
144+
}
145+
}
146+
130147
/**
131-
* A {@link ValueSerializerModifier} that logs a warning message if an instance of {@link Page} will be rendered.
148+
* A {@link ValueSerializerModifier} that logs a warning message if an instance of {@link Slice} (which includes
149+
* {@link Page}) will be rendered.
132150
*
133151
* @author Oliver Drotbohm
134152
*/
135153
static class WarningLoggingModifier extends ValueSerializerModifier {
136154

137155
private static final Logger LOGGER = LoggerFactory.getLogger(WarningLoggingModifier.class);
138156
private static final String MESSAGE = """
139-
Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
140-
For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
141-
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.
157+
Serializing PageImpl and SliceImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
158+
For a stable JSON structure, please use Spring Data's PagedModel or SlicedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
159+
or Spring HATEOAS and Spring Data's PagedResourcesAssembler or SlicedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables.
142160
""";
143161

144162
private static final @Serial long serialVersionUID = 954857444010009875L;
@@ -149,7 +167,7 @@ static class WarningLoggingModifier extends ValueSerializerModifier {
149167
public List<BeanPropertyWriter> changeProperties(tools.jackson.databind.SerializationConfig config,
150168
tools.jackson.databind.BeanDescription.Supplier beanDesc, List<BeanPropertyWriter> beanProperties) {
151169

152-
if (Page.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) {
170+
if (Slice.class.isAssignableFrom(beanDesc.getBeanClass()) && !warningRendered) {
153171

154172
this.warningRendered = true;
155173
LOGGER.warn(MESSAGE);

0 commit comments

Comments
 (0)