From 8154f5125106d10fa6213807bbce8ff97c15a806 Mon Sep 17 00:00:00 2001 From: Michael Schout Date: Tue, 4 Oct 2022 16:00:44 -0500 Subject: [PATCH] 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 --- ...arameterAwareSlicedResourcesAssembler.java | 55 ++++ .../data/web/SlicedResourcesAssembler.java | 291 ++++++++++++++++++ ...cedResourcesAssemblerArgumentResolver.java | 145 +++++++++ ...ateoasAwareSpringDataWebConfiguration.java | 19 +- ...rcesAssemblerArgumentResolverUnitTest.java | 137 +++++++++ .../web/SlicedResourcesAssemblerUnitTest.java | 291 ++++++++++++++++++ ...bleResourcesAssemblerIntegrationTests.java | 85 +++++ .../data/web/config/manual.xml | 9 + 8 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/springframework/data/web/MethodParameterAwareSlicedResourcesAssembler.java create mode 100644 src/main/java/org/springframework/data/web/SlicedResourcesAssembler.java create mode 100644 src/main/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolver.java create mode 100644 src/test/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolverUnitTest.java create mode 100644 src/test/java/org/springframework/data/web/SlicedResourcesAssemblerUnitTest.java create mode 100644 src/test/java/org/springframework/data/web/config/SliceableResourcesAssemblerIntegrationTests.java diff --git a/src/main/java/org/springframework/data/web/MethodParameterAwareSlicedResourcesAssembler.java b/src/main/java/org/springframework/data/web/MethodParameterAwareSlicedResourcesAssembler.java new file mode 100644 index 0000000000..f98f519983 --- /dev/null +++ b/src/main/java/org/springframework/data/web/MethodParameterAwareSlicedResourcesAssembler.java @@ -0,0 +1,55 @@ +/* + * Copyright 2022 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 org.springframework.core.MethodParameter; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponents; + +/** + * Custom {@link SlicedResourcesAssembler} that is aware of the {@link MethodParameter} it shall create links for. + * + * @author Michael Schout + */ +public class MethodParameterAwareSlicedResourcesAssembler extends SlicedResourcesAssembler { + private final MethodParameter parameter; + + /** + * Creates a new {@link MethodParameterAwareSlicedResourcesAssembler} using the given + * {@link MethodParameter}, {@link HateoasPageableHandlerMethodArgumentResolver} and base + * URI. + * + * @param parameter must not be {@literal null}. + * @param resolver can be {@literal null}. + * @param baseUri can be {@literal null}. + */ + public MethodParameterAwareSlicedResourcesAssembler(MethodParameter parameter, + @Nullable HateoasPageableHandlerMethodArgumentResolver resolver, @Nullable UriComponents baseUri) { + + super(resolver, baseUri); + + Assert.notNull(parameter, "Method parameter must not be null"); + this.parameter = parameter; + } + + @NonNull + @Override + protected MethodParameter getMethodParameter() { + return parameter; + } +} diff --git a/src/main/java/org/springframework/data/web/SlicedResourcesAssembler.java b/src/main/java/org/springframework/data/web/SlicedResourcesAssembler.java new file mode 100644 index 0000000000..63290c8fff --- /dev/null +++ b/src/main/java/org/springframework/data/web/SlicedResourcesAssembler.java @@ -0,0 +1,291 @@ +/* + * Copyright 2022 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 static org.springframework.web.util.UriComponentsBuilder.fromUri; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.hateoas.*; +import org.springframework.hateoas.SlicedModel.SliceMetadata; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.hateoas.server.core.EmbeddedWrapper; +import org.springframework.hateoas.server.core.EmbeddedWrappers; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * {@link RepresentationModelAssembler} to easily convert {@link Slice} instances into + * {@link SlicedModel}. + * + * @author Michael Schout + */ +public class SlicedResourcesAssembler + implements RepresentationModelAssembler, SlicedModel>> { + + private final HateoasPageableHandlerMethodArgumentResolver pageableResolver; + + private final Optional baseUri; + private final EmbeddedWrappers wrappers = new EmbeddedWrappers(false); + + private boolean forceFirstRel = false; + + /** + * Creates a new {@link SlicedResourcesAssembler} using the given + * {@link PageableHandlerMethodArgumentResolver} and base URI. If the former is + * {@literal null}, a default one will be created. If the latter is {@literal null}, calls + * to {@link #toModel(Slice)} will use the current request's URI to build the relevant + * previous and next links. + * + * @param resolver can be {@literal null}. + * @param baseUri can be {@literal null}. + */ + public SlicedResourcesAssembler(@Nullable HateoasPageableHandlerMethodArgumentResolver resolver, + @Nullable UriComponents baseUri) { + this.pageableResolver = resolver == null ? new HateoasPageableHandlerMethodArgumentResolver() : resolver; + this.baseUri = Optional.ofNullable(baseUri); + } + + private static String currentRequest() { + return ServletUriComponentsBuilder.fromCurrentRequest().build().toString(); + } + + /** + * Configures whether to always add {@code first} links to the {@link SlicedModel} * + * created. Defaults to {@literal false} which means that {@code first} links onlys appear + * in conjunction with {@code prev} and {@code next} links. + * + * @param forceFirstRel whether to always add {@code first} links to the + * {@link SlicedModel} created. + */ + public void setForceFirstRel(boolean forceFirstRel) { + this.forceFirstRel = forceFirstRel; + } + + @Override + public SlicedModel> toModel(Slice entity) { + return toModel(entity, EntityModel::of); + } + + /** + * Creates a new {@link SlicedModel} by converting the given {@link Slice} into a + * {@link SliceMetadata} instance and wrapping the contained elements into * + * {@link SlicedModel} instances. Will add pagination links based on the given self link. + * + * @param slice must not be {@literal null}. + * @param selfLink must not be {@literal null}. + * @return + */ + public SlicedModel> toModel(Slice slice, Link selfLink) { + return toModel(slice, EntityModel::of, selfLink); + } + + /** + * Creates a new {@link SlicedModel} by converting the given {@link Slice} into a + * {@link SliceMetadata} instance and using the given {@link SlicedModel} to turn elements + * of the {@link Slice} into resources. + * + * @param slice must not be {@literal null}. + * @param assembler must not be {@literal null}. + * @return + */ + public > SlicedModel toModel(Slice slice, + RepresentationModelAssembler assembler) { + return createModel(slice, assembler, Optional.empty()); + } + + /** + * Creates a new {@link SlicedModel} by converting the given {@link Slice} into a + * {@link SliceMetadata} instance and using the given {@link SlicedModel} to turn elements + * of the {@link Slice} into resources. Will add pagination links based on the given the + * self link. + * + * @param slice must not be {@literal null}. + * @param assembler must not be {@literal null}. + * @param link must not be {@literal null}. + * @return + */ + public > SlicedModel toModel(Slice slice, + RepresentationModelAssembler assembler, Link link) { + return createModel(slice, assembler, Optional.of(link)); + } + + /** + * Creates a {@link SlicedModel} with an empty collection {@link EmbeddedWrapper} for the + * given domain type. + * + * @param slice must not be {@literal null}, content must be empty. + * @param type must not be {@literal null}. + * @return + */ + public SlicedModel toEmptyModel(Slice slice, Class type) { + return toEmptyModel(slice, type, Optional.empty()); + } + + /** + * Creates a {@link SlicedModel} with an empty collection {@link EmbeddedWrapper} for the + * given domain type. + * + * @param slice must not be {@literal null}, content must be empty. + * @param type must not be {@literal null}. + * @param link must not be {@literal null}. + * @return + */ + public SlicedModel toEmptyModel(Slice slice, Class type, Link link) { + return toEmptyModel(slice, type, Optional.of(link)); + } + + public SlicedModel toEmptyModel(Slice slice, Class type, Optional link) { + Assert.notNull(slice, "Slice must not be null"); + Assert.isTrue(!slice.hasContent(), "Slice must not have any content"); + Assert.notNull(type, "Type must not be null"); + Assert.notNull(link, "Link must not be null"); + + SliceMetadata metadata = asSliceMetadata(slice); + + EmbeddedWrapper wrapper = wrappers.emptyCollectionOf(type); + List embedded = Collections.singletonList(wrapper); + + return addPaginationLinks(SlicedModel.of(embedded, metadata), slice, link); + } + + /** + * Creates the {@link SlicedModel} to be equipped with pagination links downstream. + * + * @param resources the original slices's elements mapped into {@link RepresentationModel} + * instances. + * @param metadata the calculated {@link SliceMetadata}, must not be {@literal null}. + * @param slice the original page handed to the assembler, must not be {@literal null}. + * @return must not be {@literal null}. + */ + protected , S> SlicedModel createSlicedModel(List resources, + SliceMetadata metadata, Slice slice) { + Assert.notNull(resources, "Content resources must not be null"); + Assert.notNull(metadata, "SliceMetadata must not be null"); + Assert.notNull(slice, "Slice must not be null"); + + return SlicedModel.of(resources, metadata); + } + + private > SlicedModel createModel(Slice slice, + RepresentationModelAssembler assembler, Optional link) { + Assert.notNull(slice, "Slice must not be null"); + Assert.notNull(assembler, "ResourceAssembler must not be null"); + + List resources = new ArrayList<>(slice.getNumberOfElements()); + + for (S element : slice) { + resources.add(assembler.toModel(element)); + } + + SlicedModel resource = createSlicedModel(resources, asSliceMetadata(slice), slice); + + return addPaginationLinks(resource, slice, link); + } + + private SlicedModel addPaginationLinks(SlicedModel resources, Slice slice, Optional link) { + UriTemplate base = getUriTemplate(link); + + boolean isNavigable = slice.hasPrevious() || slice.hasNext(); + + if (isNavigable || forceFirstRel) { + resources.add( + createLink(base, PageRequest.of(0, slice.getSize(), slice.getSort()), IanaLinkRelations.FIRST)); + } + + Link selfLink = link.map(Link::withSelfRel) + .orElseGet(() -> createLink(base, slice.getPageable(), IanaLinkRelations.SELF)); + + resources.add(selfLink); + + if (slice.hasPrevious()) { + resources.add(createLink(base, slice.previousPageable(), IanaLinkRelations.PREV)); + } + + if (slice.hasNext()) { + resources.add(createLink(base, slice.nextPageable(), IanaLinkRelations.NEXT)); + } + + return resources; + } + + /** + * Returns a default URI string either from the one configured on then assembler or by + * looking it up from the current request. + * + * @return + */ + private UriTemplate getUriTemplate(Optional baseLink) { + return UriTemplate.of(baseLink.map(Link::getHref).orElseGet(this::baseUriOrCurrentRequest)); + } + + /** + * Creates a {@link Link} with the given {@link LinkRelation} that will be based on the + * given {@link UriTemplate} but enriched with the values of the given {@link Pageable} + * (if not {@literal null}). + * + * @param base must not be {@literal null}. + * @param pageable can be {@literal null} + * @param relation must not be {@literal null}. + * @return + */ + private Link createLink(UriTemplate base, Pageable pageable, LinkRelation relation) { + UriComponentsBuilder builder = fromUri(base.expand()); + pageableResolver.enhance(builder, getMethodParameter(), pageable); + + return Link.of(UriTemplate.of(builder.build().toString()), relation); + } + + /** + * Return the {@link MethodParameter} to be used to potentially qualify the paging and + * sorting request parameters to. Default implementations returns {@literal null}, which + * means the parameters will not be qualified. + * + * @return + */ + @Nullable + protected MethodParameter getMethodParameter() { + return null; + } + + /** + * Creates a new {@link SliceMetadata} instance from the given {@link Slice}. + * + * @param slice must not be {@literal null}. + * @return + */ + private SliceMetadata asSliceMetadata(Slice slice) { + Assert.notNull(slice, "Slice must not be null"); + + int number = pageableResolver.isOneIndexedParameters() ? slice.getNumber() + 1 : slice.getNumber(); + + return new SliceMetadata(slice.getSize(), number); + } + + private String baseUriOrCurrentRequest() { + return baseUri.map(Object::toString).orElseGet(SlicedResourcesAssembler::currentRequest); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolver.java b/src/main/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolver.java new file mode 100644 index 0000000000..a38f5c28ee --- /dev/null +++ b/src/main/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolver.java @@ -0,0 +1,145 @@ +/* + * Copyright 2022 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.lang.reflect.Method; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.core.log.LogMessage; +import org.springframework.data.domain.Pageable; +import org.springframework.hateoas.server.core.MethodParameters; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link HandlerMethodArgumentResolver} to allow injection of {@link SlicedResourcesAssembler} into Spring MVC + * controller methods. + * + * @author Michael Schout + */ +public class SlicedResourcesAssemblerArgumentResolver implements HandlerMethodArgumentResolver { + private static final Log logger = LogFactory.getLog(SlicedResourcesAssemblerArgumentResolver.class); + + private static final String SUPERFLOUS_QUALIFIER = "Found qualified %s parameter, but a unique unqualified %s parameter; Using that one, but you might want to check your controller method configuration"; + private static final String PARAMETER_AMBIGUITY = "Discovered multiple parameters of type Pageable but no qualifier annotations to disambiguate"; + + private final HateoasPageableHandlerMethodArgumentResolver resolver; + + /** + * Creates a new {@link SlicedResourcesAssemblerArgumentResolver} using the given + * {@link PageableHandlerMethodArgumentResolver}. + * + * @param resolver can be {@literal null}. + */ + public SlicedResourcesAssemblerArgumentResolver(HateoasPageableHandlerMethodArgumentResolver resolver) { + this.resolver = resolver; + } + + /** + * Returns finds the {@link MethodParameter} for a {@link Pageable} instance matching the + * given {@link MethodParameter} requesting a {@link SlicedResourcesAssembler}. + * + * @param parameter must not be {@literal null}. + * @return + */ + @Nullable + private static MethodParameter findMatchingPageableParameter(MethodParameter parameter) { + Method method = parameter.getMethod(); + + if (method == null) { + throw new IllegalArgumentException(String.format("Could not obtain method from parameter %s", parameter)); + } + + MethodParameters parameters = MethodParameters.of(method); + List pageableParameters = parameters.getParametersOfType(Pageable.class); + Qualifier assemblerQualifier = parameter.getParameterAnnotation(Qualifier.class); + + if (pageableParameters.isEmpty()) { + return null; + } + + if (pageableParameters.size() == 1) { + MethodParameter pageableParameter = pageableParameters.get(0); + MethodParameter matchingParameter = returnIfQualifiersMatch(pageableParameter, assemblerQualifier); + + if (matchingParameter == null) { + logger.info(LogMessage.format(SUPERFLOUS_QUALIFIER, SlicedResourcesAssembler.class.getSimpleName(), + Pageable.class.getName())); + } + + return pageableParameter; + } + + if (assemblerQualifier == null) { + throw new IllegalStateException(PARAMETER_AMBIGUITY); + } + + for (MethodParameter pageableParameter : pageableParameters) { + MethodParameter matchingParameter = returnIfQualifiersMatch(pageableParameter, assemblerQualifier); + + if (matchingParameter != null) { + return matchingParameter; + } + } + + throw new IllegalStateException(PARAMETER_AMBIGUITY); + } + + @Nullable + private static MethodParameter returnIfQualifiersMatch(MethodParameter pageableParameter, + @Nullable Qualifier assemblerQualifier) { + + if (assemblerQualifier == null) { + return pageableParameter; + } + + Qualifier pageableParameterQualifier = pageableParameter.getParameterAnnotation(Qualifier.class); + + if (pageableParameterQualifier == null) { + return null; + } + + return pageableParameterQualifier.value().equals(assemblerQualifier.value()) ? pageableParameter : null; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return SlicedResourcesAssembler.class.equals(parameter.getParameterType()); + } + + @NonNull + @Override + public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) { + + MethodParameter pageableParameter = findMatchingPageableParameter(parameter); + + if (pageableParameter != null) { + return new MethodParameterAwareSlicedResourcesAssembler<>(pageableParameter, resolver, null); + } + else { + return new SlicedResourcesAssembler<>(resolver, null); + } + } +} diff --git a/src/main/java/org/springframework/data/web/config/HateoasAwareSpringDataWebConfiguration.java b/src/main/java/org/springframework/data/web/config/HateoasAwareSpringDataWebConfiguration.java index 224dc194d1..ccab0a6d0a 100644 --- a/src/main/java/org/springframework/data/web/config/HateoasAwareSpringDataWebConfiguration.java +++ b/src/main/java/org/springframework/data/web/config/HateoasAwareSpringDataWebConfiguration.java @@ -28,10 +28,13 @@ import org.springframework.data.web.HateoasSortHandlerMethodArgumentResolver; import org.springframework.data.web.PagedResourcesAssembler; import org.springframework.data.web.PagedResourcesAssemblerArgumentResolver; +import org.springframework.data.web.SlicedResourcesAssembler; +import org.springframework.data.web.SlicedResourcesAssemblerArgumentResolver; import org.springframework.web.method.support.HandlerMethodArgumentResolver; /** - * JavaConfig class to register {@link PagedResourcesAssembler} and {@link PagedResourcesAssemblerArgumentResolver}. + * JavaConfig class to register {@link PagedResourcesAssembler}, {@link PagedResourcesAssemblerArgumentResolver}, + * {@link SlicedResourcesAssembler} and {@link SlicedResourcesAssemblerArgumentResolver}. * * @since 1.6 * @author Oliver Gierke @@ -47,6 +50,7 @@ public class HateoasAwareSpringDataWebConfiguration extends SpringDataWebConfigu private final Lazy sortResolver; private final Lazy pageableResolver; private final Lazy argumentResolver; + private final Lazy slicedResourcesArgumentResolver; /** * @param context must not be {@literal null}. @@ -63,6 +67,8 @@ public HateoasAwareSpringDataWebConfiguration(ApplicationContext context, .of(() -> context.getBean("pageableResolver", HateoasPageableHandlerMethodArgumentResolver.class)); this.argumentResolver = Lazy.of(() -> context.getBean("pagedResourcesAssemblerArgumentResolver", PagedResourcesAssemblerArgumentResolver.class)); + this.slicedResourcesArgumentResolver = Lazy.of(() -> context.getBean("slicedResourcesAssemblerArgumentResolver", + SlicedResourcesAssemblerArgumentResolver.class)); } @Override @@ -94,11 +100,22 @@ public PagedResourcesAssemblerArgumentResolver pagedResourcesAssemblerArgumentRe return new PagedResourcesAssemblerArgumentResolver(pageableResolver.get()); } + @Bean + public SlicedResourcesAssembler slicedResourcesAssembler() { + return new SlicedResourcesAssembler<>(pageableResolver.get(), null); + } + + @Bean + public SlicedResourcesAssemblerArgumentResolver slicedResourcesAssemblerArgumentResolver() { + return new SlicedResourcesAssemblerArgumentResolver(pageableResolver.get()); + } + @Override public void addArgumentResolvers(List argumentResolvers) { super.addArgumentResolvers(argumentResolvers); argumentResolvers.add(argumentResolver.get()); + argumentResolvers.add(slicedResourcesArgumentResolver.get()); } } diff --git a/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolverUnitTest.java b/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolverUnitTest.java new file mode 100644 index 0000000000..1aec6c983e --- /dev/null +++ b/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerArgumentResolverUnitTest.java @@ -0,0 +1,137 @@ +package org.springframework.data.web; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.RequestMapping; + +class SlicedResourcesAssemblerArgumentResolverUnitTest { + SlicedResourcesAssemblerArgumentResolver resolver; + + private static void assertMethodParameterAwareSlicedResourcesAssemblerFor(Object result, + MethodParameter parameter) { + assertThat(result).isInstanceOf(MethodParameterAwareSlicedResourcesAssembler.class); + + var assembler = (MethodParameterAwareSlicedResourcesAssembler) result; + + assertThat(assembler.getMethodParameter()).isEqualTo(parameter); + } + + @BeforeEach + void setUp() { + WebTestUtils.initWebTest(); + + var hateoasPageableHandlerMethodArgumentResolver = new HateoasPageableHandlerMethodArgumentResolver(); + this.resolver = new SlicedResourcesAssemblerArgumentResolver(hateoasPageableHandlerMethodArgumentResolver); + } + + @Test + void createsPlainAssemblerWithoutContext() throws Exception { + var method = Controller.class.getMethod("noContext", SlicedResourcesAssembler.class); + var result = resolver.resolveArgument(new MethodParameter(method, 0), null, null, null); + + assertThat(result).isInstanceOf(SlicedResourcesAssembler.class); + assertThat(result).isNotInstanceOf(MethodParameterAwareSlicedResourcesAssembler.class); + } + + @Test + void selectsUniquePageableParameter() throws Exception { + var method = Controller.class.getMethod("unique", SlicedResourcesAssembler.class, Pageable.class); + assertSelectsParameter(method, 1); + } + + @Test + void selectsUniquePageableParameterForQualifiedAssembler() throws Exception { + var method = Controller.class.getMethod("unnecessarilyQualified", SlicedResourcesAssembler.class, + Pageable.class); + assertSelectsParameter(method, 1); + } + + @Test + void selectsUniqueQualifiedPageableParameter() throws Exception { + + var method = Controller.class.getMethod("qualifiedUnique", SlicedResourcesAssembler.class, Pageable.class); + assertSelectsParameter(method, 1); + } + + @Test + void selectsQualifiedPageableParameter() throws Exception { + var method = Controller.class.getMethod("qualified", SlicedResourcesAssembler.class, Pageable.class, + Pageable.class); + assertSelectsParameter(method, 1); + } + + @Test + void rejectsAmbiguousPageableParameters() throws Exception { + assertRejectsAmbiguity("unqualifiedAmbiguity"); + } + + @Test + void rejectsAmbiguousPageableParametersForQualifiedAssembler() throws Exception { + assertRejectsAmbiguity("assemblerQualifiedAmbiguity"); + } + + @Test + void rejectsAmbiguityWithoutMatchingQualifiers() throws Exception { + assertRejectsAmbiguity("noMatchingQualifiers"); + } + + @Test + void doesNotFailForTemplatedMethodMapping() throws Exception { + var method = Controller.class.getMethod("methodWithPathVariable", SlicedResourcesAssembler.class); + var result = resolver.resolveArgument(new MethodParameter(method, 0), null, null, null); + + assertThat(result).isNotNull(); + } + + private void assertSelectsParameter(Method method, int expectedIndex) { + var parameter = new MethodParameter(method, 0); + + var result = resolver.resolveArgument(parameter, null, null, null); + assertMethodParameterAwareSlicedResourcesAssemblerFor(result, new MethodParameter(method, expectedIndex)); + } + + private void assertRejectsAmbiguity(String methodName) throws Exception { + var method = Controller.class.getMethod(methodName, SlicedResourcesAssembler.class, Pageable.class, + Pageable.class); + + assertThatIllegalStateException() + .isThrownBy(() -> resolver.resolveArgument(new MethodParameter(method, 0), null, null, null)); + } + + @RequestMapping("/") + interface Controller { + void noContext(SlicedResourcesAssembler resolver); + + void unique(SlicedResourcesAssembler assembler, Pageable pageable); + + void unnecessarilyQualified(@Qualifier("qualified") SlicedResourcesAssembler assembler, + Pageable pageable); + + void qualifiedUnique(@Qualifier("qualified") SlicedResourcesAssembler assembler, + @Qualifier("qualified") Pageable pageable); + + void qualified(@Qualifier("qualified") SlicedResourcesAssembler resolver, + @Qualifier("qualified") Pageable pageable, Pageable unqualified); + + void unqualifiedAmbiguity(SlicedResourcesAssembler assembler, Pageable pageable, Pageable unqualified); + + void assemblerQualifiedAmbiguity(@Qualifier("qualified") SlicedResourcesAssembler assembler, + Pageable pageable, Pageable unqualified); + + void noMatchingQualifiers(@Qualifier("qualified") SlicedResourcesAssembler assembler, Pageable pageable, + @Qualifier("qualified2") Pageable unqualified); + + @RequestMapping("/{variable}/foo") + void methodWithPathVariable(SlicedResourcesAssembler assembler); + + @RequestMapping("/mapping") + Object methodWithMapping(SlicedResourcesAssembler pageable); + } +} diff --git a/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerUnitTest.java b/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerUnitTest.java new file mode 100644 index 0000000000..a0a38da95f --- /dev/null +++ b/src/test/java/org/springframework/data/web/SlicedResourcesAssemblerUnitTest.java @@ -0,0 +1,291 @@ +/* + * Copyright 2022 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.net.URI; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.*; +import org.springframework.hateoas.*; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.hateoas.server.core.EmbeddedWrapper; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Unit tests for {@link SlicedResourcesAssembler}. + * + * @author Michael Schout + */ +class SlicedResourcesAssemblerUnitTest { + static final Pageable PAGEABLE = PageRequest.of(0, 20); + static final Slice EMPTY_SLICE = new SliceImpl<>(Collections.emptyList(), PAGEABLE, false); + + HateoasPageableHandlerMethodArgumentResolver resolver = new HateoasPageableHandlerMethodArgumentResolver(); + SlicedResourcesAssembler assembler = new SlicedResourcesAssembler<>(resolver, null); + + private static Slice createSlice(int index) { + Pageable request = PageRequest.of(index, 1); + + var person = new Person(); + person.name = "Dave"; + + boolean hasNext = index < 2; + + return new SliceImpl<>(Collections.singletonList(person), request, hasNext); + } + + private static Map getQueryParameters(Link link) { + var uriComponents = UriComponentsBuilder.fromUri(URI.create(link.expand().getHref())).build(); + return uriComponents.getQueryParams().toSingleValueMap(); + } + + @BeforeEach + void setUp() { + WebTestUtils.initWebTest(); + } + + @Test + void addsNextLinkForFirstSlice() { + var resources = assembler.toModel(createSlice(0)); + + assertThat(resources.getLink(IanaLinkRelations.PREV)).isEmpty(); + assertThat(resources.getLink(IanaLinkRelations.SELF)).isNotEmpty(); + assertThat(resources.getLink(IanaLinkRelations.NEXT)).isNotEmpty(); + } + + @Test + void addsPreviousAndNextLinksForMiddleSlice() { + var resources = assembler.toModel(createSlice(1)); + + assertThat(resources.getLink(IanaLinkRelations.PREV)).isNotEmpty(); + assertThat(resources.getLink(IanaLinkRelations.SELF)).isNotEmpty(); + assertThat(resources.getLink(IanaLinkRelations.NEXT)).isNotEmpty(); + } + + @Test + void addsPreviousLinkForLastSlice() { + var resources = assembler.toModel(createSlice(2)); + + assertThat(resources.getLink(IanaLinkRelations.PREV)).isNotEmpty(); + assertThat(resources.getLink(IanaLinkRelations.SELF)).isNotEmpty(); + assertThat(resources.getLink(IanaLinkRelations.NEXT)).isEmpty(); + } + + @Test + void usesBaseUriIfConfigured() { + var baseUri = UriComponentsBuilder.fromUriString("https://foo:9090").build(); + + var assembler = new SlicedResourcesAssembler(resolver, baseUri); + var resources = assembler.toModel(createSlice(1)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.PREV).getHref()).startsWith(baseUri.toUriString()); + assertThat(resources.getRequiredLink(IanaLinkRelations.SELF)).isNotNull(); + assertThat(resources.getRequiredLink(IanaLinkRelations.NEXT).getHref()).startsWith(baseUri.toUriString()); + } + + @Test + void usesCustomLinkProvided() { + var link = Link.of("https://foo:9090", "rel"); + + var resources = assembler.toModel(createSlice(1), link); + + assertThat(resources.getRequiredLink(IanaLinkRelations.PREV).getHref()).startsWith(link.getHref()); + assertThat(resources.getRequiredLink(IanaLinkRelations.SELF)).isEqualTo(link.withSelfRel()); + assertThat(resources.getRequiredLink(IanaLinkRelations.NEXT).getHref()).startsWith(link.getHref()); + } + + @Test + void createsSlicedResourcesForOneIndexedArgumentResolver() { + resolver.setOneIndexedParameters(true); + + AbstractPageRequest request = PageRequest.of(0, 1); + Slice slice = new SliceImpl<>(Collections.emptyList(), request, true); + + assembler.toModel(slice); + } + + @Test + void createsACanonicalLinkWithoutTemplateParameters() { + var resources = assembler.toModel(createSlice(1)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.SELF).getHref()).doesNotContain("{").doesNotContain("}"); + } + + @Test + void invokesCustomElementResourceAssembler() { + var personAssembler = new PersonResourceAssembler(); + + var resources = assembler.toModel(createSlice(0), personAssembler); + + assertThat(resources.hasLink(IanaLinkRelations.SELF)).isTrue(); + assertThat(resources.hasLink(IanaLinkRelations.NEXT)).isTrue(); + + var content = resources.getContent(); + assertThat(content).hasSize(1); + assertThat(content.iterator().next().name).isEqualTo("Dave"); + } + + @Test + void createsPaginationLinksForOneIndexedArgumentResolverCorrectly() { + var argumentResolver = new HateoasPageableHandlerMethodArgumentResolver(); + argumentResolver.setOneIndexedParameters(true); + + var assembler = new SlicedResourcesAssembler(argumentResolver, null); + var resource = assembler.toModel(createSlice(1)); + + assertThat(resource.hasLink("prev")).isTrue(); + assertThat(resource.hasLink("next")).isTrue(); + + // We expect 2 as the created slice has index 1. slices are always 0 indexed, so we + // created page 2 above. + assertThat(resource.getMetadata().getNumber()).isEqualTo(2); + + assertThat(getQueryParameters(resource.getRequiredLink("prev"))).containsEntry("page", "1"); + assertThat(getQueryParameters(resource.getRequiredLink("next"))).containsEntry("page", "3"); + } + + @Test + void generatedLinksShouldNotBeTemplated() { + var resources = assembler.toModel(createSlice(1)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.SELF).getHref()).doesNotContain("{").doesNotContain("}"); + assertThat(resources.getRequiredLink(IanaLinkRelations.NEXT).getHref()).endsWith("?page=2&size=1"); + assertThat(resources.getRequiredLink(IanaLinkRelations.PREV).getHref()).endsWith("?page=0&size=1"); + } + + @Test + void generatesEmptySliceResourceWithEmbeddedWrapper() { + var result = assembler.toEmptyModel(EMPTY_SLICE, Person.class); + + var content = result.getContent(); + assertThat(content).hasSize(1); + + var element = content.iterator().next(); + assertThat(element).isInstanceOf(EmbeddedWrapper.class); + assertThat(((EmbeddedWrapper) element).getRelTargetType()).isEqualTo(Person.class); + } + + @Test + void emptySliceCreatorRejectsSliceWithContent() { + assertThatIllegalArgumentException().isThrownBy(() -> assembler.toEmptyModel(createSlice(1), Person.class)); + } + + @Test + void emptySliceCreatorRejectsNullType() { + assertThatIllegalArgumentException().isThrownBy(() -> assembler.toEmptyModel(EMPTY_SLICE, null)); + } + + @Test + void addsFirstLinkForMultipleSlices() { + var resources = assembler.toModel(createSlice(1)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.FIRST).getHref()).endsWith("?page=0&size=1"); + } + + @Test + void addsFirstLinkForFirstSlice() { + var resources = assembler.toModel(createSlice(0)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.FIRST).getHref()).endsWith("?page=0&size=1"); + } + + @Test + void addsFirstLinkForLastSlice() { + var resources = assembler.toModel(createSlice(2)); + + assertThat(resources.getRequiredLink(IanaLinkRelations.FIRST).getHref()).endsWith("?page=0&size=1"); + } + + @Test + void alwaysAddsFirstLinkIfConfiguredTo() { + var assembler = new SlicedResourcesAssembler(resolver, null); + assembler.setForceFirstRel(true); + + var resources = assembler.toModel(EMPTY_SLICE); + + assertThat(resources.getRequiredLink(IanaLinkRelations.FIRST).getHref()).endsWith("?page=0&size=20"); + } + + @Test + void usesCustomSlicedResources() { + RepresentationModelAssembler, SlicedModel>> assembler = new CustomSlicedResourcesAssembler<>( + resolver, null); + + assertThat(assembler.toModel(EMPTY_SLICE)).isInstanceOf(CustomSlicedResources.class); + } + + @Test + void selfLinkContainsCoordinatesForCurrentSlice() { + var resource = assembler.toModel(createSlice(0)); + + assertThat(resource.getRequiredLink(IanaLinkRelations.SELF).getHref()).endsWith("?page=0&size=1"); + } + + @Test + void keepsRequestParametersOfOriginalRequestUri() { + WebTestUtils.initWebTest(new MockHttpServletRequest("GET", "/sample?foo=bar")); + + var model = assembler.toModel(createSlice(1)); + + assertThat(model.getRequiredLink(IanaLinkRelations.FIRST).getHref()) + .isEqualTo("http://localhost/sample?foo=bar&page=0&size=1"); + } + + static class Person { + String name; + } + + static class PersonResource extends RepresentationModel { + String name; + } + + static class PersonResourceAssembler implements RepresentationModelAssembler { + @Override + public PersonResource toModel(Person entity) { + var resource = new PersonResource(); + resource.name = entity.name; + return resource; + } + } + + static class CustomSlicedResourcesAssembler extends SlicedResourcesAssembler { + CustomSlicedResourcesAssembler(HateoasPageableHandlerMethodArgumentResolver resolver, UriComponents baseUri) { + super(resolver, baseUri); + } + + @Override + protected , S> SlicedModel createSlicedModel(List resources, + SlicedModel.SliceMetadata metadata, Slice slice) { + return new CustomSlicedResources<>(resources, metadata); + } + } + + static class CustomSlicedResources extends SlicedModel { + CustomSlicedResources(Collection content, SliceMetadata metadata) { + super(content, metadata); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/data/web/config/SliceableResourcesAssemblerIntegrationTests.java b/src/test/java/org/springframework/data/web/config/SliceableResourcesAssemblerIntegrationTests.java new file mode 100644 index 0000000000..57f71bf5e5 --- /dev/null +++ b/src/test/java/org/springframework/data/web/config/SliceableResourcesAssemblerIntegrationTests.java @@ -0,0 +1,85 @@ +package org.springframework.data.web.config; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collections; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.web.SlicedResourcesAssembler; +import org.springframework.data.web.WebTestUtils; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.SlicedModel; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +public class SliceableResourcesAssemblerIntegrationTests { + @BeforeEach + void setUp() { + WebTestUtils.initWebTest(); + } + + @Test + void injectsSlicedResourcesAssembler() { + var context = WebTestUtils.createApplicationContext(Config.class); + var controller = context.getBean(SampleController.class); + + assertThat(controller.assembler).isNotNull(); + + var resources = controller.sample(PageRequest.of(1, 1)); + + assertThat(resources.getLink(IanaLinkRelations.PREV)).isNotNull(); + assertThat(resources.getLink(IanaLinkRelations.NEXT)).isNotNull(); + assertThat(resources.getLink(IanaLinkRelations.SELF)).isNotNull(); + } + + @Test + void setsUpSlicedResourcesAssemblerFromManualXmlConfig() { + var context = new ClassPathXmlApplicationContext("manual.xml", getClass()); + assertThat(context.getBean(SlicedResourcesAssembler.class)).isNotNull(); + context.close(); + } + + @Test + void setsUpPagedResourcesAssemblerFromJavaConfigXmlConfig() { + var context = new ClassPathXmlApplicationContext("via-config-class.xml", getClass()); + assertThat(context.getBean(SlicedResourcesAssembler.class)).isNotNull(); + context.close(); + } + + @Configuration + @EnableSpringDataWebSupport + static class Config { + + @Bean + SampleController controller() { + return new SampleController(); + } + } + + @Controller + static class SampleController { + @Autowired + SlicedResourcesAssembler assembler; + + @RequestMapping("/persons") + SlicedModel> sample(Pageable pageable) { + + Slice page = new SliceImpl<>(Collections.singletonList(new Person()), pageable, true); + + return assembler.toModel(page); + } + } + + static class Person { + } +} diff --git a/src/test/resources/org/springframework/data/web/config/manual.xml b/src/test/resources/org/springframework/data/web/config/manual.xml index 876d52a878..1ea70009b5 100644 --- a/src/test/resources/org/springframework/data/web/config/manual.xml +++ b/src/test/resources/org/springframework/data/web/config/manual.xml @@ -12,4 +12,13 @@ + + + + + + + + +