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
John Niang 2022-09-30 09:30:19 +08:00 committed by GitHub
parent 98829f0a3e
commit e8d00e56f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 342 additions and 2 deletions

View File

@ -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) {
} }

View 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);
}
}
}

View File

@ -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());
}
} }

View File

@ -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;
}
}