diff --git a/pom.xml b/pom.xml index 9c3a13776..6e0f1ff0a 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,11 @@ spring-web true + + org.springframework + spring-webflux + true + javax.servlet diff --git a/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolver.java b/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolver.java index 2832e66a4..0e0ddc0ce 100644 --- a/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolver.java +++ b/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolver.java @@ -15,19 +15,11 @@ */ package org.springframework.data.web; -import static org.springframework.data.web.SpringDataAnnotationUtils.*; - -import java.lang.reflect.Method; -import java.util.Optional; - -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.MethodParameter; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.ModelAndViewContainer; @@ -43,26 +35,11 @@ import org.springframework.web.method.support.ModelAndViewContainer; * @author Mark Paluch * @author Christoph Strobl */ -public class PageableHandlerMethodArgumentResolver implements PageableArgumentResolver { +public class PageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolverSupport + implements PageableArgumentResolver { private static final SortHandlerMethodArgumentResolver DEFAULT_SORT_RESOLVER = new SortHandlerMethodArgumentResolver(); - private static final String INVALID_DEFAULT_PAGE_SIZE = "Invalid default page size configured for method %s! Must not be less than one!"; - - private static final String DEFAULT_PAGE_PARAMETER = "page"; - private static final String DEFAULT_SIZE_PARAMETER = "size"; - private static final String DEFAULT_PREFIX = ""; - private static final String DEFAULT_QUALIFIER_DELIMITER = "_"; - private static final int DEFAULT_MAX_PAGE_SIZE = 2000; - static final Pageable DEFAULT_PAGE_REQUEST = PageRequest.of(0, 20); - - private Pageable fallbackPageable = DEFAULT_PAGE_REQUEST; private SortArgumentResolver sortResolver; - private String pageParameterName = DEFAULT_PAGE_PARAMETER; - private String sizeParameterName = DEFAULT_SIZE_PARAMETER; - private String prefix = DEFAULT_PREFIX; - private String qualifierDelimiter = DEFAULT_QUALIFIER_DELIMITER; - private int maxPageSize = DEFAULT_MAX_PAGE_SIZE; - private boolean oneIndexedParameters = false; /** * Constructs an instance of this resolved with a default {@link SortHandlerMethodArgumentResolver}. @@ -239,106 +216,17 @@ public class PageableHandlerMethodArgumentResolver implements PageableArgumentRe public Pageable resolveArgument(MethodParameter methodParameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) { - assertPageableUniqueness(methodParameter); + String page = webRequest.getParameter(getParameterNameToUse(getPageParameterName(), methodParameter)); + String pageSize = webRequest.getParameter(getParameterNameToUse(getSizeParameterName(), methodParameter)); - Optional defaultOrFallback = getDefaultFromAnnotationOrFallback(methodParameter).toOptional(); - - String pageString = webRequest.getParameter(getParameterNameToUse(pageParameterName, methodParameter)); - String pageSizeString = webRequest.getParameter(getParameterNameToUse(sizeParameterName, methodParameter)); - - Optional page = parseAndApplyBoundaries(pageString, Integer.MAX_VALUE, true); - Optional pageSize = parseAndApplyBoundaries(pageSizeString, maxPageSize, false); - - if (!(page.isPresent() && pageSize.isPresent()) && !defaultOrFallback.isPresent()) { - return Pageable.unpaged(); - } - - int p = page - .orElseGet(() -> defaultOrFallback.map(Pageable::getPageNumber).orElseThrow(IllegalStateException::new)); - int ps = pageSize - .orElseGet(() -> defaultOrFallback.map(Pageable::getPageSize).orElseThrow(IllegalStateException::new)); - - // Limit lower bound - ps = ps < 1 ? defaultOrFallback.map(Pageable::getPageSize).orElseThrow(IllegalStateException::new) : ps; - // Limit upper bound - ps = ps > maxPageSize ? maxPageSize : ps; Sort sort = sortResolver.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + Pageable pageable = getPageable(methodParameter, page, pageSize); - return PageRequest.of(p, ps, - sort.isSorted() ? sort : defaultOrFallback.map(Pageable::getSort).orElseGet(Sort::unsorted)); - } - - /** - * Returns the name of the request parameter to find the {@link Pageable} information in. Inspects the given - * {@link MethodParameter} for {@link Qualifier} present and prefixes the given source parameter name with it. - * - * @param source the basic parameter name. - * @param parameter the {@link MethodParameter} potentially qualified. - * @return the name of the request parameter. - */ - protected String getParameterNameToUse(String source, @Nullable MethodParameter parameter) { - - StringBuilder builder = new StringBuilder(prefix); - - Qualifier qualifier = parameter == null ? null : parameter.getParameterAnnotation(Qualifier.class); - - if (qualifier != null) { - builder.append(qualifier.value()); - builder.append(qualifierDelimiter); + if (sort.isSorted()) { + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); } - return builder.append(source).toString(); - } - - private Pageable getDefaultFromAnnotationOrFallback(MethodParameter methodParameter) { - - PageableDefault defaults = methodParameter.getParameterAnnotation(PageableDefault.class); - - if (defaults != null) { - return getDefaultPageRequestFrom(methodParameter, defaults); - } - - return fallbackPageable; - } - - private static Pageable getDefaultPageRequestFrom(MethodParameter parameter, PageableDefault defaults) { - - Integer defaultPageNumber = defaults.page(); - Integer defaultPageSize = getSpecificPropertyOrDefaultFromValue(defaults, "size"); - - if (defaultPageSize < 1) { - Method annotatedMethod = parameter.getMethod(); - throw new IllegalStateException(String.format(INVALID_DEFAULT_PAGE_SIZE, annotatedMethod)); - } - - if (defaults.sort().length == 0) { - return PageRequest.of(defaultPageNumber, defaultPageSize); - } - - return PageRequest.of(defaultPageNumber, defaultPageSize, defaults.direction(), defaults.sort()); - } - - /** - * Tries to parse the given {@link String} into an integer and applies the given boundaries. Will return 0 if the - * {@link String} cannot be parsed. - * - * @param parameter the parameter value. - * @param upper the upper bound to be applied. - * @param shiftIndex whether to shift the index if {@link #oneIndexedParameters} is set to true. - * @return - */ - private Optional parseAndApplyBoundaries(@Nullable String parameter, int upper, boolean shiftIndex) { - - if (!StringUtils.hasText(parameter)) { - return Optional.empty(); - } - - try { - int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0); - return Optional.of(parsed < 0 ? 0 : parsed > upper ? upper : parsed); - } catch (NumberFormatException e) { - return Optional.of(0); - } + return pageable; } } diff --git a/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolverSupport.java b/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolverSupport.java new file mode 100644 index 000000000..9ccb84aaf --- /dev/null +++ b/src/main/java/org/springframework/data/web/PageableHandlerMethodArgumentResolverSupport.java @@ -0,0 +1,289 @@ +/* + * Copyright 2017 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 + * + * http://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.data.web.SpringDataAnnotationUtils.*; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Base class providing methods for handler method argument resolvers to create paging information from web requests and + * thus allows injecting {@link Pageable} instances into controller methods. Request properties to be parsed can be + * configured. Default configuration uses request parameters beginning with + * {@link #DEFAULT_PAGE_PARAMETER}{@link #DEFAULT_QUALIFIER_DELIMITER}. + * + * @since 2.1 + * @author Mark Paluch + */ +public abstract class PageableHandlerMethodArgumentResolverSupport { + + private static final String INVALID_DEFAULT_PAGE_SIZE = "Invalid default page size configured for method %s! Must not be less than one!"; + + private static final String DEFAULT_PAGE_PARAMETER = "page"; + private static final String DEFAULT_SIZE_PARAMETER = "size"; + private static final String DEFAULT_PREFIX = ""; + private static final String DEFAULT_QUALIFIER_DELIMITER = "_"; + private static final int DEFAULT_MAX_PAGE_SIZE = 2000; + static final Pageable DEFAULT_PAGE_REQUEST = PageRequest.of(0, 20); + + private Pageable fallbackPageable = DEFAULT_PAGE_REQUEST; + private String pageParameterName = DEFAULT_PAGE_PARAMETER; + private String sizeParameterName = DEFAULT_SIZE_PARAMETER; + private String prefix = DEFAULT_PREFIX; + private String qualifierDelimiter = DEFAULT_QUALIFIER_DELIMITER; + private int maxPageSize = DEFAULT_MAX_PAGE_SIZE; + private boolean oneIndexedParameters = false; + + /** + * Configures the {@link Pageable} to be used as fallback in case no {@link PageableDefault} or + * {@link PageableDefault} (the latter only supported in legacy mode) can be found at the method parameter to be + * resolved. + *

+ * If you set this to {@literal Optional#empty()}, be aware that you controller methods will get {@literal null} + * handed into them in case no {@link Pageable} data can be found in the request. Note, that doing so will require you + * supply bot the page and the size parameter with the requests as there will be no default for any of the + * parameters available. + * + * @param fallbackPageable the {@link Pageable} to be used as general fallback. + */ + public void setFallbackPageable(Pageable fallbackPageable) { + + Assert.notNull(fallbackPageable, "Fallback Pageable must not be null!"); + + this.fallbackPageable = fallbackPageable; + } + + /** + * Returns whether the given {@link Pageable} is the fallback one. + * + * @param pageable can be {@literal null}. + * @return + */ + public boolean isFallbackPageable(Pageable pageable) { + return fallbackPageable == null ? false : fallbackPageable.equals(pageable); + } + + /** + * Configures the maximum page size to be accepted. This allows to put an upper boundary of the page size to prevent + * potential attacks trying to issue an {@link OutOfMemoryError}. Defaults to {@link #DEFAULT_MAX_PAGE_SIZE}. + * + * @param maxPageSize the maxPageSize to set + */ + public void setMaxPageSize(int maxPageSize) { + this.maxPageSize = maxPageSize; + } + + /** + * Retrieves the maximum page size to be accepted. This allows to put an upper boundary of the page size to prevent + * potential attacks trying to issue an {@link OutOfMemoryError}. Defaults to {@link #DEFAULT_MAX_PAGE_SIZE}. + * + * @return the maximum page size allowed. + */ + protected int getMaxPageSize() { + return this.maxPageSize; + } + + /** + * Configures the parameter name to be used to find the page number in the request. Defaults to {@code page}. + * + * @param pageParameterName the parameter name to be used, must not be {@literal null} or empty. + */ + public void setPageParameterName(String pageParameterName) { + + Assert.hasText(pageParameterName, "Page parameter name must not be null or empty!"); + this.pageParameterName = pageParameterName; + } + + /** + * Retrieves the parameter name to be used to find the page number in the request. Defaults to {@code page}. + * + * @return the parameter name to be used, never {@literal null} or empty. + */ + protected String getPageParameterName() { + return this.pageParameterName; + } + + /** + * Configures the parameter name to be used to find the page size in the request. Defaults to {@code size}. + * + * @param sizeParameterName the parameter name to be used, must not be {@literal null} or empty. + */ + public void setSizeParameterName(String sizeParameterName) { + + Assert.hasText(sizeParameterName, "Size parameter name must not be null or empty!"); + this.sizeParameterName = sizeParameterName; + } + + /** + * Retrieves the parameter name to be used to find the page size in the request. Defaults to {@code size}. + * + * @return the parameter name to be used, never {@literal null} or empty. + */ + protected String getSizeParameterName() { + return this.sizeParameterName; + } + + /** + * Configures a general prefix to be prepended to the page number and page size parameters. Useful to namespace the + * property names used in case they are clashing with ones used by your application. By default, no prefix is used. + * + * @param prefix the prefix to be used or {@literal null} to reset to the default. + */ + public void setPrefix(String prefix) { + this.prefix = prefix == null ? DEFAULT_PREFIX : prefix; + } + + /** + * The delimiter to be used between the qualifier and the actual page number and size properties. Defaults to + * {@code _}. So a qualifier of {@code foo} will result in a page number parameter of {@code foo_page}. + * + * @param qualifierDelimiter the delimiter to be used or {@literal null} to reset to the default. + */ + public void setQualifierDelimiter(String qualifierDelimiter) { + this.qualifierDelimiter = qualifierDelimiter == null ? DEFAULT_QUALIFIER_DELIMITER : qualifierDelimiter; + } + + /** + * Configures whether to expose and assume 1-based page number indexes in the request parameters. Defaults to + * {@literal false}, meaning a page number of 0 in the request equals the first page. If this is set to + * {@literal true}, a page number of 1 in the request will be considered the first page. + * + * @param oneIndexedParameters the oneIndexedParameters to set + */ + public void setOneIndexedParameters(boolean oneIndexedParameters) { + this.oneIndexedParameters = oneIndexedParameters; + } + + /** + * Indicates whether to expose and assume 1-based page number indexes in the request parameters. Defaults to + * {@literal false}, meaning a page number of 0 in the request equals the first page. If this is set to + * {@literal true}, a page number of 1 in the request will be considered the first page. + * + * @return whether to assume 1-based page number indexes in the request parameters. + */ + protected boolean isOneIndexedParameters() { + return this.oneIndexedParameters; + } + + protected Pageable getPageable(MethodParameter methodParameter, @Nullable String pageString, + @Nullable String pageSizeString) { + assertPageableUniqueness(methodParameter); + + Optional defaultOrFallback = getDefaultFromAnnotationOrFallback(methodParameter).toOptional(); + + Optional page = parseAndApplyBoundaries(pageString, Integer.MAX_VALUE, true); + Optional pageSize = parseAndApplyBoundaries(pageSizeString, maxPageSize, false); + + if (!(page.isPresent() && pageSize.isPresent()) && !defaultOrFallback.isPresent()) { + return Pageable.unpaged(); + } + + int p = page + .orElseGet(() -> defaultOrFallback.map(Pageable::getPageNumber).orElseThrow(IllegalStateException::new)); + int ps = pageSize + .orElseGet(() -> defaultOrFallback.map(Pageable::getPageSize).orElseThrow(IllegalStateException::new)); + + // Limit lower bound + ps = ps < 1 ? defaultOrFallback.map(Pageable::getPageSize).orElseThrow(IllegalStateException::new) : ps; + // Limit upper bound + ps = ps > maxPageSize ? maxPageSize : ps; + + return PageRequest.of(p, ps, defaultOrFallback.map(Pageable::getSort).orElseGet(Sort::unsorted)); + } + + /** + * Returns the name of the request parameter to find the {@link Pageable} information in. Inspects the given + * {@link MethodParameter} for {@link Qualifier} present and prefixes the given source parameter name with it. + * + * @param source the basic parameter name. + * @param parameter the {@link MethodParameter} potentially qualified. + * @return the name of the request parameter. + */ + protected String getParameterNameToUse(String source, @Nullable MethodParameter parameter) { + + StringBuilder builder = new StringBuilder(prefix); + + Qualifier qualifier = parameter == null ? null : parameter.getParameterAnnotation(Qualifier.class); + + if (qualifier != null) { + builder.append(qualifier.value()); + builder.append(qualifierDelimiter); + } + + return builder.append(source).toString(); + } + + private Pageable getDefaultFromAnnotationOrFallback(MethodParameter methodParameter) { + + PageableDefault defaults = methodParameter.getParameterAnnotation(PageableDefault.class); + + if (defaults != null) { + return getDefaultPageRequestFrom(methodParameter, defaults); + } + + return fallbackPageable; + } + + private static Pageable getDefaultPageRequestFrom(MethodParameter parameter, PageableDefault defaults) { + + Integer defaultPageNumber = defaults.page(); + Integer defaultPageSize = getSpecificPropertyOrDefaultFromValue(defaults, "size"); + + if (defaultPageSize < 1) { + Method annotatedMethod = parameter.getMethod(); + throw new IllegalStateException(String.format(INVALID_DEFAULT_PAGE_SIZE, annotatedMethod)); + } + + if (defaults.sort().length == 0) { + return PageRequest.of(defaultPageNumber, defaultPageSize); + } + + return PageRequest.of(defaultPageNumber, defaultPageSize, defaults.direction(), defaults.sort()); + } + + /** + * Tries to parse the given {@link String} into an integer and applies the given boundaries. Will return 0 if the + * {@link String} cannot be parsed. + * + * @param parameter the parameter value. + * @param upper the upper bound to be applied. + * @param shiftIndex whether to shift the index if {@link #oneIndexedParameters} is set to true. + * @return + */ + private Optional parseAndApplyBoundaries(@Nullable String parameter, int upper, boolean shiftIndex) { + + if (!StringUtils.hasText(parameter)) { + return Optional.empty(); + } + + try { + int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0); + return Optional.of(parsed < 0 ? 0 : parsed > upper ? upper : parsed); + } catch (NumberFormatException e) { + return Optional.of(0); + } + } +} diff --git a/src/main/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolver.java b/src/main/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolver.java new file mode 100644 index 000000000..9994c0ca8 --- /dev/null +++ b/src/main/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolver.java @@ -0,0 +1,92 @@ +/* + * Copyright 2017 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 + * + * http://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 javax.annotation.Nonnull; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Extracts paging information from web requests and thus allows injecting {@link Pageable} instances into WebFlux + * controller methods. Request properties to be parsed can be configured. Default configuration uses request parameters + * beginning with {@link #DEFAULT_PAGE_PARAMETER}{@link #DEFAULT_QUALIFIER_DELIMITER}. + * + * @since 2.1 + * @author Mark Paluch + */ +public class ReactivePageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolverSupport + implements SyncHandlerMethodArgumentResolver { + + private static final ReactiveSortHandlerMethodArgumentResolver DEFAULT_SORT_RESOLVER = new ReactiveSortHandlerMethodArgumentResolver(); + + private ReactiveSortHandlerMethodArgumentResolver sortResolver; + + /** + * Constructs an instance of this resolved with a default {@link ReactiveSortHandlerMethodArgumentResolver}. + */ + public ReactivePageableHandlerMethodArgumentResolver() { + this(DEFAULT_SORT_RESOLVER); + } + + /** + * Constructs an instance of this resolver with the specified {@link SortArgumentResolver}. + * + * @param sortResolver the sort resolver to use. + */ + public ReactivePageableHandlerMethodArgumentResolver(ReactiveSortHandlerMethodArgumentResolver sortResolver) { + + Assert.notNull(sortResolver, "ReactiveSortHandlerMethodArgumentResolver must not be null!"); + + this.sortResolver = sortResolver; + } + + /* + * (non-Javadoc) + * @see org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Pageable.class.equals(parameter.getParameterType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver#resolveArgumentValue(org.springframework.core.MethodParameter, org.springframework.web.reactive.BindingContext, org.springframework.web.server.ServerWebExchange) + */ + @Nonnull + @Override + public Pageable resolveArgumentValue(MethodParameter parameter, BindingContext bindingContext, + ServerWebExchange exchange) { + + MultiValueMap queryParams = exchange.getRequest().getQueryParams(); + String page = queryParams.getFirst(getParameterNameToUse(getPageParameterName(), parameter)); + String pageSize = queryParams.getFirst(getParameterNameToUse(getSizeParameterName(), parameter)); + + Sort sort = sortResolver.resolveArgumentValue(parameter, bindingContext, exchange); + + Pageable pageable = getPageable(parameter, page, pageSize); + + return sort.isSorted() ? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort) : pageable; + } +} diff --git a/src/main/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolver.java b/src/main/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolver.java new file mode 100644 index 000000000..d5c52af31 --- /dev/null +++ b/src/main/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolver.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017 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 + * + * http://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.util.List; + +import javax.annotation.Nonnull; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Reactive {@link HandlerMethodArgumentResolver} to create {@link Sort} instances from query string parameters or + * {@link SortDefault} annotations. + * + * @since 2.1 + * @author Mark Paluch + */ +public class ReactiveSortHandlerMethodArgumentResolver extends SortHandlerMethodArgumentResolverSupport + implements SyncHandlerMethodArgumentResolver { + + /* + * (non-Javadoc) + * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter) + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Sort.class.equals(parameter.getParameterType()); + } + + /* + *(non-Javadoc) + * @see org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver#resolveArgumentValue(org.springframework.core.MethodParameter, org.springframework.web.reactive.BindingContext, org.springframework.web.server.ServerWebExchange) + */ + @Nonnull + @Override + public Sort resolveArgumentValue(MethodParameter parameter, BindingContext bindingContext, + ServerWebExchange exchange) { + + List directionParameter = exchange.getRequest().getQueryParams().get(getSortParameter(parameter)); + + // No parameter + if (directionParameter == null) { + return getDefaultFromAnnotationOrFallback(parameter); + } + + // Single empty parameter, e.g "sort=" + if (directionParameter.size() == 1 && !StringUtils.hasText(directionParameter.get(0))) { + return getDefaultFromAnnotationOrFallback(parameter); + } + + return parseParameterIntoSort(directionParameter, getPropertyDelimiter()); + } +} diff --git a/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolver.java b/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolver.java index 7bafca892..53fcfd5ba 100644 --- a/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolver.java +++ b/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolver.java @@ -17,18 +17,12 @@ package org.springframework.data.web; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.Optional; import javax.annotation.Nullable; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.MethodParameter; import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.data.domain.Sort.Order; -import org.springframework.data.web.SortDefault.SortDefaults; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -47,14 +41,14 @@ import org.springframework.web.method.support.ModelAndViewContainer; * @author Mark Paluch * @author Christoph Strobl */ -public class SortHandlerMethodArgumentResolver implements SortArgumentResolver { +public class SortHandlerMethodArgumentResolver extends SortHandlerMethodArgumentResolverSupport { private static final String DEFAULT_PARAMETER = "sort"; private static final String DEFAULT_PROPERTY_DELIMITER = ","; private static final String DEFAULT_QUALIFIER_DELIMITER = "_"; private static final Sort DEFAULT_SORT = Sort.unsorted(); - private static final String SORT_DEFAULTS_NAME = SortDefaults.class.getSimpleName(); + private static final String SORT_DEFAULTS_NAME = SortDefault.SortDefaults.class.getSimpleName(); private static final String SORT_DEFAULT_NAME = SortDefault.class.getSimpleName(); private Sort fallbackSort = DEFAULT_SORT; @@ -137,255 +131,6 @@ public class SortHandlerMethodArgumentResolver implements SortArgumentResolver { return getDefaultFromAnnotationOrFallback(parameter); } - return parseParameterIntoSort(directionParameter, propertyDelimiter); - } - - /** - * Returns the sort parameter to be looked up from the request. Potentially applies qualifiers to it. - * - * @param parameter can be {@literal null}. - * @return - */ - protected String getSortParameter(@Nullable MethodParameter parameter) { - - StringBuilder builder = new StringBuilder(); - - Qualifier qualifier = parameter != null ? parameter.getParameterAnnotation(Qualifier.class) : null; - - if (qualifier != null) { - builder.append(qualifier.value()).append(qualifierDelimiter); - } - - return builder.append(sortParameter).toString(); - } - - /** - * Folds the given {@link Sort} instance into a {@link List} of sort expressions, accumulating {@link Order} instances - * of the same direction into a single expression if they are in order. - * - * @param sort must not be {@literal null}. - * @return - */ - protected List foldIntoExpressions(Sort sort) { - - List expressions = new ArrayList<>(); - ExpressionBuilder builder = null; - - for (Order order : sort) { - - Direction direction = order.getDirection(); - - if (builder == null) { - builder = new ExpressionBuilder(direction); - } else if (!builder.hasSameDirectionAs(order)) { - builder.dumpExpressionIfPresentInto(expressions); - builder = new ExpressionBuilder(direction); - } - - builder.add(order.getProperty()); - } - - return builder == null ? Collections.emptyList() : builder.dumpExpressionIfPresentInto(expressions); - } - - /** - * Folds the given {@link Sort} instance into two expressions. The first being the property list, the second being the - * direction. - * - * @throws IllegalArgumentException if a {@link Sort} with multiple {@link Direction}s has been handed in. - * @param sort must not be {@literal null}. - * @return - */ - protected List legacyFoldExpressions(Sort sort) { - - List expressions = new ArrayList<>(); - ExpressionBuilder builder = null; - - for (Order order : sort) { - - Direction direction = order.getDirection(); - - if (builder == null) { - builder = new ExpressionBuilder(direction); - } else if (!builder.hasSameDirectionAs(order)) { - throw new IllegalArgumentException(String.format( - "%s in legacy configuration only supports a single direction to sort by!", getClass().getSimpleName())); - } - - builder.add(order.getProperty()); - } - - return builder == null ? Collections.emptyList() : builder.dumpExpressionIfPresentInto(expressions); - } - - /** - * Parses the given sort expressions into a {@link Sort} instance. The implementation expects the sources to be a - * concatenation of Strings using the given delimiter. If the last element can be parsed into a {@link Direction} it's - * considered a {@link Direction} and a simple property otherwise. - * - * @param source will never be {@literal null}. - * @param delimiter the delimiter to be used to split up the source elements, will never be {@literal null}. - * @return - */ - Sort parseParameterIntoSort(String[] source, String delimiter) { - - List allOrders = new ArrayList<>(); - - for (String part : source) { - - if (part == null) { - continue; - } - - String[] elements = Arrays.stream(part.split(delimiter)) // - .filter(SortHandlerMethodArgumentResolver::notOnlyDots) // - .toArray(String[]::new); - - Optional direction = elements.length == 0 // - ? Optional.empty() // - : Direction.fromOptionalString(elements[elements.length - 1]); - - int lastIndex = direction.map(it -> elements.length - 1) // - .orElseGet(() -> elements.length); - - for (int i = 0; i < lastIndex; i++) { - toOrder(elements[i], direction).ifPresent(allOrders::add); - } - } - - return allOrders.isEmpty() ? Sort.unsorted() : Sort.by(allOrders); - } - - /** - * Reads the default {@link Sort} to be used from the given {@link MethodParameter}. Rejects the parameter if both an - * {@link SortDefaults} and {@link SortDefault} annotation is found as we cannot build a reliable {@link Sort} - * instance then (property ordering). - * - * @param parameter will never be {@literal null}. - * @return the default {@link Sort} instance derived from the parameter annotations or the configured fallback-sort - * {@link #setFallbackSort(Sort)}. - */ - private Sort getDefaultFromAnnotationOrFallback(MethodParameter parameter) { - - SortDefaults annotatedDefaults = parameter.getParameterAnnotation(SortDefaults.class); - SortDefault annotatedDefault = parameter.getParameterAnnotation(SortDefault.class); - - if (annotatedDefault != null && annotatedDefaults != null) { - throw new IllegalArgumentException( - String.format("Cannot use both @%s and @%s on parameter %s! Move %s into %s to define sorting order!", - SORT_DEFAULTS_NAME, SORT_DEFAULT_NAME, parameter.toString(), SORT_DEFAULT_NAME, SORT_DEFAULTS_NAME)); - } - - if (annotatedDefault != null) { - return appendOrCreateSortTo(annotatedDefault, Sort.unsorted()); - } - - if (annotatedDefaults != null) { - - Sort sort = Sort.unsorted(); - - for (SortDefault currentAnnotatedDefault : annotatedDefaults.value()) { - sort = appendOrCreateSortTo(currentAnnotatedDefault, sort); - } - - return sort; - } - - return fallbackSort; - } - - /** - * Creates a new {@link Sort} instance from the given {@link SortDefault} or appends it to the given {@link Sort} - * instance if it's not {@literal null}. - * - * @param sortDefault - * @param sortOrNull - * @return - */ - private Sort appendOrCreateSortTo(SortDefault sortDefault, Sort sortOrNull) { - - String[] fields = SpringDataAnnotationUtils.getSpecificPropertyOrDefaultFromValue(sortDefault, "sort"); - - return fields.length == 0 // - ? Sort.unsorted() - : sortOrNull.and(Sort.by(sortDefault.direction(), fields)); - } - - /** - * Returns whether the given source {@link String} consists of dots only. - * - * @param source must not be {@literal null}. - * @return - */ - private static boolean notOnlyDots(String source) { - return StringUtils.hasText(source.replace(".", "")); - } - - private static Optional toOrder(String property, Optional direction) { - - return !StringUtils.hasText(property) // - ? Optional.empty() // - : Optional.of(direction.map(it -> new Order(it, property)) // - .orElseGet(() -> Order.by(property))); - } - - /** - * Helper to easily build request parameter expressions for {@link Sort} instances. - * - * @author Oliver Gierke - */ - class ExpressionBuilder { - - private final List elements = new ArrayList<>(); - private final Direction direction; - - /** - * Sets up a new {@link ExpressionBuilder} for properties to be sorted in the given {@link Direction}. - * - * @param direction must not be {@literal null}. - */ - public ExpressionBuilder(Direction direction) { - - Assert.notNull(direction, "Direction must not be null!"); - this.direction = direction; - } - - /** - * Returns whether the given {@link Order} has the same direction as the current {@link ExpressionBuilder}. - * - * @param order must not be {@literal null}. - * @return - */ - public boolean hasSameDirectionAs(Order order) { - return this.direction == order.getDirection(); - } - - /** - * Adds the given property to the expression to be built. - * - * @param property - */ - public void add(String property) { - this.elements.add(property); - } - - /** - * Dumps the expression currently in build into the given {@link List} of {@link String}s. Will only dump it in case - * there are properties piled up currently. - * - * @param expressions - * @return - */ - public List dumpExpressionIfPresentInto(List expressions) { - - if (elements.isEmpty()) { - return expressions; - } - - elements.add(direction.name().toLowerCase()); - expressions.add(StringUtils.collectionToDelimitedString(elements, propertyDelimiter)); - - return expressions; - } + return parseParameterIntoSort(Arrays.asList(directionParameter), getPropertyDelimiter()); } } diff --git a/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolverSupport.java b/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolverSupport.java new file mode 100644 index 000000000..127fd6f4d --- /dev/null +++ b/src/main/java/org/springframework/data/web/SortHandlerMethodArgumentResolverSupport.java @@ -0,0 +1,348 @@ +/* + * Copyright 2017 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 + * + * http://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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.web.SortDefault.SortDefaults; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Base class providing methods for handler method argument resolvers to create {@link Sort} instances from request + * parameters or {@link SortDefault} annotations. + * + * @since 2.1 + * @see SortHandlerMethodArgumentResolver + * @see ReactiveSortHandlerMethodArgumentResolver + * @author Mark Paluch + */ +public abstract class SortHandlerMethodArgumentResolverSupport { + + private static final String DEFAULT_PARAMETER = "sort"; + private static final String DEFAULT_PROPERTY_DELIMITER = ","; + private static final String DEFAULT_QUALIFIER_DELIMITER = "_"; + private static final Sort DEFAULT_SORT = Sort.unsorted(); + + private static final String SORT_DEFAULTS_NAME = SortDefaults.class.getSimpleName(); + private static final String SORT_DEFAULT_NAME = SortDefault.class.getSimpleName(); + + private Sort fallbackSort = DEFAULT_SORT; + private String sortParameter = DEFAULT_PARAMETER; + private String propertyDelimiter = DEFAULT_PROPERTY_DELIMITER; + private String qualifierDelimiter = DEFAULT_QUALIFIER_DELIMITER; + + /** + * propertyDel Configure the request parameter to lookup sort information from. Defaults to {@code sort}. + * + * @param sortParameter must not be {@literal null} or empty. + */ + public void setSortParameter(String sortParameter) { + + Assert.hasText(sortParameter, "SortParameter must not be null nor empty!"); + this.sortParameter = sortParameter; + } + + /** + * Configures the delimiter used to separate property references and the direction to be sorted by. Defaults to + * {@code}, which means sort values look like this: {@code firstname,lastname,asc}. + * + * @param propertyDelimiter must not be {@literal null} or empty. + */ + public void setPropertyDelimiter(String propertyDelimiter) { + + Assert.hasText(propertyDelimiter, "Property delimiter must not be null or empty!"); + this.propertyDelimiter = propertyDelimiter; + } + + /** + * @return the configured delimiter used to separate property references and the direction to be sorted by + */ + public String getPropertyDelimiter() { + return propertyDelimiter; + } + + /** + * Configures the delimiter used to separate the qualifier from the sort parameter. Defaults to {@code _}, so a + * qualified sort property would look like {@code qualifier_sort}. + * + * @param qualifierDelimiter the qualifier delimiter to be used or {@literal null} to reset to the default. + */ + public void setQualifierDelimiter(String qualifierDelimiter) { + this.qualifierDelimiter = qualifierDelimiter == null ? DEFAULT_QUALIFIER_DELIMITER : qualifierDelimiter; + } + + /** + * Configures the {@link Sort} to be used as fallback in case no {@link SortDefault} or {@link SortDefaults} (the + * latter only supported in legacy mode) can be found at the method parameter to be resolved. + *

+ * If you set this to {@literal null}, be aware that you controller methods will get {@literal null} handed into them + * in case no {@link Sort} data can be found in the request. + * + * @param fallbackSort the {@link Sort} to be used as general fallback. + */ + public void setFallbackSort(Sort fallbackSort) { + this.fallbackSort = fallbackSort; + } + + /** + * Reads the default {@link Sort} to be used from the given {@link MethodParameter}. Rejects the parameter if both an + * {@link SortDefaults} and {@link SortDefault} annotation is found as we cannot build a reliable {@link Sort} + * instance then (property ordering). + * + * @param parameter will never be {@literal null}. + * @return the default {@link Sort} instance derived from the parameter annotations or the configured fallback-sort + * {@link #setFallbackSort(Sort)}. + */ + protected Sort getDefaultFromAnnotationOrFallback(MethodParameter parameter) { + + SortDefaults annotatedDefaults = parameter.getParameterAnnotation(SortDefaults.class); + SortDefault annotatedDefault = parameter.getParameterAnnotation(SortDefault.class); + + if (annotatedDefault != null && annotatedDefaults != null) { + throw new IllegalArgumentException( + String.format("Cannot use both @%s and @%s on parameter %s! Move %s into %s to define sorting order!", + SORT_DEFAULTS_NAME, SORT_DEFAULT_NAME, parameter.toString(), SORT_DEFAULT_NAME, SORT_DEFAULTS_NAME)); + } + + if (annotatedDefault != null) { + return appendOrCreateSortTo(annotatedDefault, Sort.unsorted()); + } + + if (annotatedDefaults != null) { + + Sort sort = Sort.unsorted(); + + for (SortDefault currentAnnotatedDefault : annotatedDefaults.value()) { + sort = appendOrCreateSortTo(currentAnnotatedDefault, sort); + } + + return sort; + } + + return fallbackSort; + } + + /** + * Creates a new {@link Sort} instance from the given {@link SortDefault} or appends it to the given {@link Sort} + * instance if it's not {@literal null}. + * + * @param sortDefault + * @param sortOrNull + * @return + */ + private Sort appendOrCreateSortTo(SortDefault sortDefault, Sort sortOrNull) { + + String[] fields = SpringDataAnnotationUtils.getSpecificPropertyOrDefaultFromValue(sortDefault, "sort"); + + if (fields.length == 0) { + return Sort.unsorted(); + } + + return sortOrNull.and(Sort.by(sortDefault.direction(), fields)); + } + + /** + * Returns the sort parameter to be looked up from the request. Potentially applies qualifiers to it. + * + * @param parameter can be {@literal null}. + * @return + */ + protected String getSortParameter(@Nullable MethodParameter parameter) { + + StringBuilder builder = new StringBuilder(); + + Qualifier qualifier = parameter != null ? parameter.getParameterAnnotation(Qualifier.class) : null; + + if (qualifier != null) { + builder.append(qualifier.value()).append(qualifierDelimiter); + } + + return builder.append(sortParameter).toString(); + } + + /** + * Parses the given sort expressions into a {@link Sort} instance. The implementation expects the sources to be a + * concatenation of Strings using the given delimiter. If the last element can be parsed into a {@link Direction} it's + * considered a {@link Direction} and a simple property otherwise. + * + * @param source will never be {@literal null}. + * @param delimiter the delimiter to be used to split up the source elements, will never be {@literal null}. + * @return + */ + Sort parseParameterIntoSort(List source, String delimiter) { + + List allOrders = new ArrayList<>(); + + for (String part : source) { + + if (part == null) { + continue; + } + + String[] elements = part.split(delimiter); + + Optional direction = elements.length == 0 ? Optional.empty() + : Direction.fromOptionalString(elements[elements.length - 1]); + + int lastIndex = direction.map(it -> elements.length - 1).orElseGet(() -> elements.length); + + for (int i = 0; i < lastIndex; i++) { + toOrder(elements[i], direction).ifPresent(allOrders::add); + } + } + + return allOrders.isEmpty() ? Sort.unsorted() : Sort.by(allOrders); + } + + private static Optional toOrder(String property, Optional direction) { + + if (!StringUtils.hasText(property)) { + return Optional.empty(); + } + + return Optional.of(direction.map(it -> new Order(it, property)).orElseGet(() -> Order.by(property))); + } + + /** + * Folds the given {@link Sort} instance into a {@link List} of sort expressions, accumulating {@link Order} instances + * of the same direction into a single expression if they are in order. + * + * @param sort must not be {@literal null}. + * @return + */ + protected List foldIntoExpressions(Sort sort) { + + List expressions = new ArrayList<>(); + ExpressionBuilder builder = null; + + for (Order order : sort) { + + Direction direction = order.getDirection(); + + if (builder == null) { + builder = new ExpressionBuilder(direction); + } else if (!builder.hasSameDirectionAs(order)) { + builder.dumpExpressionIfPresentInto(expressions); + builder = new ExpressionBuilder(direction); + } + + builder.add(order.getProperty()); + } + + return builder == null ? Collections.emptyList() : builder.dumpExpressionIfPresentInto(expressions); + } + + /** + * Folds the given {@link Sort} instance into two expressions. The first being the property list, the second being the + * direction. + * + * @throws IllegalArgumentException if a {@link Sort} with multiple {@link Direction}s has been handed in. + * @param sort must not be {@literal null}. + * @return + */ + protected List legacyFoldExpressions(Sort sort) { + + List expressions = new ArrayList<>(); + ExpressionBuilder builder = null; + + for (Order order : sort) { + + Direction direction = order.getDirection(); + + if (builder == null) { + builder = new ExpressionBuilder(direction); + } else if (!builder.hasSameDirectionAs(order)) { + throw new IllegalArgumentException(String.format( + "%s in legacy configuration only supports a single direction to sort by!", getClass().getSimpleName())); + } + + builder.add(order.getProperty()); + } + + return builder == null ? Collections.emptyList() : builder.dumpExpressionIfPresentInto(expressions); + } + + /** + * Helper to easily build request parameter expressions for {@link Sort} instances. + * + * @author Oliver Gierke + */ + class ExpressionBuilder { + + private final List elements = new ArrayList<>(); + private final Direction direction; + + /** + * Sets up a new {@link ExpressionBuilder} for properties to be sorted in the given {@link Direction}. + * + * @param direction must not be {@literal null}. + */ + ExpressionBuilder(Direction direction) { + + Assert.notNull(direction, "Direction must not be null!"); + this.direction = direction; + } + + /** + * Returns whether the given {@link Order} has the same direction as the current {@link ExpressionBuilder}. + * + * @param order must not be {@literal null}. + * @return + */ + boolean hasSameDirectionAs(Order order) { + return this.direction == order.getDirection(); + } + + /** + * Adds the given property to the expression to be built. + * + * @param property + */ + void add(String property) { + this.elements.add(property); + } + + /** + * Dumps the expression currently in build into the given {@link List} of {@link String}s. Will only dump it in case + * there are properties piled up currently. + * + * @param expressions + * @return + */ + List dumpExpressionIfPresentInto(List expressions) { + + if (elements.isEmpty()) { + return expressions; + } + + elements.add(direction.name().toLowerCase()); + expressions.add(StringUtils.collectionToDelimitedString(elements, propertyDelimiter)); + + return expressions; + } + } +} diff --git a/src/test/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolverUnitTests.java b/src/test/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolverUnitTests.java new file mode 100755 index 000000000..0fb9d1e22 --- /dev/null +++ b/src/test/java/org/springframework/data/web/ReactivePageableHandlerMethodArgumentResolverUnitTests.java @@ -0,0 +1,280 @@ +/* + * Copyright 2017 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 + * + * http://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.*; +import static org.springframework.data.web.PageableDefaultUnitTests.*; +import static org.springframework.data.web.PageableHandlerMethodArgumentResolver.*; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.SortDefault.SortDefaults; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; + +/** + * Unit tests for {@link ReactivePageableHandlerMethodArgumentResolver}. + * + * @author Mark Paluch + */ +public class ReactivePageableHandlerMethodArgumentResolverUnitTests { + + @Rule public final ExpectedException exception = ExpectedException.none(); + + MethodParameter supportedMethodParameter; + + @Before + public void setUp() throws Exception { + this.supportedMethodParameter = new MethodParameter(Sample.class.getMethod("supportedMethod", Pageable.class), 0); + } + + @Test // DATACMNS-1211 + public void preventsPageSizeFromExceedingMayValueIfConfigured() { + + // Read side + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=0&size=200").build(); + + assertSupportedAndResult(supportedMethodParameter, PageRequest.of(0, 100), request); + } + + @Test(expected = IllegalArgumentException.class) // DATACMNS-1211 + public void rejectsEmptyPageParameterName() { + new ReactivePageableHandlerMethodArgumentResolver().setPageParameterName(""); + } + + @Test(expected = IllegalArgumentException.class) // DATACMNS-1211 + public void rejectsNullPageParameterName() { + new ReactivePageableHandlerMethodArgumentResolver().setPageParameterName(null); + } + + @Test(expected = IllegalArgumentException.class) // DATACMNS-1211 + public void rejectsEmptySizeParameterName() { + new ReactivePageableHandlerMethodArgumentResolver().setSizeParameterName(""); + } + + @Test(expected = IllegalArgumentException.class) // DATACMNS-1211 + public void rejectsNullSizeParameterName() { + new ReactivePageableHandlerMethodArgumentResolver().setSizeParameterName(null); + } + + @Test // DATACMNS-1211 + public void qualifierIsUsedInParameterLookup() throws Exception { + + MethodParameter parameter = new MethodParameter(Sample.class.getMethod("validQualifier", Pageable.class), 0); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?foo_page=2&foo_size=10").build(); + + assertSupportedAndResult(parameter, PageRequest.of(2, 10), request); + } + + @Test // DATACMNS-1211 + public void usesDefaultPageSizeIfRequestPageSizeIsLessThanOne() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=0&size=0").build(); + + assertSupportedAndResult(supportedMethodParameter, DEFAULT_PAGE_REQUEST, request); + } + + @Test // DATACMNS-1211 + public void rejectsInvalidCustomDefaultForPageSize() throws Exception { + + MethodParameter parameter = new MethodParameter(Sample.class.getMethod("invalidDefaultPageSize", Pageable.class), + 0); + + exception.expect(IllegalStateException.class); + exception.expectMessage("invalidDefaultPageSize"); + + assertSupportedAndResult(parameter, DEFAULT_PAGE_REQUEST); + } + + @Test // DATACMNS-1211 + public void fallsBackToFirstPageIfNegativePageNumberIsGiven() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=-1").build(); + + assertSupportedAndResult(supportedMethodParameter, DEFAULT_PAGE_REQUEST, request); + } + + @Test // DATACMNS-1211 + public void pageParamIsNotNumeric() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=a").build(); + + assertSupportedAndResult(supportedMethodParameter, DEFAULT_PAGE_REQUEST, request); + } + + @Test // DATACMNS-1211 + public void sizeParamIsNotNumeric() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?size=a").build(); + + assertSupportedAndResult(supportedMethodParameter, DEFAULT_PAGE_REQUEST, request); + } + + @Test // DATACMNS-1211 + public void returnsNullIfFallbackIsUnpagedAndNoParametersGiven() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setFallbackPageable(Pageable.unpaged()); + + assertSupportedAndResult(supportedMethodParameter, Pageable.unpaged(), TestUtils.getWebfluxRequest(), resolver); + } + + @Test // DATACMNS-1211 + public void returnsFallbackIfOnlyPageIsGiven() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setFallbackPageable(Pageable.unpaged()); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=20").build(); + + assertThat(resolve(resolver, request)).isEqualTo(Pageable.unpaged()); + } + + @Test // DATACMNS-1211 + public void returnsFallbackIfFallbackIsUnpagedAndOnlySizeIsGiven() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setFallbackPageable(Pageable.unpaged()); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?size=10").build(); + + assertThat(resolve(resolver, request)).isEqualTo(Pageable.unpaged()); + } + + @Test // DATACMNS-1211 + public void considersOneIndexedParametersSetting() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setOneIndexedParameters(true); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=1").build(); + + assertThat(resolve(resolver, request).getPageNumber()).isEqualTo(0); + } + + @Test // DATACMNS-1211 + public void usesNullSortIfNoDefaultIsConfiguredAndPageAndSizeAreGiven() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setFallbackPageable(Pageable.unpaged()); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=0&size=10").build(); + + Pageable result = resolve(resolver, request); + + assertThat(result.getPageNumber()).isEqualTo(0); + assertThat(result.getPageSize()).isEqualTo(10); + assertThat(result.getSort().isSorted()).isFalse(); + } + + @Test // DATACMNS-1211 + public void oneIndexedParametersDefaultsIndexOutOfRange() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setOneIndexedParameters(true); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?page=0").build(); + + assertThat(resolve(resolver, request).getPageNumber()).isEqualTo(0); + } + + @Test // DATACMNS-1211 + public void returnsCorrectPageSizeForOneIndexParameters() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setOneIndexedParameters(true); + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?size=10").build(); + + assertThat(resolve(resolver, request).getPageSize()).isEqualTo(10); + } + + @Test // DATACMNS-1211 + public void detectsFallbackPageableIfNullOneIsConfigured() { + + ReactivePageableHandlerMethodArgumentResolver resolver = getReactiveResolver(); + resolver.setFallbackPageable(Pageable.unpaged()); + + assertThat(resolver.isFallbackPageable(null)).isFalse(); + assertThat(resolver.isFallbackPageable(PageRequest.of(0, 10))).isFalse(); + } + + private ReactivePageableHandlerMethodArgumentResolver getReactiveResolver() { + + ReactivePageableHandlerMethodArgumentResolver resolver = new ReactivePageableHandlerMethodArgumentResolver(); + resolver.setMaxPageSize(100); + return resolver; + } + + private void assertSupportedAndResult(MethodParameter parameter, Pageable pageable) { + assertSupportedAndResult(parameter, pageable, TestUtils.getWebfluxRequest()); + } + + private void assertSupportedAndResult(MethodParameter parameter, Pageable pageable, MockServerHttpRequest request) { + assertSupportedAndResult(parameter, pageable, request, getReactiveResolver()); + } + + private void assertSupportedAndResult(MethodParameter parameter, Pageable pageable, MockServerHttpRequest request, + SyncHandlerMethodArgumentResolver resolver) { + + assertThat(resolver.supportsParameter(parameter)).isTrue(); + + Object value = resolver.resolveArgumentValue(parameter, null, MockServerWebExchange.from(request)); + assertThat(value).isEqualTo(pageable); + } + + private Pageable resolve(ReactivePageableHandlerMethodArgumentResolver resolver, MockServerHttpRequest request) { + return resolver.resolveArgumentValue(supportedMethodParameter, null, MockServerWebExchange.from(request)); + } + + interface Sample { + + void supportedMethod(Pageable pageable); + + void unsupportedMethod(String string); + + void invalidDefaultPageSize(@PageableDefault(size = 0) Pageable pageable); + + void simpleDefault(@PageableDefault(size = PAGE_SIZE, page = PAGE_NUMBER) Pageable pageable); + + void simpleDefaultWithSort( + @PageableDefault(size = PAGE_SIZE, page = PAGE_NUMBER, sort = { "firstname", "lastname" }) Pageable pageable); + + void simpleDefaultWithSortAndDirection(@PageableDefault(size = PAGE_SIZE, page = PAGE_NUMBER, + sort = { "firstname", "lastname" }, direction = Direction.DESC) Pageable pageable); + + void simpleDefaultWithExternalSort(@PageableDefault(size = PAGE_SIZE, page = PAGE_NUMBER) // + @SortDefault(sort = { "firstname", "lastname" }, direction = Direction.DESC) Pageable pageable); + + void simpleDefaultWithContaineredExternalSort(@PageableDefault(size = PAGE_SIZE, page = PAGE_NUMBER) // + @SortDefaults(@SortDefault(sort = { "firstname", "lastname" }, direction = Direction.DESC)) Pageable pageable); + + void invalidQualifiers(@Qualifier("foo") Pageable first, @Qualifier("foo") Pageable second); + + void validQualifier(@Qualifier("foo") Pageable pageable); + + void noQualifiers(Pageable first, Pageable second); + } +} diff --git a/src/test/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolverUnitTests.java b/src/test/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolverUnitTests.java new file mode 100755 index 000000000..ca074b767 --- /dev/null +++ b/src/test/java/org/springframework/data/web/ReactiveSortHandlerMethodArgumentResolverUnitTests.java @@ -0,0 +1,271 @@ +/* + * Copyright 2017 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 + * + * http://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.*; +import static org.springframework.data.domain.Sort.Direction.*; +import static org.springframework.data.web.SortDefaultUnitTests.*; + +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.web.SortDefault.SortDefaults; +import org.springframework.lang.Nullable; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.StringUtils; + +/** + * Unit tests for {@link ReactiveSortHandlerMethodArgumentResolver}. + * + * @author Mark Paluch + */ +public class ReactiveSortHandlerMethodArgumentResolverUnitTests { + + static MethodParameter PARAMETER; + + @Rule public final ExpectedException exception = ExpectedException.none(); + + @BeforeClass + public static void setUp() throws Exception { + PARAMETER = new MethodParameter(Controller.class.getMethod("supportedMethod", Sort.class), 0); + } + + @Test // DATACMNS-1211 + public void supportsSortParameter() { + + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + + assertThat(resolver.supportsParameter(getParameterOfMethod("supportedMethod"))).isTrue(); + } + + @Test // DATACMNS-1211 + public void returnsNullForNoDefault() { + assertSupportedAndResolvedTo(getParameterOfMethod("supportedMethod"), Sort.unsorted()); + } + + @Test // DATACMNS-1211 + public void discoversSimpleDefault() { + assertSupportedAndResolvedTo(getParameterOfMethod("simpleDefault"), Sort.by(SORT_FIELDS).ascending()); + } + + @Test // DATACMNS-1211 + public void discoversSimpleDefaultWithDirection() { + assertSupportedAndResolvedTo(getParameterOfMethod("simpleDefaultWithDirection"), SORT); + } + + @Test // DATACMNS-1211 + public void fallbackToGivenDefaultSort() { + + MethodParameter parameter = TestUtils.getParameterOfMethod(Controller.class, "unsupportedMethod", String.class); + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + Sort fallbackSort = Sort.by(Direction.ASC, "ID"); + resolver.setFallbackSort(fallbackSort); + + assertThat(resolve(resolver, TestUtils.getWebfluxRequest(), parameter)).isEqualTo(fallbackSort); + } + + @Test // DATACMNS-1211 + public void fallbackToDefaultDefaultSort() { + + MethodParameter parameter = TestUtils.getParameterOfMethod(Controller.class, "unsupportedMethod", String.class); + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + + assertThat(resolve(resolver, TestUtils.getWebfluxRequest(), parameter).isSorted()).isFalse(); + } + + @Test // DATACMNS-1211 + public void discoversSimpleSortFromRequest() { + + MethodParameter parameter = getParameterOfMethod("simpleDefault"); + Sort reference = Sort.by("bar", "foo"); + MockServerHttpRequest request = getRequestWithSort(reference); + + assertSupportedAndResolvedTo(request, parameter, reference); + } + + @Test // DATACMNS-1211 + public void discoversComplexSortFromRequest() { + + MethodParameter parameter = getParameterOfMethod("simpleDefault"); + Sort reference = Sort.by("bar", "foo").and(Sort.by("fizz", "buzz")); + + assertSupportedAndResolvedTo(getRequestWithSort(reference), parameter, reference); + } + + @Test // DATACMNS-1211 + public void discoversQualifiedSortFromRequest() { + + MethodParameter parameter = getParameterOfMethod("qualifiedSort"); + Sort reference = Sort.by("bar", "foo"); + + assertSupportedAndResolvedTo(getRequestWithSort(reference, "qual"), parameter, reference); + } + + @Test // DATACMNS-1211 + public void requestForMultipleSortPropertiesIsUnmarshalledCorrectly() { + + MockServerHttpRequest request = MockServerHttpRequest.get(String.format("foo?sort=%s", SortDefaultUnitTests.SORT_3)) + .build(); + + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + Sort result = resolve(resolver, request, PARAMETER); + + assertThat(result).isEqualTo(Sort.by(Direction.ASC, "firstname", "lastname")); + } + + @Test // DATACMNS-1211 + public void parsesEmptySortToNull() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=").build(); + + assertThat(resolve(request, PARAMETER).isSorted()).isFalse(); + } + + @Test // DATACMNS-1211 + public void sortParamIsInvalidProperty() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=,DESC").build(); + + assertThat(resolve(request, PARAMETER).isSorted()).isFalse(); + } + + @Test // DATACMNS-1211 + public void sortParamIsInvalidPropertyWhenMultiProperty() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=property1,,DESC").build(); + + assertThat(resolve(request, PARAMETER)).isEqualTo(Sort.by(DESC, "property1")); + } + + @Test // DATACMNS-1211 + public void rejectsDoubleAnnotatedMethod() { + + MethodParameter parameter = getParameterOfMethod("invalid"); + + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + assertThat(resolver.supportsParameter(parameter)).isTrue(); + + exception.expect(IllegalArgumentException.class); + exception.expectMessage(SortDefault.class.getSimpleName()); + exception.expectMessage(SortDefaults.class.getSimpleName()); + exception.expectMessage(parameter.toString()); + + resolver.resolveArgumentValue(parameter, null, + MockServerWebExchange.from(TestUtils.getWebfluxRequest())); + } + + @Test // DATACMNS-1211 + public void sortParamIsEmptyWhenMultiParams() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=property,DESC&sort=").build(); + + assertThat(resolve(request, PARAMETER)).isEqualTo(Sort.by(DESC, "property")); + } + + @Test // DATACMNS-1211 + public void parsesCommaParameterForSort() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=,").build(); + + assertThat(resolve(request, PARAMETER).isSorted()).isFalse(); + } + + @Test // DATACMNS-1211 + public void doesNotReturnNullWhenAnnotatedWithSortDefault() { + + MockServerHttpRequest request = MockServerHttpRequest.get("foo?sort=").build(); + + assertThat(resolve(request, getParameterOfMethod("simpleDefault"))).isEqualTo(Sort.by("firstname", "lastname")); + assertThat(resolve(request, getParameterOfMethod("containeredDefault"))).isEqualTo(Sort.by("foo", "bar")); + } + + private static Sort resolve(MockServerHttpRequest request, MethodParameter parameter) { + return resolve(new ReactiveSortHandlerMethodArgumentResolver(), request, parameter); + } + + private static Sort resolve(ReactiveSortHandlerMethodArgumentResolver resolver, MockServerHttpRequest request, + MethodParameter parameter) { + return resolver.resolveArgumentValue(parameter, null, MockServerWebExchange.from(request)); + } + + private static void assertSupportedAndResolvedTo(MethodParameter parameter, Sort sort) { + assertSupportedAndResolvedTo(TestUtils.getWebfluxRequest(), parameter, sort); + } + + private static void assertSupportedAndResolvedTo(MockServerHttpRequest request, MethodParameter parameter, + Sort sort) { + + ReactiveSortHandlerMethodArgumentResolver resolver = new ReactiveSortHandlerMethodArgumentResolver(); + assertThat(resolver.supportsParameter(parameter)).isTrue(); + + Sort resolved = resolve(resolver, request, parameter); + + assertThat(resolved).isEqualTo(sort); + } + + private static MockServerHttpRequest getRequestWithSort(Sort sort) { + return getRequestWithSort(sort, null); + } + + private static MockServerHttpRequest getRequestWithSort(@Nullable Sort sort, @Nullable String qualifier) { + + if (sort == null) { + return TestUtils.getWebfluxRequest(); + } + + StringBuilder queryString = new StringBuilder(); + for (Order order : sort) { + + String prefix = StringUtils.hasText(qualifier) ? qualifier + "_" : ""; + + if (queryString.length() != 0) { + queryString.append('&'); + } + queryString.append(String.format("%ssort=%s,%s", prefix, order.getProperty(), order.getDirection().name())); + } + + return MockServerHttpRequest.get(String.format("foo?%s", queryString.toString())).build(); + } + + private static MethodParameter getParameterOfMethod(String name) { + return TestUtils.getParameterOfMethod(Controller.class, name, Sort.class); + } + + interface Controller { + + void supportedMethod(Sort sort); + + void unsupportedMethod(String string); + + void qualifiedSort(@Qualifier("qual") Sort sort); + + void simpleDefault(@SortDefault({ "firstname", "lastname" }) Sort sort); + + void simpleDefaultWithDirection( + @SortDefault(sort = { "firstname", "lastname" }, direction = Direction.DESC) Sort sort); + + void containeredDefault(@SortDefaults(@SortDefault({ "foo", "bar" })) Sort sort); + + void invalid(@SortDefaults(@SortDefault({ "foo", "bar" })) @SortDefault({ "bar", "foo" }) Sort sort); + } +} diff --git a/src/test/java/org/springframework/data/web/SortDefaultUnitTests.java b/src/test/java/org/springframework/data/web/SortDefaultUnitTests.java index 566a752af..59fd365ef 100755 --- a/src/test/java/org/springframework/data/web/SortDefaultUnitTests.java +++ b/src/test/java/org/springframework/data/web/SortDefaultUnitTests.java @@ -17,6 +17,8 @@ package org.springframework.data.web; import static org.assertj.core.api.Assertions.*; +import java.util.Arrays; + import org.junit.Test; import org.springframework.core.MethodParameter; @@ -57,7 +59,7 @@ public abstract class SortDefaultUnitTests { private static void assertSortStringParsedInto(Sort expected, String... source) { SortHandlerMethodArgumentResolver resolver = new SortHandlerMethodArgumentResolver(); - Sort sort = resolver.parseParameterIntoSort(source, ","); + Sort sort = resolver.parseParameterIntoSort(Arrays.asList(source), ","); assertThat(sort).isEqualTo(expected); } diff --git a/src/test/java/org/springframework/data/web/TestUtils.java b/src/test/java/org/springframework/data/web/TestUtils.java index 22e378eed..56e3f25aa 100644 --- a/src/test/java/org/springframework/data/web/TestUtils.java +++ b/src/test/java/org/springframework/data/web/TestUtils.java @@ -18,6 +18,7 @@ package org.springframework.data.web; import java.lang.reflect.Method; import org.springframework.core.MethodParameter; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; @@ -27,6 +28,7 @@ import org.springframework.web.context.request.ServletWebRequest; * * @since 1.6 * @author Oliver Gierke + * @author Mark Paluch */ class TestUtils { @@ -34,6 +36,10 @@ class TestUtils { return new ServletWebRequest(new MockHttpServletRequest()); } + public static MockServerHttpRequest getWebfluxRequest() { + return MockServerHttpRequest.get("foo").build(); + } + public static MethodParameter getParameterOfMethod(Class controller, String name, Class... argumentTypes) { Method method = getMethod(controller, name, argumentTypes);