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
This commit is contained in:
Michael Schout
2022-10-04 16:00:44 -05:00
committed by Oliver Drotbohm
parent 83655663ea
commit 70f21bda9f
8 changed files with 1031 additions and 1 deletions

View File

@@ -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<Object> resolver);
void unique(SlicedResourcesAssembler<Object> assembler, Pageable pageable);
void unnecessarilyQualified(@Qualifier("qualified") SlicedResourcesAssembler<Object> assembler,
Pageable pageable);
void qualifiedUnique(@Qualifier("qualified") SlicedResourcesAssembler<Object> assembler,
@Qualifier("qualified") Pageable pageable);
void qualified(@Qualifier("qualified") SlicedResourcesAssembler<Object> resolver,
@Qualifier("qualified") Pageable pageable, Pageable unqualified);
void unqualifiedAmbiguity(SlicedResourcesAssembler<Object> assembler, Pageable pageable, Pageable unqualified);
void assemblerQualifiedAmbiguity(@Qualifier("qualified") SlicedResourcesAssembler<Object> assembler,
Pageable pageable, Pageable unqualified);
void noMatchingQualifiers(@Qualifier("qualified") SlicedResourcesAssembler<Object> assembler, Pageable pageable,
@Qualifier("qualified2") Pageable unqualified);
@RequestMapping("/{variable}/foo")
void methodWithPathVariable(SlicedResourcesAssembler<Object> assembler);
@RequestMapping("/mapping")
Object methodWithMapping(SlicedResourcesAssembler<Object> pageable);
}
}

View File

@@ -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<Person> EMPTY_SLICE = new SliceImpl<>(Collections.emptyList(), PAGEABLE, false);
HateoasPageableHandlerMethodArgumentResolver resolver = new HateoasPageableHandlerMethodArgumentResolver();
SlicedResourcesAssembler<Person> assembler = new SlicedResourcesAssembler<>(resolver, null);
private static Slice<Person> 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<String, String> 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<Person>(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<Person> 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<Person>(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<Person>(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<Slice<Person>, SlicedModel<EntityModel<Person>>> 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<PersonResource> {
String name;
}
static class PersonResourceAssembler implements RepresentationModelAssembler<Person, PersonResource> {
@Override
public PersonResource toModel(Person entity) {
var resource = new PersonResource();
resource.name = entity.name;
return resource;
}
}
static class CustomSlicedResourcesAssembler<T> extends SlicedResourcesAssembler<T> {
CustomSlicedResourcesAssembler(HateoasPageableHandlerMethodArgumentResolver resolver, UriComponents baseUri) {
super(resolver, baseUri);
}
@Override
protected <R extends RepresentationModel<?>, S> SlicedModel<R> createSlicedModel(List<R> resources,
SlicedModel.SliceMetadata metadata, Slice<S> slice) {
return new CustomSlicedResources<>(resources, metadata);
}
}
static class CustomSlicedResources<R extends RepresentationModel> extends SlicedModel<R> {
CustomSlicedResources(Collection<R> content, SliceMetadata metadata) {
super(content, metadata);
}
}
}

View File

@@ -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<Person> assembler;
@RequestMapping("/persons")
SlicedModel<EntityModel<Person>> sample(Pageable pageable) {
Slice<Person> page = new SliceImpl<>(Collections.singletonList(new Person()), pageable, true);
return assembler.toModel(page);
}
}
static class Person {
}
}