Skip to content

Commit

Permalink
Merge pull request #275 from xenit-eu/ACC-1597
Browse files Browse the repository at this point in the history
Support cursor-based pagination
  • Loading branch information
vierbergenlars authored Sep 20, 2024
2 parents 687c39c + 04697f7 commit 6f05757
Show file tree
Hide file tree
Showing 27 changed files with 1,527 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
package com.contentgrid.spring.boot.autoconfigure.data.pagination;

import com.contentgrid.spring.data.pagination.cursor.ContentGridSpringDataPaginationCursorConfiguration;
import com.contentgrid.spring.data.pagination.cursor.CursorCodec;
import com.contentgrid.spring.data.pagination.cursor.RequestIntegrityCheckCursorCodec;
import com.contentgrid.spring.data.pagination.cursor.SimplePageBasedCursorCodec;
import com.contentgrid.spring.data.pagination.web.ContentGridSpringDataPaginationWebConfiguration;
import com.contentgrid.spring.data.pagination.web.ItemCountPageMetadata;
import com.contentgrid.spring.data.pagination.web.ItemCountPageMetadataOmitLegacyPropertiesMixin;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.webmvc.ContentGridRestProperties;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer;


@AutoConfiguration
@Import(ContentGridSpringDataPaginationWebConfiguration.class)
@Import(
{
ContentGridSpringDataPaginationWebConfiguration.class,
ContentGridSpringDataPaginationCursorConfiguration.class
}
)
@ConditionalOnClass({
ContentGridSpringDataPaginationWebConfiguration.class,
PagedResourcesAssembler.class
Expand All @@ -34,4 +51,55 @@ Jackson2ObjectMapperBuilderCustomizer contentGridSpringDataPaginationOmitLegacyP
};
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({RepositoryRestConfiguration.class})
static class WebmvcPaginationConfiguration {

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
// This needs to run first, so it can be overridden
PageableHandlerMethodArgumentResolverCustomizer contentGridPageableHandlerMethodArgumentResolverParametersCustomizer(
RepositoryRestConfiguration repositoryRestConfiguration
) {
return pageableResolver -> {
pageableResolver.setPageParameterName(repositoryRestConfiguration.getPageParamName());
pageableResolver.setSizeParameterName(repositoryRestConfiguration.getLimitParamName());
pageableResolver.setFallbackPageable(
PageRequest.ofSize(repositoryRestConfiguration.getDefaultPageSize()));
pageableResolver.setMaxPageSize(repositoryRestConfiguration.getMaxPageSize());
};
}
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(CursorCodec.class)
static class CursorCodecConfiguration {

@Bean
CursorCodec contentGridCursorCodec(
ContentGridRestProperties restProperties
) {
return switch (restProperties.getPagination()) {
case PAGE_NUMBER -> new SimplePageBasedCursorCodec();
case PAGE_CURSOR -> new RequestIntegrityCheckCursorCodec(new SimplePageBasedCursorCodec());
};
}

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
RepositoryRestConfigurer contentGridCursorBasedPaginationDefaultsRepositoryRestConfigurer(
ContentGridRestProperties restProperties
) {
// For cursor-based pagination, switch the default page parameter to _cursor
// Note that the SpringBootRepositoryRestConfigurer (which configures the repository from properties)
// has a lower priority, so it runs _after_ this configurer.
// This means that the default provided here can be overridden with the property spring.data.rest.page-param-name
return RepositoryRestConfigurer.withConfig(config -> {
if (restProperties.getPagination().isCursorBased()) {
config.setPageParamName("_cursor");
}
});
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.contentgrid.spring.boot.autoconfigure.data.pagination;

import static org.assertj.core.api.Assertions.assertThat;

import com.contentgrid.spring.boot.autoconfigure.data.web.ContentGridSpringDataRestAutoConfiguration;
import com.contentgrid.spring.data.pagination.cursor.CursorCodec;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;

class WebPaginationAutoConfigurationTest {

private static final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
ContentGridSpringDataRestAutoConfiguration.class,
WebPaginationAutoConfiguration.class,
RepositoryRestMvcAutoConfiguration.class
));

@Test
void pagination_configuration_default_pagination() {
contextRunner.run(context -> {
assertThat(context).hasNotFailed();

assertThat(context).hasSingleBean(CursorCodec.class);
assertThat(context.getBean(RepositoryRestConfiguration.class).getPageParamName()).isEqualTo("page");
});

contextRunner
.withPropertyValues("spring.data.rest.page-param-name=my_page")
.run(context -> {
assertThat(context).hasNotFailed();

assertThat(context).hasSingleBean(CursorCodec.class);
assertThat(context.getBean(RepositoryRestConfiguration.class).getPageParamName()).isEqualTo(
"my_page");
});
}

@Test
void pagination_configuration_cursor_pagination() {
contextRunner
.withPropertyValues("contentgrid.rest.pagination=page_cursor")
.run(context -> {
assertThat(context).hasNotFailed();

assertThat(context).hasSingleBean(CursorCodec.class);
assertThat(context.getBean(RepositoryRestConfiguration.class).getPageParamName()).isEqualTo(
"_cursor");
});

contextRunner
.withPropertyValues("contentgrid.rest.pagination=page_cursor")
.withPropertyValues("spring.data.rest.page-param-name=my_page")
.run(context -> {
assertThat(context).hasNotFailed();

assertThat(context).hasSingleBean(CursorCodec.class);
assertThat(context.getBean(RepositoryRestConfiguration.class).getPageParamName()).isEqualTo(
"my_page");
});
}

@Test
void pagination_configuration_custom_codec() {
contextRunner
.withBean(CursorCodec.class, () -> Mockito.mock(CursorCodec.class))
.withPropertyValues("contentgrid.rest.pagination=page_cursor")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(CursorCodec.class);
assertThat(context.getBean(RepositoryRestConfiguration.class).getPageParamName()).isEqualTo("page");
});

}

}
2 changes: 2 additions & 0 deletions contentgrid-spring-data-pagination/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ dependencies {

implementation 'org.springframework.data:spring-data-jpa'
implementation 'org.springframework.hateoas:spring-hateoas'
implementation 'org.springframework:spring-webmvc'
implementation 'jakarta.servlet:jakarta.servlet-api'
implementation 'com.querydsl:querydsl-core'
implementation 'com.querydsl:querydsl-jpa::jakarta'
implementation 'jakarta.persistence:jakarta.persistence-api'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.contentgrid.spring.data.pagination;

public class InvalidPageSizeException extends InvalidPaginationException {

public InvalidPageSizeException(String parameter, String invalidValue, String message) {
super(parameter, invalidValue, message);
}

public InvalidPageSizeException(String parameter, String invalidValue, Throwable cause) {
this(parameter, invalidValue, cause.getMessage());
initCause(cause);
}

public static InvalidPageSizeException mustBePositive(String parameter, String invalidValue) {
return new InvalidPageSizeException(parameter, invalidValue, "must be positive");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.contentgrid.spring.data.pagination;

import lombok.Getter;

@Getter
public class InvalidPaginationException extends RuntimeException {

private final String parameter;

private final String invalidValue;

public InvalidPaginationException(String parameter, String invalidValue, String message) {
super("Invalid parameter '%s': %s".formatted(parameter, message));
this.parameter = parameter;
this.invalidValue = invalidValue;
}

public InvalidPaginationException(String parameter, String invalidValue, Throwable cause) {
this(parameter, invalidValue, cause.getMessage());
initCause(cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.contentgrid.spring.data.pagination.cursor;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver;
import org.springframework.data.web.HateoasSortHandlerMethodArgumentResolver;
import org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer;

@Configuration(proxyBeanMethods = false)
public class ContentGridSpringDataPaginationCursorConfiguration {

@Bean
static BeanPostProcessor replaceHateoasPageableHandlerMethodArgumentResolverBeanPostProcessor(
@Lazy HateoasSortHandlerMethodArgumentResolver sortHandlerMethodArgumentResolver,
@Lazy CursorCodec cursorCodec,
ObjectProvider<PageableHandlerMethodArgumentResolverCustomizer> resolverCustomizers
) {
return new BeanPostProcessor() {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof HateoasPageableHandlerMethodArgumentResolver) {
return new HateoasPageableCursorHandlerMethodArgumentResolver(
sortHandlerMethodArgumentResolver,
cursorCodec,
resolverCustomizers
);
}
return bean;
}
};
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.contentgrid.spring.data.pagination.cursor;

import java.util.function.UnaryOperator;
import lombok.Builder;
import lombok.NonNull;
import lombok.experimental.StandardException;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.web.util.UriComponents;

/**
* Encoder and decoder for pagination query parameters
*/
public interface CursorCodec {

/**
* Decodes a cursor to a spring pageable
*
* @param context The cursor to decode
* @param uriComponents The rest of the URI, without cursor, page size or sort parameters
* @return Spring pageable, decoded from the cursor
* @throws CursorDecodeException When a cursor can not be decoded
*/
Pageable decodeCursor(CursorContext context, UriComponents uriComponents) throws CursorDecodeException;

/**
* Encodes a spring pageable to a cursor
*
* @param pageable The spring pageable
* @param uriComponents The rest of the URI, without cursor, page size or sort parameters
* @return The cursor that can be used in a request
*/
CursorContext encodeCursor(Pageable pageable, UriComponents uriComponents);

/**
* The cursor with its context.
* <p>
* This object represents the pagination information as encoded in a request
*
* @param cursor The cursor (can be null if no cursor is present in the request)
* @param pageSize The size of a page
* @param sort Sorting of the resultset
*/
@Builder
record CursorContext(
@Nullable
String cursor,
int pageSize,
@NonNull
Sort sort
) {

public CursorContext mapCursor(@NonNull UnaryOperator<@NonNull String> cursorMapper) {
if (cursor == null) {
return this;
}
return new CursorContext(cursorMapper.apply(cursor), pageSize, sort);
}

}

/**
* Thrown when a cursor can not be decoded for any reason
*/
@StandardException
class CursorDecodeException extends Exception {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.contentgrid.spring.data.pagination.cursor;

import org.springframework.data.domain.Pageable;

public interface CursorEncoder {
String encodeCursor(Pageable pageable, String referenceUrl);
}
Loading

0 comments on commit 6f05757

Please sign in to comment.