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;
|
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.apiresponse.Builder.responseBuilder;
|
||||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
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.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 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 io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springdoc.core.fn.builders.schema.Builder;
|
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.http.codec.multipart.Part;
|
import org.springframework.http.codec.multipart.Part;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.MultiValueMap;
|
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.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.app.core.extension.Plugin;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
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.PluginProperties;
|
||||||
import run.halo.app.plugin.YamlPluginFinder;
|
import run.halo.app.plugin.YamlPluginFinder;
|
||||||
|
|
||||||
|
@ -54,13 +67,115 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
.required(true)
|
.required(true)
|
||||||
.content(contentBuilder()
|
.content(contentBuilder()
|
||||||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
.schema(Builder.schemaBuilder().implementation(InstallRequest.class))
|
.schema(schemaBuilder().implementation(InstallRequest.class))
|
||||||
))
|
))
|
||||||
.response(responseBuilder().implementation(Plugin.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();
|
.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(
|
public record InstallRequest(
|
||||||
@Schema(required = true, description = "Plugin Jar file.") FilePart file) {
|
@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())
|
.load(ListResult.class.getClassLoader())
|
||||||
.getLoaded();
|
.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