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 @@ + + + + + + + + +