improve pagination configuration

This commit is contained in:
Jake Potrebic 2022-11-06 19:44:16 -08:00
parent 1ec34b350b
commit b6c34e616d
No known key found for this signature in database
GPG Key ID: ECE0B3C133C016C5
6 changed files with 114 additions and 61 deletions

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.controller.api.v1;
import io.papermc.hangar.controller.api.v1.interfaces.IVersionsController;
import io.papermc.hangar.controller.extras.pagination.annotations.ApplicableFilters;
import io.papermc.hangar.controller.extras.pagination.annotations.ConfigurePagination;
import io.papermc.hangar.controller.extras.pagination.filters.versions.VersionChannelFilter;
import io.papermc.hangar.controller.extras.pagination.filters.versions.VersionPlatformFilter;
import io.papermc.hangar.controller.extras.pagination.filters.versions.VersionTagFilter;
@ -51,7 +52,7 @@ public class VersionsController implements IVersionsController {
@Override
@VisibilityRequired(type = Type.PROJECT, args = "{#author, #slug}")
@ApplicableFilters({VersionChannelFilter.class, VersionPlatformFilter.class, VersionTagFilter.class})
public PaginatedResult<Version> getVersions(String author, String slug, @NotNull RequestPagination pagination) {
public PaginatedResult<Version> getVersions(String author, String slug, @NotNull @ConfigurePagination(defaultLimitString = "@hangarConfig.projects.initVersionLoad", maxLimit = 25) RequestPagination pagination) {
return versionsApiService.getVersions(author, slug, pagination);
}

View File

@ -1,28 +1,35 @@
package io.papermc.hangar.controller.extras;
import io.papermc.hangar.config.hangar.HangarConfig;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.util.StaticContextAccessor;
import org.jetbrains.annotations.Nullable;
import java.util.function.Function;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.framework.qual.DefaultQualifier;
import org.springframework.http.HttpStatus;
import org.springframework.web.context.request.NativeWebRequest;
public class ApiUtils {
@DefaultQualifier(NonNull.class)
public final class ApiUtils {
private ApiUtils() { }
public static final int DEFAULT_MAX_LIMIT = 50;
public static final int DEFAULT_LIMIT = 25;
private static final HangarConfig hangarConfig = StaticContextAccessor.getBean(HangarConfig.class);
private ApiUtils() {
}
/**
* Gets the pagination limit or the max configured
*
* @param limit requested limit
* @return actual limit
*/
public static long limitOrDefault(@Nullable Long limit) {
return limitOrDefault(limit, hangarConfig.projects.initLoad());
public static long limitOrDefault(@Nullable final Long limit) {
return limitOrDefault(limit, DEFAULT_LIMIT);
}
public static long limitOrDefault(@Nullable Long limit, long maxLimit) {
if (limit != null && limit < 1) throw new HangarApiException(HttpStatus.BAD_REQUEST, "Limit should be greater than 0");
public static long limitOrDefault(@Nullable final Long limit, final long maxLimit) {
if (limit != null && limit < 1)
throw new HangarApiException(HttpStatus.BAD_REQUEST, "Limit should be greater than 0");
return Math.min(limit == null ? maxLimit : limit, maxLimit);
}
@ -32,8 +39,16 @@ public class ApiUtils {
* @param offset the requested offset
* @return actual offset
*/
public static long offsetOrZero(Long offset) {
public static long offsetOrZero(final @Nullable Long offset) {
return Math.max(offset == null ? 0 : offset, 0);
}
public static <T> @Nullable T mapParameter(final NativeWebRequest webRequest, final String param, Function<String, T> map) {
final @Nullable String value = webRequest.getParameter(param);
if (value != null) {
return map.apply(value);
}
return null;
}
}

View File

@ -1,13 +1,11 @@
package io.papermc.hangar.controller.extras.pagination;
import io.papermc.hangar.controller.extras.pagination.Filter.FilterInstance;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
@SuppressWarnings("unchecked")
@ -16,9 +14,9 @@ public class FilterRegistry {
private final Map<Class<? extends Filter<?>>, Filter<?>> filters = new HashMap<>();
@Autowired
public FilterRegistry(List<? extends Filter<? extends FilterInstance>> filters) {
public FilterRegistry(final List<? extends Filter<? extends Filter.FilterInstance>> filters) {
filters.forEach(f -> {
var filterClass = (Class<? extends Filter<? extends FilterInstance>>) f.getClass();
final Class<? extends Filter<?>> filterClass = (Class<? extends Filter<? extends Filter.FilterInstance>>) f.getClass();
if (this.filters.containsKey((filterClass))) {
throw new IllegalArgumentException(filterClass + " is already registered as filter");
}
@ -27,9 +25,9 @@ public class FilterRegistry {
}
@NotNull
public <T extends Filter<? extends FilterInstance>> T get(Class<T> filterClass) {
if (filters.containsKey(filterClass)) {
return (T) filters.get(filterClass);
public <T extends Filter<? extends Filter.FilterInstance>> T get(final Class<T> filterClass) {
if (this.filters.containsKey(filterClass)) {
return (T) this.filters.get(filterClass);
}
throw new IllegalArgumentException(filterClass + " is not a registered filter");
}

View File

@ -5,6 +5,7 @@ import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.intellij.lang.annotations.Language;
/**
* Configure default page length
@ -14,10 +15,13 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
public @interface ConfigurePagination {
/**
* -1 means fallback to default configured value
*/
long maxLimit();
long maxLimit() default -1;
// TODO add String SpEL param to use configurable values for action log amounts and version amounts
@Language("SpEL")
String maxLimitString() default ""; // TODO implement
long defaultLimit() default -1;
@Language("SpEL")
String defaultLimitString() default "";
}

View File

@ -2,7 +2,6 @@ package io.papermc.hangar.controller.extras.resolvers;
import io.papermc.hangar.controller.extras.ApiUtils;
import io.papermc.hangar.controller.extras.pagination.Filter;
import io.papermc.hangar.controller.extras.pagination.Filter.FilterInstance;
import io.papermc.hangar.controller.extras.pagination.FilterRegistry;
import io.papermc.hangar.controller.extras.pagination.SorterRegistry;
import io.papermc.hangar.controller.extras.pagination.annotations.ApplicableFilters;
@ -10,62 +9,98 @@ import io.papermc.hangar.controller.extras.pagination.annotations.ApplicableSort
import io.papermc.hangar.controller.extras.pagination.annotations.ConfigurePagination;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.api.requests.RequestPagination;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.MethodParameter;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@Component
public class RequestPaginationResolver implements HandlerMethodArgumentResolver {
private static final SpelExpressionParser EXPRESSION_PARSER = new SpelExpressionParser();
private final FilterRegistry filterRegistry;
private final StandardEvaluationContext evaluationContext;
// need lazy here to avoid circular dep issue
@Autowired
public RequestPaginationResolver(@Lazy FilterRegistry filterRegistry) {
public RequestPaginationResolver(@Lazy final FilterRegistry filterRegistry, final StandardEvaluationContext evaluationContext) {
this.filterRegistry = filterRegistry;
this.evaluationContext = evaluationContext;
}
@Override
public boolean supportsParameter(@NotNull MethodParameter parameter) {
public boolean supportsParameter(@NotNull final MethodParameter parameter) {
return RequestPagination.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public RequestPagination resolveArgument(@NotNull MethodParameter parameter, ModelAndViewContainer mavContainer, @NotNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
long offset = ApiUtils.offsetOrZero(Optional.ofNullable(webRequest.getParameter("offset")).map(Long::parseLong).orElse(null));
long limit;
Optional<Long> limitParam = Optional.ofNullable(webRequest.getParameter("limit")).map(Long::parseLong);
ConfigurePagination settings = parameter.getParameterAnnotation(ConfigurePagination.class);
if (settings != null) {
limit = ApiUtils.limitOrDefault(limitParam.orElse(null), settings.maxLimit());
} else {
limit = ApiUtils.limitOrDefault(limitParam.orElse(null));
private RequestPagination create(final @Nullable Long requestOffset, final @Nullable Long requestLimit, final @Nullable ConfigurePagination settings) {
final long offset = ApiUtils.offsetOrZero(requestOffset);
if (settings == null) {
return new RequestPagination(ApiUtils.limitOrDefault(requestLimit), offset);
}
RequestPagination pagination = new RequestPagination(limit, offset);
final long maxLimit;
if (settings.maxLimit() != -1) {
maxLimit = settings.maxLimit();
} else if (!settings.maxLimitString().isBlank()) {
final Expression expression = EXPRESSION_PARSER.parseExpression(settings.maxLimitString());
maxLimit = Objects.requireNonNull(expression.getValue(this.evaluationContext, requestLimit, Long.class), "SpEL must evaluate to a long");
} else {
maxLimit = ApiUtils.DEFAULT_MAX_LIMIT;
}
final long limit;
if (requestLimit == null) {
final long defaultLimit;
if (settings.defaultLimit() != -1) {
defaultLimit = settings.defaultLimit();
} else if (!settings.defaultLimitString().isBlank()) {
final Expression expression = EXPRESSION_PARSER.parseExpression(settings.defaultLimitString());
defaultLimit = Objects.requireNonNull(expression.getValue(this.evaluationContext, requestLimit, Long.class), "SpEL must evaluate to a long");
} else {
defaultLimit = ApiUtils.DEFAULT_LIMIT;
}
limit = defaultLimit;
} else {
limit = requestLimit;
}
return new RequestPagination(Math.min(maxLimit, limit), offset);
}
@Override
public RequestPagination resolveArgument(@NotNull final MethodParameter parameter, final ModelAndViewContainer mavContainer, @NotNull final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final RequestPagination pagination = this.create(
ApiUtils.mapParameter(webRequest, "offset", Long::parseLong),
ApiUtils.mapParameter(webRequest, "limit", Long::parseLong),
parameter.getParameterAnnotation(ConfigurePagination.class)
);
// find filters
Set<String> paramNames = new HashSet<>(webRequest.getParameterMap().keySet());
Class<? extends Filter<? extends FilterInstance>>[] applicableFilters = Optional.ofNullable(parameter.getMethodAnnotation(ApplicableFilters.class)).map(ApplicableFilters::value).orElse(null);
final Set<String> paramNames = new HashSet<>(webRequest.getParameterMap().keySet());
final Class<? extends Filter<? extends Filter.FilterInstance>>[] applicableFilters = Optional.ofNullable(parameter.getMethodAnnotation(ApplicableFilters.class)).map(ApplicableFilters::value).orElse(null);
if (applicableFilters != null) {
for (Class<? extends Filter<? extends FilterInstance>> filter : applicableFilters) {
Filter<? extends FilterInstance> f = filterRegistry.get(filter);
for (final Class<? extends Filter<? extends Filter.FilterInstance>> filterClass : applicableFilters) {
final Filter<? extends Filter.FilterInstance> f = this.filterRegistry.get(filterClass);
if (f.supports(webRequest)) {
pagination.getFilters().add(f.create(webRequest));
paramNames.removeAll(f.getQueryParamNames());
@ -81,7 +116,7 @@ public class RequestPaginationResolver implements HandlerMethodArgumentResolver
paramNames.remove("relevance");
// remove request params
for (Parameter param : parameter.getExecutable().getParameters()) {
for (final Parameter param : parameter.getExecutable().getParameters()) {
paramNames.remove(param.getName());
}
@ -91,10 +126,10 @@ public class RequestPaginationResolver implements HandlerMethodArgumentResolver
}
// find sorters
Set<String> applicableSorters = Optional.ofNullable(parameter.getMethodAnnotation(ApplicableSorters.class)).map(ApplicableSorters::value).map(sorters -> Stream.of(sorters).map(SorterRegistry::getName).collect(Collectors.toUnmodifiableSet())).orElse(Collections.emptySet());
List<String> presentSorters = Optional.ofNullable(webRequest.getParameterValues("sort")).map(Arrays::asList).orElse(new ArrayList<>());
for (String sorter : presentSorters) {
String sortKey = sorter.startsWith("-") ? sorter.substring(1) : sorter;
final Set<String> applicableSorters = Optional.ofNullable(parameter.getMethodAnnotation(ApplicableSorters.class)).map(ApplicableSorters::value).map(sorters -> Stream.of(sorters).map(SorterRegistry::getName).collect(Collectors.toUnmodifiableSet())).orElse(Collections.emptySet());
final List<String> presentSorters = Optional.ofNullable(webRequest.getParameterValues("sort")).map(Arrays::asList).orElse(new ArrayList<>());
for (final String sorter : presentSorters) {
final String sortKey = sorter.startsWith("-") ? sorter.substring(1) : sorter;
if (!applicableSorters.contains(sortKey)) {
throw new HangarApiException(sortKey + " is an invalid sort type for this request");
}

View File

@ -124,7 +124,7 @@ public class AdminController extends HangarComponent {
@PermissionRequired(NamedPermission.REVIEWER)
@ApplicableFilters({LogActionFilter.class, LogPageFilter.class, LogProjectFilter.class, LogSubjectFilter.class, LogUserFilter.class, LogVersionFilter.class})
// TODO add sorters
public PaginatedResult<HangarLoggedAction> getActionLog(@NotNull @ConfigurePagination(maxLimit = 50) RequestPagination pagination) {
public PaginatedResult<HangarLoggedAction> getActionLog(@NotNull @ConfigurePagination(defaultLimit = 50, maxLimit = 100) RequestPagination pagination) {
return actionLogger.getLogs(pagination);
}