mirror of https://github.com/halo-dev/halo
Support filtering and sorting plugins (#2489)
#### What type of PR is this? /kind feature /area core /milestone 2.0 #### What this PR does / why we need it: Support filtering and sorting plugins. e.g.: ```bash http://localhost:8090/api/api.console.halo.run/v1alpha1/plugins?keyword=xyz&enabled=true&sort=creationTimestamp,desc ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2470 #### Does this PR introduce a user-facing change? ```release-note None ```pull/2495/head
parent
98829f0a3e
commit
e8d00e56f4
|
@ -1,31 +1,44 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static java.util.Comparator.comparing;
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
||||
import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||
import static run.halo.app.extension.ListResult.generateGenericClass;
|
||||
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.http.codec.multipart.Part;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.router.IListRequest.QueryListRequest;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
import run.halo.app.plugin.YamlPluginFinder;
|
||||
|
||||
|
@ -54,13 +67,115 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
|
||||
.schema(schemaBuilder().implementation(InstallRequest.class))
|
||||
))
|
||||
.response(responseBuilder().implementation(Plugin.class))
|
||||
)
|
||||
.GET("plugins", this::list, builder -> {
|
||||
builder.operationId("ListPlugins")
|
||||
.tag(tag)
|
||||
.description("List plugins using query criteria and sort params")
|
||||
.response(responseBuilder().implementation(generateGenericClass(Plugin.class)));
|
||||
buildParametersFromType(builder, ListRequest.class);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
public static class ListRequest extends QueryListRequest {
|
||||
|
||||
private final ServerWebExchange exchange;
|
||||
|
||||
public ListRequest(ServerRequest request) {
|
||||
super(request.queryParams());
|
||||
this.exchange = request.exchange();
|
||||
}
|
||||
|
||||
@Schema(name = "keyword", description = "Keyword of plugin name or description")
|
||||
public String getKeyword() {
|
||||
return queryParams.getFirst("keyword");
|
||||
}
|
||||
|
||||
@Schema(name = "enabled", description = "Whether the plugin is enabled")
|
||||
public Boolean getEnabled() {
|
||||
var enabled = queryParams.getFirst("enabled");
|
||||
return enabled == null ? null : getSharedInstance().convert(enabled, Boolean.class);
|
||||
}
|
||||
|
||||
@ArraySchema(uniqueItems = true,
|
||||
arraySchema = @Schema(name = "sort",
|
||||
description = "Sort property and direction of the list result. Supported fields: "
|
||||
+ "creationTimestamp"),
|
||||
schema = @Schema(description = "like field,asc or field,desc",
|
||||
implementation = String.class,
|
||||
example = "creationtimestamp,desc"))
|
||||
public Sort getSort() {
|
||||
return SortResolver.defaultInstance.resolve(exchange);
|
||||
}
|
||||
|
||||
public Predicate<Plugin> toPredicate() {
|
||||
Predicate<Plugin> displayNamePredicate = plugin -> {
|
||||
var keyword = getKeyword();
|
||||
if (!StringUtils.hasText(keyword)) {
|
||||
return true;
|
||||
}
|
||||
var displayName = plugin.getSpec().getDisplayName();
|
||||
if (!StringUtils.hasText(displayName)) {
|
||||
return false;
|
||||
}
|
||||
return displayName.toLowerCase().contains(keyword.trim().toLowerCase());
|
||||
};
|
||||
Predicate<Plugin> descriptionPredicate = plugin -> {
|
||||
var keyword = getKeyword();
|
||||
if (!StringUtils.hasText(keyword)) {
|
||||
return true;
|
||||
}
|
||||
var description = plugin.getSpec().getDescription();
|
||||
if (!StringUtils.hasText(description)) {
|
||||
return false;
|
||||
}
|
||||
return description.toLowerCase().contains(keyword.trim().toLowerCase());
|
||||
};
|
||||
Predicate<Plugin> enablePredicate = plugin -> {
|
||||
var enabled = getEnabled();
|
||||
if (enabled == null) {
|
||||
return true;
|
||||
}
|
||||
return Objects.equals(enabled, plugin.getSpec().getEnabled());
|
||||
};
|
||||
return displayNamePredicate.or(descriptionPredicate)
|
||||
.and(enablePredicate)
|
||||
.and(labelAndFieldSelectorToPredicate(getLabelSelector(), getFieldSelector()));
|
||||
}
|
||||
|
||||
public Comparator<Plugin> toComparator() {
|
||||
var sort = getSort();
|
||||
var ctOrder = sort.getOrderFor("creationTimestamp");
|
||||
Comparator<Plugin> comparator = null;
|
||||
if (ctOrder != null) {
|
||||
comparator = comparing(plugin -> plugin.getMetadata().getCreationTimestamp());
|
||||
if (ctOrder.isDescending()) {
|
||||
comparator = comparator.reversed();
|
||||
}
|
||||
}
|
||||
return comparator;
|
||||
}
|
||||
}
|
||||
|
||||
Mono<ServerResponse> list(ServerRequest request) {
|
||||
return Mono.just(request)
|
||||
.map(ListRequest::new)
|
||||
.flatMap(listRequest -> {
|
||||
var predicate = listRequest.toPredicate();
|
||||
var comparator = listRequest.toComparator();
|
||||
return client.list(Plugin.class,
|
||||
predicate,
|
||||
comparator,
|
||||
listRequest.getPage(),
|
||||
listRequest.getSize());
|
||||
})
|
||||
.flatMap(listResult -> ServerResponse.ok().bodyValue(listResult));
|
||||
}
|
||||
|
||||
public record InstallRequest(
|
||||
@Schema(required = true, description = "Plugin Jar file.") FilePart file) {
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.ReactiveSortHandlerMethodArgumentResolver;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
public interface SortResolver {
|
||||
|
||||
SortResolver defaultInstance = new DefaultSortResolver();
|
||||
|
||||
@NonNull
|
||||
Sort resolve(@NonNull ServerWebExchange exchange);
|
||||
|
||||
class DefaultSortResolver extends ReactiveSortHandlerMethodArgumentResolver
|
||||
implements SortResolver {
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
protected Sort getDefaultFromAnnotationOrFallback(@Nullable MethodParameter parameter) {
|
||||
return Sort.unsorted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sort resolve(ServerWebExchange exchange) {
|
||||
return resolveArgumentValue(null, null, exchange);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -124,4 +124,8 @@ public class ListResult<T> implements Streamable<T> {
|
|||
.load(ListResult.class.getClassLoader())
|
||||
.getLoaded();
|
||||
}
|
||||
|
||||
public static <T> ListResult<T> emptyResult() {
|
||||
return new ListResult<>(List.of());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PluginEndpointTest {
|
||||
|
||||
@Mock
|
||||
PluginProperties pluginProperties;
|
||||
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
@InjectMocks
|
||||
PluginEndpoint endpoint;
|
||||
|
||||
@Test
|
||||
void shouldListEmptyPluginsWhenNoPlugins() {
|
||||
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(ListResult.emptyResult()));
|
||||
|
||||
bindToRouterFunction(endpoint.endpoint())
|
||||
.build()
|
||||
.get().uri("/plugins")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.items.length()").isEqualTo(0)
|
||||
.jsonPath("$.total").isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldListPluginsWhenPluginPresent() {
|
||||
var plugins = List.of(
|
||||
createPlugin("fake-plugin-1"),
|
||||
createPlugin("fake-plugin-2"),
|
||||
createPlugin("fake-plugin-3")
|
||||
);
|
||||
var expectResult = new ListResult<>(plugins);
|
||||
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(expectResult));
|
||||
|
||||
bindToRouterFunction(endpoint.endpoint())
|
||||
.build()
|
||||
.get().uri("/plugins")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.items.length()").isEqualTo(3)
|
||||
.jsonPath("$.total").isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFilterPluginsWhenKeywordProvided() {
|
||||
var expectPlugin =
|
||||
createPlugin("fake-plugin-2", "expected display name", "", false);
|
||||
var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false);
|
||||
var unexpectedPlugin2 =
|
||||
createPlugin("fake-plugin-3", "second fake display name", "", false);
|
||||
var plugins = List.of(
|
||||
expectPlugin
|
||||
);
|
||||
var expectResult = new ListResult<>(plugins);
|
||||
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(expectResult));
|
||||
|
||||
bindToRouterFunction(endpoint.endpoint())
|
||||
.build()
|
||||
.get().uri("/plugins?keyword=Expected")
|
||||
.exchange()
|
||||
.expectStatus().isOk();
|
||||
|
||||
verify(client).list(same(Plugin.class), argThat(
|
||||
predicate -> predicate.test(expectPlugin)
|
||||
&& !predicate.test(unexpectedPlugin1)
|
||||
&& !predicate.test(unexpectedPlugin2)),
|
||||
any(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFilterPluginsWhenEnabledProvided() {
|
||||
var expectPlugin =
|
||||
createPlugin("fake-plugin-2", "expected display name", "", true);
|
||||
var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false);
|
||||
var unexpectedPlugin2 =
|
||||
createPlugin("fake-plugin-3", "second fake display name", "", false);
|
||||
var plugins = List.of(
|
||||
expectPlugin
|
||||
);
|
||||
var expectResult = new ListResult<>(plugins);
|
||||
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(expectResult));
|
||||
|
||||
bindToRouterFunction(endpoint.endpoint())
|
||||
.build()
|
||||
.get().uri("/plugins?enabled=true")
|
||||
.exchange()
|
||||
.expectStatus().isOk();
|
||||
|
||||
verify(client).list(same(Plugin.class), argThat(
|
||||
predicate -> predicate.test(expectPlugin)
|
||||
&& !predicate.test(unexpectedPlugin1)
|
||||
&& !predicate.test(unexpectedPlugin2)),
|
||||
any(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSortPluginsWhenCreationTimestampSet() {
|
||||
var expectPlugin =
|
||||
createPlugin("fake-plugin-2", "expected display name", "", true);
|
||||
var unexpectedPlugin1 = createPlugin("fake-plugin-1", "first fake display name", "", false);
|
||||
var unexpectedPlugin2 =
|
||||
createPlugin("fake-plugin-3", "second fake display name", "", false);
|
||||
var expectResult = new ListResult<>(List.of(expectPlugin));
|
||||
when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(expectResult));
|
||||
|
||||
bindToRouterFunction(endpoint.endpoint())
|
||||
.build()
|
||||
.get().uri("/plugins?sort=creationTimestamp,desc")
|
||||
.exchange()
|
||||
.expectStatus().isOk();
|
||||
|
||||
verify(client).list(same(Plugin.class), any(), argThat(comparator -> {
|
||||
var now = Instant.now();
|
||||
var plugins = new ArrayList<>(List.of(
|
||||
createPlugin("fake-plugin-a", now),
|
||||
createPlugin("fake-plugin-b", now.plusSeconds(1)),
|
||||
createPlugin("fake-plugin-c", now.plusSeconds(2))
|
||||
));
|
||||
plugins.sort(comparator);
|
||||
return Objects.deepEquals(plugins, List.of(
|
||||
createPlugin("fake-plugin-c", now.plusSeconds(2)),
|
||||
createPlugin("fake-plugin-b", now.plusSeconds(1)),
|
||||
createPlugin("fake-plugin-a", now)
|
||||
));
|
||||
}), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
Plugin createPlugin(String name) {
|
||||
return createPlugin(name, "fake display name", "fake description", null);
|
||||
}
|
||||
|
||||
Plugin createPlugin(String name, String displayName, String description, Boolean enabled) {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
metadata.setCreationTimestamp(Instant.now());
|
||||
var spec = new Plugin.PluginSpec();
|
||||
spec.setDisplayName(displayName);
|
||||
spec.setDescription(description);
|
||||
spec.setEnabled(enabled);
|
||||
var plugin = new Plugin();
|
||||
plugin.setMetadata(metadata);
|
||||
plugin.setSpec(spec);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
Plugin createPlugin(String name, Instant creationTimestamp) {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
metadata.setCreationTimestamp(creationTimestamp);
|
||||
var spec = new Plugin.PluginSpec();
|
||||
var plugin = new Plugin();
|
||||
plugin.setMetadata(metadata);
|
||||
plugin.setSpec(spec);
|
||||
return plugin;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue