diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java index 4891316df..5bbe4f7a9 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -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 toPredicate() { + Predicate 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 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 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 toComparator() { + var sort = getSort(); + var ctOrder = sort.getOrderFor("creationTimestamp"); + Comparator comparator = null; + if (ctOrder != null) { + comparator = comparing(plugin -> plugin.getMetadata().getCreationTimestamp()); + if (ctOrder.isDescending()) { + comparator = comparator.reversed(); + } + } + return comparator; + } + } + + Mono 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) { } diff --git a/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java b/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java new file mode 100644 index 000000000..9b9b9e689 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java @@ -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); + } + } +} diff --git a/src/main/java/run/halo/app/extension/ListResult.java b/src/main/java/run/halo/app/extension/ListResult.java index cf2c04b1c..06ae08a7c 100644 --- a/src/main/java/run/halo/app/extension/ListResult.java +++ b/src/main/java/run/halo/app/extension/ListResult.java @@ -124,4 +124,8 @@ public class ListResult implements Streamable { .load(ListResult.class.getClassLoader()) .getLoaded(); } + + public static ListResult emptyResult() { + return new ListResult<>(List.of()); + } } diff --git a/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java new file mode 100644 index 000000000..fe39ef1b3 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java @@ -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; + } +}