Skip to content

Commit 70f21bd

Browse files
mschoutodrotbohm
authored andcommitted
Add SlicedResourcesAssembler for web integration.
Added SlicedResourcesAssembler to esaily convert Slice instances into SlicedResource instances and automatically build the required previous/next link based on PageableHandlerMethodArgumentResolver present in the MVC configuration. The assembler can either be injected into a Spring MVC controller or a controller method. The latter will then assume the controller methods URI to be used as pagination link base. Added necessary SlicedResourcesAssemblerArgumentResolver and MethodParameterAwareSlicedResourcesAssembler classes and wire up HateoasAwareSpringDataWebConfiguration configuration beans to that SlicedResourcesAssembler's can be auto-injected into controllers. Closes #1307
1 parent 8365566 commit 70f21bd

8 files changed

+1031
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2022 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 org.springframework.core.MethodParameter;
19+
import org.springframework.lang.NonNull;
20+
import org.springframework.lang.Nullable;
21+
import org.springframework.util.Assert;
22+
import org.springframework.web.util.UriComponents;
23+
24+
/**
25+
* Custom {@link SlicedResourcesAssembler} that is aware of the {@link MethodParameter} it shall create links for.
26+
*
27+
* @author Michael Schout
28+
*/
29+
public class MethodParameterAwareSlicedResourcesAssembler<T> extends SlicedResourcesAssembler<T> {
30+
private final MethodParameter parameter;
31+
32+
/**
33+
* Creates a new {@link MethodParameterAwareSlicedResourcesAssembler} using the given
34+
* {@link MethodParameter}, {@link HateoasPageableHandlerMethodArgumentResolver} and base
35+
* URI.
36+
*
37+
* @param parameter must not be {@literal null}.
38+
* @param resolver can be {@literal null}.
39+
* @param baseUri can be {@literal null}.
40+
*/
41+
public MethodParameterAwareSlicedResourcesAssembler(MethodParameter parameter,
42+
@Nullable HateoasPageableHandlerMethodArgumentResolver resolver, @Nullable UriComponents baseUri) {
43+
44+
super(resolver, baseUri);
45+
46+
Assert.notNull(parameter, "Method parameter must not be null");
47+
this.parameter = parameter;
48+
}
49+
50+
@NonNull
51+
@Override
52+
protected MethodParameter getMethodParameter() {
53+
return parameter;
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/*
2+
* Copyright 2022 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 static org.springframework.web.util.UriComponentsBuilder.fromUri;
19+
20+
import java.util.ArrayList;
21+
import java.util.Collections;
22+
import java.util.List;
23+
import java.util.Optional;
24+
25+
import org.springframework.core.MethodParameter;
26+
import org.springframework.data.domain.PageRequest;
27+
import org.springframework.data.domain.Pageable;
28+
import org.springframework.data.domain.Slice;
29+
import org.springframework.hateoas.*;
30+
import org.springframework.hateoas.SlicedModel.SliceMetadata;
31+
import org.springframework.hateoas.server.RepresentationModelAssembler;
32+
import org.springframework.hateoas.server.core.EmbeddedWrapper;
33+
import org.springframework.hateoas.server.core.EmbeddedWrappers;
34+
import org.springframework.lang.Nullable;
35+
import org.springframework.util.Assert;
36+
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
37+
import org.springframework.web.util.UriComponents;
38+
import org.springframework.web.util.UriComponentsBuilder;
39+
40+
/**
41+
* {@link RepresentationModelAssembler} to easily convert {@link Slice} instances into
42+
* {@link SlicedModel}.
43+
*
44+
* @author Michael Schout
45+
*/
46+
public class SlicedResourcesAssembler<T>
47+
implements RepresentationModelAssembler<Slice<T>, SlicedModel<EntityModel<T>>> {
48+
49+
private final HateoasPageableHandlerMethodArgumentResolver pageableResolver;
50+
51+
private final Optional<UriComponents> baseUri;
52+
private final EmbeddedWrappers wrappers = new EmbeddedWrappers(false);
53+
54+
private boolean forceFirstRel = false;
55+
56+
/**
57+
* Creates a new {@link SlicedResourcesAssembler} using the given
58+
* {@link PageableHandlerMethodArgumentResolver} and base URI. If the former is
59+
* {@literal null}, a default one will be created. If the latter is {@literal null}, calls
60+
* to {@link #toModel(Slice)} will use the current request's URI to build the relevant
61+
* previous and next links.
62+
*
63+
* @param resolver can be {@literal null}.
64+
* @param baseUri can be {@literal null}.
65+
*/
66+
public SlicedResourcesAssembler(@Nullable HateoasPageableHandlerMethodArgumentResolver resolver,
67+
@Nullable UriComponents baseUri) {
68+
this.pageableResolver = resolver == null ? new HateoasPageableHandlerMethodArgumentResolver() : resolver;
69+
this.baseUri = Optional.ofNullable(baseUri);
70+
}
71+
72+
private static String currentRequest() {
73+
return ServletUriComponentsBuilder.fromCurrentRequest().build().toString();
74+
}
75+
76+
/**
77+
* Configures whether to always add {@code first} links to the {@link SlicedModel} *
78+
* created. Defaults to {@literal false} which means that {@code first} links onlys appear
79+
* in conjunction with {@code prev} and {@code next} links.
80+
*
81+
* @param forceFirstRel whether to always add {@code first} links to the
82+
* {@link SlicedModel} created.
83+
*/
84+
public void setForceFirstRel(boolean forceFirstRel) {
85+
this.forceFirstRel = forceFirstRel;
86+
}
87+
88+
@Override
89+
public SlicedModel<EntityModel<T>> toModel(Slice<T> entity) {
90+
return toModel(entity, EntityModel::of);
91+
}
92+
93+
/**
94+
* Creates a new {@link SlicedModel} by converting the given {@link Slice} into a
95+
* {@link SliceMetadata} instance and wrapping the contained elements into *
96+
* {@link SlicedModel} instances. Will add pagination links based on the given self link.
97+
*
98+
* @param slice must not be {@literal null}.
99+
* @param selfLink must not be {@literal null}.
100+
* @return
101+
*/
102+
public SlicedModel<EntityModel<T>> toModel(Slice<T> slice, Link selfLink) {
103+
return toModel(slice, EntityModel::of, selfLink);
104+
}
105+
106+
/**
107+
* Creates a new {@link SlicedModel} by converting the given {@link Slice} into a
108+
* {@link SliceMetadata} instance and using the given {@link SlicedModel} to turn elements
109+
* of the {@link Slice} into resources.
110+
*
111+
* @param slice must not be {@literal null}.
112+
* @param assembler must not be {@literal null}.
113+
* @return
114+
*/
115+
public <R extends RepresentationModel<?>> SlicedModel<R> toModel(Slice<T> slice,
116+
RepresentationModelAssembler<T, R> assembler) {
117+
return createModel(slice, assembler, Optional.empty());
118+
}
119+
120+
/**
121+
* Creates a new {@link SlicedModel} by converting the given {@link Slice} into a
122+
* {@link SliceMetadata} instance and using the given {@link SlicedModel} to turn elements
123+
* of the {@link Slice} into resources. Will add pagination links based on the given the
124+
* self link.
125+
*
126+
* @param slice must not be {@literal null}.
127+
* @param assembler must not be {@literal null}.
128+
* @param link must not be {@literal null}.
129+
* @return
130+
*/
131+
public <R extends RepresentationModel<?>> SlicedModel<R> toModel(Slice<T> slice,
132+
RepresentationModelAssembler<T, R> assembler, Link link) {
133+
return createModel(slice, assembler, Optional.of(link));
134+
}
135+
136+
/**
137+
* Creates a {@link SlicedModel} with an empty collection {@link EmbeddedWrapper} for the
138+
* given domain type.
139+
*
140+
* @param slice must not be {@literal null}, content must be empty.
141+
* @param type must not be {@literal null}.
142+
* @return
143+
*/
144+
public SlicedModel<?> toEmptyModel(Slice<?> slice, Class<?> type) {
145+
return toEmptyModel(slice, type, Optional.empty());
146+
}
147+
148+
/**
149+
* Creates a {@link SlicedModel} with an empty collection {@link EmbeddedWrapper} for the
150+
* given domain type.
151+
*
152+
* @param slice must not be {@literal null}, content must be empty.
153+
* @param type must not be {@literal null}.
154+
* @param link must not be {@literal null}.
155+
* @return
156+
*/
157+
public SlicedModel<?> toEmptyModel(Slice<?> slice, Class<?> type, Link link) {
158+
return toEmptyModel(slice, type, Optional.of(link));
159+
}
160+
161+
public SlicedModel<?> toEmptyModel(Slice<?> slice, Class<?> type, Optional<Link> link) {
162+
Assert.notNull(slice, "Slice must not be null");
163+
Assert.isTrue(!slice.hasContent(), "Slice must not have any content");
164+
Assert.notNull(type, "Type must not be null");
165+
Assert.notNull(link, "Link must not be null");
166+
167+
SliceMetadata metadata = asSliceMetadata(slice);
168+
169+
EmbeddedWrapper wrapper = wrappers.emptyCollectionOf(type);
170+
List<EmbeddedWrapper> embedded = Collections.singletonList(wrapper);
171+
172+
return addPaginationLinks(SlicedModel.of(embedded, metadata), slice, link);
173+
}
174+
175+
/**
176+
* Creates the {@link SlicedModel} to be equipped with pagination links downstream.
177+
*
178+
* @param resources the original slices's elements mapped into {@link RepresentationModel}
179+
* instances.
180+
* @param metadata the calculated {@link SliceMetadata}, must not be {@literal null}.
181+
* @param slice the original page handed to the assembler, must not be {@literal null}.
182+
* @return must not be {@literal null}.
183+
*/
184+
protected <R extends RepresentationModel<?>, S> SlicedModel<R> createSlicedModel(List<R> resources,
185+
SliceMetadata metadata, Slice<S> slice) {
186+
Assert.notNull(resources, "Content resources must not be null");
187+
Assert.notNull(metadata, "SliceMetadata must not be null");
188+
Assert.notNull(slice, "Slice must not be null");
189+
190+
return SlicedModel.of(resources, metadata);
191+
}
192+
193+
private <S, R extends RepresentationModel<?>> SlicedModel<R> createModel(Slice<S> slice,
194+
RepresentationModelAssembler<S, R> assembler, Optional<Link> link) {
195+
Assert.notNull(slice, "Slice must not be null");
196+
Assert.notNull(assembler, "ResourceAssembler must not be null");
197+
198+
List<R> resources = new ArrayList<>(slice.getNumberOfElements());
199+
200+
for (S element : slice) {
201+
resources.add(assembler.toModel(element));
202+
}
203+
204+
SlicedModel<R> resource = createSlicedModel(resources, asSliceMetadata(slice), slice);
205+
206+
return addPaginationLinks(resource, slice, link);
207+
}
208+
209+
private <R> SlicedModel<R> addPaginationLinks(SlicedModel<R> resources, Slice<?> slice, Optional<Link> link) {
210+
UriTemplate base = getUriTemplate(link);
211+
212+
boolean isNavigable = slice.hasPrevious() || slice.hasNext();
213+
214+
if (isNavigable || forceFirstRel) {
215+
resources.add(
216+
createLink(base, PageRequest.of(0, slice.getSize(), slice.getSort()), IanaLinkRelations.FIRST));
217+
}
218+
219+
Link selfLink = link.map(Link::withSelfRel)
220+
.orElseGet(() -> createLink(base, slice.getPageable(), IanaLinkRelations.SELF));
221+
222+
resources.add(selfLink);
223+
224+
if (slice.hasPrevious()) {
225+
resources.add(createLink(base, slice.previousPageable(), IanaLinkRelations.PREV));
226+
}
227+
228+
if (slice.hasNext()) {
229+
resources.add(createLink(base, slice.nextPageable(), IanaLinkRelations.NEXT));
230+
}
231+
232+
return resources;
233+
}
234+
235+
/**
236+
* Returns a default URI string either from the one configured on then assembler or by
237+
* looking it up from the current request.
238+
*
239+
* @return
240+
*/
241+
private UriTemplate getUriTemplate(Optional<Link> baseLink) {
242+
return UriTemplate.of(baseLink.map(Link::getHref).orElseGet(this::baseUriOrCurrentRequest));
243+
}
244+
245+
/**
246+
* Creates a {@link Link} with the given {@link LinkRelation} that will be based on the
247+
* given {@link UriTemplate} but enriched with the values of the given {@link Pageable}
248+
* (if not {@literal null}).
249+
*
250+
* @param base must not be {@literal null}.
251+
* @param pageable can be {@literal null}
252+
* @param relation must not be {@literal null}.
253+
* @return
254+
*/
255+
private Link createLink(UriTemplate base, Pageable pageable, LinkRelation relation) {
256+
UriComponentsBuilder builder = fromUri(base.expand());
257+
pageableResolver.enhance(builder, getMethodParameter(), pageable);
258+
259+
return Link.of(UriTemplate.of(builder.build().toString()), relation);
260+
}
261+
262+
/**
263+
* Return the {@link MethodParameter} to be used to potentially qualify the paging and
264+
* sorting request parameters to. Default implementations returns {@literal null}, which
265+
* means the parameters will not be qualified.
266+
*
267+
* @return
268+
*/
269+
@Nullable
270+
protected MethodParameter getMethodParameter() {
271+
return null;
272+
}
273+
274+
/**
275+
* Creates a new {@link SliceMetadata} instance from the given {@link Slice}.
276+
*
277+
* @param slice must not be {@literal null}.
278+
* @return
279+
*/
280+
private SliceMetadata asSliceMetadata(Slice<?> slice) {
281+
Assert.notNull(slice, "Slice must not be null");
282+
283+
int number = pageableResolver.isOneIndexedParameters() ? slice.getNumber() + 1 : slice.getNumber();
284+
285+
return new SliceMetadata(slice.getSize(), number);
286+
}
287+
288+
private String baseUriOrCurrentRequest() {
289+
return baseUri.map(Object::toString).orElseGet(SlicedResourcesAssembler::currentRequest);
290+
}
291+
}

0 commit comments

Comments
 (0)