diff --git a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java index 710f28172..5e8a7df94 100644 --- a/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java +++ b/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java @@ -11,7 +11,7 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.attachment.Attachment; /** - * AttachmentService + * AttachmentService. * * @author johnniang * @since 2.5.0 diff --git a/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java new file mode 100644 index 000000000..7a294939d --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java @@ -0,0 +1,143 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.theme.endpoint.PublicApiUtils.containsElement; +import static run.halo.app.theme.endpoint.PublicApiUtils.toAnotherListResult; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.util.function.Predicate; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Category; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.vo.CategoryVo; +import run.halo.app.theme.finders.vo.ListedPostVo; + +/** + * Endpoint for category query APIs. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class CategoryQueryEndpoint implements CustomEndpoint { + + private final ReactiveExtensionClient client; + private final PostPublicQueryService postPublicQueryService; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Category"; + return SpringdocRouteBuilder.route() + .GET("categories", this::listCategories, + builder -> { + builder.operationId("queryCategories") + .description("Lists categories.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(CategoryVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, CategoryPublicQuery.class); + } + ) + .GET("categories/{name}", this::getByName, + builder -> builder.operationId("queryCategoryByName") + .description("Gets category by name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Category name") + .required(true) + ) + .response(responseBuilder() + .implementation(CategoryVo.class) + ) + ) + .GET("categories/{name}/posts", this::listPostsByCategoryName, + builder -> { + builder.operationId("queryPostsByCategoryName") + .description("Lists posts by category name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Category name") + .required(true) + ) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(ListedPostVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, PostPublicQuery.class); + } + ) + .build(); + } + + private Mono listPostsByCategoryName(ServerRequest request) { + final var name = request.pathVariable("name"); + final var query = new PostPublicQuery(request.exchange()); + Predicate categoryContainsPredicate = + post -> containsElement(post.getSpec().getCategories(), name); + return postPublicQueryService.list(query.getPage(), + query.getSize(), + categoryContainsPredicate.and(query.toPredicate()), + query.toComparator() + ) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + private Mono getByName(ServerRequest request) { + String name = request.pathVariable("name"); + return client.get(Category.class, name) + .map(CategoryVo::from) + .flatMap(categoryVo -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(categoryVo) + ); + } + + private Mono listCategories(ServerRequest request) { + CategoryPublicQuery query = new CategoryPublicQuery(request.exchange()); + return client.list(Category.class, + query.toPredicate(), + query.toComparator(), + query.getPage(), + query.getSize() + ) + .map(listResult -> toAnotherListResult(listResult, CategoryVo::from)) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + public static class CategoryPublicQuery extends SortableRequest { + public CategoryPublicQuery(ServerWebExchange exchange) { + super(exchange); + } + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new Category()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java new file mode 100644 index 000000000..1cd663a89 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java @@ -0,0 +1,92 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Menu; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.theme.finders.MenuFinder; +import run.halo.app.theme.finders.vo.MenuVo; + +/** + * Endpoint for menu query APIs. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class MenuQueryEndpoint implements CustomEndpoint { + + private final MenuFinder menuFinder; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Menu"; + return SpringdocRouteBuilder.route() + .GET("menus/-", this::getByName, + builder -> builder.operationId("queryPrimaryMenu") + .description("Gets primary menu.") + .tag(tag) + .response(responseBuilder() + .implementation(MenuVo.class) + ) + ) + .GET("menus/{name}", this::getByName, + builder -> builder.operationId("queryMenuByName") + .description("Gets menu by name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Menu name") + .required(true) + ) + .response(responseBuilder() + .implementation(MenuVo.class) + ) + ) + .build(); + } + + private Mono getByName(ServerRequest request) { + return determineMenuName(request) + .flatMap(menuFinder::getByName) + .flatMap(menuVo -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(menuVo) + ); + } + + private Mono determineMenuName(ServerRequest request) { + String name = request.pathVariables().getOrDefault("name", "-"); + if (!"-".equals(name)) { + return Mono.just(name); + } + // If name is "-", then get primary menu. + return environmentFetcher.fetch(SystemSetting.Menu.GROUP, SystemSetting.Menu.class) + .mapNotNull(SystemSetting.Menu::getPrimary) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Primary menu is not configured.")) + ); + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new Menu()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java new file mode 100644 index 000000000..7b0eee600 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java @@ -0,0 +1,62 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.Plugin; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.theme.finders.PluginFinder; + +/** + * Endpoint for plugin query APIs. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class PluginQueryEndpoint implements CustomEndpoint { + + private final PluginFinder pluginFinder; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Plugin"; + return SpringdocRouteBuilder.route() + .GET("plugins/{name}/available", this::availableByName, + builder -> builder.operationId("queryPluginAvailableByName") + .description("Gets plugin available by name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Plugin name") + .required(true) + ) + .response(responseBuilder() + .implementation(Boolean.class) + ) + ) + .build(); + } + + private Mono availableByName(ServerRequest request) { + String name = request.pathVariable("name"); + boolean available = pluginFinder.available(name); + return ServerResponse.ok().bodyValue(available); + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new Plugin()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java b/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java new file mode 100644 index 000000000..c6c518143 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java @@ -0,0 +1,16 @@ +package run.halo.app.theme.endpoint; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Query parameters for post public APIs. + * + * @author guqing + * @since 2.5.0 + */ +public class PostPublicQuery extends SortableRequest { + + public PostPublicQuery(ServerWebExchange exchange) { + super(exchange); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java new file mode 100644 index 000000000..632ef1c50 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java @@ -0,0 +1,121 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.infra.exception.NotFoundException; +import run.halo.app.theme.finders.PostFinder; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.vo.ListedPostVo; +import run.halo.app.theme.finders.vo.NavigationPostVo; +import run.halo.app.theme.finders.vo.PostVo; + +/** + * Endpoint for post query. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class PostQueryEndpoint implements CustomEndpoint { + + private final PostFinder postFinder; + private final PostPublicQueryService postPublicQueryService; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Post"; + return SpringdocRouteBuilder.route() + .GET("posts", this::listPosts, + builder -> { + builder.operationId("queryPosts") + .description("Lists posts.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(ListedPostVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, PostPublicQuery.class); + } + ) + .GET("posts/{name}", this::getPostByName, + builder -> builder.operationId("queryPostByName") + .description("Gets a post by name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Post name") + .required(true) + ) + .response(responseBuilder() + .implementation(PostVo.class) + ) + ) + .GET("posts/{name}/navigation", this::getPostNavigationByName, + builder -> builder.operationId("queryPostNavigationByName") + .description("Gets a post navigation by name.") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Post name") + .required(true) + ) + .response(responseBuilder() + .implementation(NavigationPostVo.class) + ) + ) + .build(); + } + + private Mono getPostNavigationByName(ServerRequest request) { + final var name = request.pathVariable("name"); + return postFinder.cursor(name) + .doOnNext(result -> { + if (result.getCurrent() == null) { + throw new NotFoundException("Post not found"); + } + }) + .flatMap(result -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + private Mono getPostByName(ServerRequest request) { + final var name = request.pathVariable("name"); + return postFinder.getByName(name) + .switchIfEmpty(Mono.error(() -> new NotFoundException("Post not found"))) + .flatMap(post -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) + .bodyValue(post) + ); + } + + private Mono listPosts(ServerRequest request) { + PostPublicQuery query = new PostPublicQuery(request.exchange()); + return postPublicQueryService.list(query.getPage(), query.getSize(), query.toPredicate(), + query.toComparator()) + .flatMap(result -> ServerResponse.ok().contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new Post()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java new file mode 100644 index 000000000..142b72055 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java @@ -0,0 +1,70 @@ +package run.halo.app.theme.endpoint; + +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import run.halo.app.extension.Extension; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.ListResult; + +/** + * Utility class for public api. + * + * @author guqing + * @since 2.5.0 + */ +@UtilityClass +public class PublicApiUtils { + + /** + * Get group version from extension for public api. + * + * @param extension extension + * @return api.{group}/{version} if group is not empty, + * otherwise api.halo.run/{version}. + */ + public static GroupVersion groupVersion(Extension extension) { + GroupVersionKind groupVersionKind = extension.groupVersionKind(); + String group = StringUtils.defaultIfBlank(groupVersionKind.group(), "halo.run"); + return new GroupVersion("api." + group, groupVersionKind.version()); + } + + /** + * Converts list result to another list result. + * + * @param listResult list result to be converted + * @param mapper mapper function to convert item + * @param item type + * @param converted item type + * @return converted list result + */ + public static ListResult toAnotherListResult(ListResult listResult, + Function mapper) { + Assert.notNull(listResult, "List result must not be null"); + Assert.notNull(mapper, "The mapper must not be null"); + List mappedItems = listResult.get() + .map(mapper) + .toList(); + return new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), + mappedItems); + } + + /** + * Checks whether collection contains element. + * + * @param element type + * @return true if collection contains element, otherwise false. + */ + public static boolean containsElement(@Nullable Collection collection, + @Nullable T element) { + if (collection != null && element != null) { + return collection.contains(element); + } + return false; + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java new file mode 100644 index 000000000..5874b404b --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java @@ -0,0 +1,104 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.theme.finders.SinglePageFinder; +import run.halo.app.theme.finders.vo.ListedSinglePageVo; +import run.halo.app.theme.finders.vo.SinglePageVo; + +/** + * Endpoint for single page query. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class SinglePageQueryEndpoint implements CustomEndpoint { + + private final SinglePageFinder singlePageFinder; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/SinglePage"; + return SpringdocRouteBuilder.route() + .GET("singlepages", this::listSinglePages, + builder -> { + builder.operationId("querySinglePages") + .description("Lists single pages") + .tag(tag) + .response(responseBuilder() + .implementation( + ListResult.generateGenericClass(ListedSinglePageVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, + SinglePagePublicQuery.class); + } + ) + .GET("singlepages/{name}", this::getByName, + builder -> builder.operationId("querySinglePageByName") + .description("Gets single page by name") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("SinglePage name") + .required(true) + ) + .response(responseBuilder() + .implementation(SinglePageVo.class) + ) + ) + .build(); + } + + private Mono getByName(ServerRequest request) { + var name = request.pathVariable("name"); + return singlePageFinder.getByName(name) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + private Mono listSinglePages(ServerRequest request) { + var query = new SinglePagePublicQuery(request.exchange()); + return singlePageFinder.list(query.getPage(), + query.getSize(), + query.toPredicate(), + query.toComparator() + ) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + static class SinglePagePublicQuery extends SortableRequest { + + public SinglePagePublicQuery(ServerWebExchange exchange) { + super(exchange); + } + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new SinglePage()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java new file mode 100644 index 000000000..dc73e8a3e --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java @@ -0,0 +1,57 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; + +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.theme.finders.SiteStatsFinder; +import run.halo.app.theme.finders.vo.SiteStatsVo; + +/** + * Endpoint for site stats query APIs. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class SiteStatsQueryEndpoint implements CustomEndpoint { + + private final SiteStatsFinder siteStatsFinder; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Stats"; + return SpringdocRouteBuilder.route() + .GET("stats/-", this::getStats, + builder -> builder.operationId("queryStats") + .description("Gets site stats") + .tag(tag) + .response(responseBuilder() + .implementation(SiteStatsVo.class) + ) + ) + .build(); + } + + private Mono getStats(ServerRequest request) { + return siteStatsFinder.getStats() + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + @Override + public GroupVersion groupVersion() { + return new GroupVersion("api.halo.run", "v1alpha1"); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SortableRequest.java b/application/src/main/java/run/halo/app/theme/endpoint/SortableRequest.java new file mode 100644 index 000000000..d20ad9218 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/SortableRequest.java @@ -0,0 +1,81 @@ +package run.halo.app.theme.endpoint; + +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.util.Comparator; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; +import org.springframework.data.domain.Sort; +import org.springframework.util.comparator.Comparators; +import org.springframework.web.server.ServerWebExchange; +import run.halo.app.core.extension.endpoint.SortResolver; +import run.halo.app.extension.Extension; +import run.halo.app.extension.router.IListRequest; + +public class SortableRequest extends IListRequest.QueryListRequest { + + protected final ServerWebExchange exchange; + + public SortableRequest(ServerWebExchange exchange) { + super(exchange.getRequest().getQueryParams()); + this.exchange = exchange; + } + + @ArraySchema(uniqueItems = true, + arraySchema = @Schema(name = "sort", + description = "Sort property and direction of the list result. Support sorting based " + + "on attribute name path."), + schema = @Schema(description = "like field,asc or field,desc", + implementation = String.class, + example = "metadata.creationTimestamp,desc")) + public Sort getSort() { + return SortResolver.defaultInstance.resolve(exchange); + } + + /** + * Build predicate from query params, default is label and field selector, you can + * override this method to change it. + * + * @return predicate + */ + public Predicate toPredicate() { + return labelAndFieldSelectorToPredicate(getLabelSelector(), getFieldSelector()); + } + + /** + * Build comparator from sort. + * + * @param Extension type + * @return comparator + */ + public Comparator toComparator() { + var sort = getSort(); + Stream> fallbackComparator = + Stream.of(run.halo.app.extension.Comparators.compareCreationTimestamp(false), + run.halo.app.extension.Comparators.compareName(true)); + var comparatorStream = sort.stream() + .map(order -> { + String property = order.getProperty(); + Sort.Direction direction = order.getDirection(); + Function function = extension -> { + BeanWrapper beanWrapper = new BeanWrapperImpl(extension); + return beanWrapper.getPropertyValue(property); + }; + Comparator nullsComparator = + direction.isAscending() ? Comparators.nullsLow() : Comparators.nullsHigh(); + Comparator comparator = Comparator.comparing(function, nullsComparator); + if (direction.isDescending()) { + comparator = comparator.reversed(); + } + return comparator; + }); + return Stream.concat(comparatorStream, fallbackComparator) + .reduce(Comparator::thenComparing) + .orElse(null); + } +} diff --git a/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java new file mode 100644 index 000000000..c15ac8a1a --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java @@ -0,0 +1,140 @@ +package run.halo.app.theme.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static run.halo.app.theme.endpoint.PublicApiUtils.containsElement; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.util.function.Predicate; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +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 reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Tag; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.vo.ListedPostVo; +import run.halo.app.theme.finders.vo.TagVo; + +/** + * Endpoint for tag query APIs. + * + * @author guqing + * @since 2.5.0 + */ +@Component +@RequiredArgsConstructor +public class TagQueryEndpoint implements CustomEndpoint { + + private final TagFinder tagFinder; + private final PostPublicQueryService postPublicQueryService; + + @Override + public RouterFunction endpoint() { + final var tag = groupVersion().toString() + "/Tag"; + return SpringdocRouteBuilder.route() + .GET("tags", this::listTags, + builder -> { + builder.operationId("queryTags") + .description("Lists tags") + .tag(tag) + .response(responseBuilder() + .implementation( + ListResult.generateGenericClass(TagVo.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, TagPublicQuery.class); + } + ) + .GET("tags/{name}", this::getTagByName, + builder -> builder.operationId("queryTagByName") + .description("Gets tag by name") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Tag name") + .required(true) + ) + .response(responseBuilder() + .implementation(TagVo.class) + ) + ) + .GET("tags/{name}/posts", this::listPostsByTagName, + builder -> { + builder.operationId("queryPostsByTagName") + .description("Lists posts by tag name") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("Tag name") + .required(true) + ) + .response(responseBuilder() + .implementation(ListedPostVo.class) + ); + QueryParamBuildUtil.buildParametersFromType(builder, PostPublicQuery.class); + } + ) + .build(); + } + + private Mono getTagByName(ServerRequest request) { + String name = request.pathVariable("name"); + return tagFinder.getByName(name) + .flatMap(tag -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(tag) + ); + } + + private Mono listPostsByTagName(ServerRequest request) { + final var name = request.pathVariable("name"); + final var query = new PostPublicQuery(request.exchange()); + final Predicate containsTagPredicate = + post -> containsElement(post.getSpec().getTags(), name); + return postPublicQueryService.list(query.getPage(), + query.getSize(), + containsTagPredicate.and(query.toPredicate()), + query.toComparator() + ) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + private Mono listTags(ServerRequest request) { + var query = new TagPublicQuery(request.exchange()); + return tagFinder.list(query.getPage(), + query.getSize(), + query.toPredicate(), + query.toComparator() + ) + .flatMap(result -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(result) + ); + } + + static class TagPublicQuery extends SortableRequest { + public TagPublicQuery(ServerWebExchange exchange) { + super(exchange); + } + } + + @Override + public GroupVersion groupVersion() { + return PublicApiUtils.groupVersion(new Tag()); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java new file mode 100644 index 000000000..d20afb63e --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java @@ -0,0 +1,38 @@ +package run.halo.app.theme.finders; + +import java.util.Comparator; +import java.util.Objects; +import java.util.function.Predicate; +import org.springframework.lang.NonNull; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Post; +import run.halo.app.extension.ListResult; +import run.halo.app.theme.finders.vo.ListedPostVo; + +public interface PostPublicQueryService { + Predicate FIXED_PREDICATE = post -> post.isPublished() + && Objects.equals(false, post.getSpec().getDeleted()) + && Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible()); + + + /** + * Lists posts page by predicate and comparator. + * + * @param page page number + * @param size page size + * @param postPredicate post predicate + * @param comparator post comparator + * @return list result + */ + Mono> list(Integer page, Integer size, + Predicate postPredicate, + Comparator comparator); + + /** + * Converts post to listed post vo. + * + * @param post post must not be null + * @return listed post vo + */ + Mono convertToListedPostVo(@NonNull Post post); +} diff --git a/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java b/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java index f0f01bf38..d577b8877 100644 --- a/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/SinglePageFinder.java @@ -1,5 +1,7 @@ package run.halo.app.theme.finders; +import java.util.Comparator; +import java.util.function.Predicate; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.SinglePage; @@ -21,4 +23,7 @@ public interface SinglePageFinder { Mono content(String pageName); Mono> list(@Nullable Integer page, @Nullable Integer size); + + Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator); } diff --git a/application/src/main/java/run/halo/app/theme/finders/TagFinder.java b/application/src/main/java/run/halo/app/theme/finders/TagFinder.java index 0074509f9..9c157e5e5 100644 --- a/application/src/main/java/run/halo/app/theme/finders/TagFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/TagFinder.java @@ -1,6 +1,8 @@ package run.halo.app.theme.finders; +import java.util.Comparator; import java.util.List; +import java.util.function.Predicate; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -22,5 +24,8 @@ public interface TagFinder { Mono> list(@Nullable Integer page, @Nullable Integer size); + Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator); + Flux listAll(); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java index a81843cd4..d40378ce7 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java @@ -19,6 +19,7 @@ import run.halo.app.core.extension.MenuItem; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.exception.NotFoundException; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.MenuFinder; import run.halo.app.theme.finders.vo.MenuItemVo; @@ -41,7 +42,9 @@ public class MenuFinderImpl implements MenuFinder { public Mono getByName(String name) { return listAsTree() .filter(menu -> menu.getMetadata().getName().equals(name)) - .next(); + .next() + .switchIfEmpty(Mono.error( + () -> new NotFoundException("Menu with name " + name + " not found"))); } @Override @@ -59,7 +62,10 @@ public class MenuFinderImpl implements MenuFinder { .orElse(menuVos.get(0)) ) .defaultIfEmpty(menuVos.get(0)); - }); + }) + .switchIfEmpty( + Mono.error(() -> new NotFoundException("No primary menu found")) + ); } Flux listAll() { diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java index f2dc17b43..2d495c510 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -1,5 +1,7 @@ package run.halo.app.theme.finders.impl; +import static run.halo.app.theme.finders.PostPublicQueryService.FIXED_PREDICATE; + import java.time.Instant; import java.util.ArrayDeque; import java.util.ArrayList; @@ -9,14 +11,10 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; -import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.AllArgsConstructor; -import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.lang.NonNull; -import org.springframework.util.CollectionUtils; import org.springframework.util.comparator.Comparators; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -25,20 +23,15 @@ import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.HaloUtils; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; -import run.halo.app.theme.finders.CategoryFinder; -import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.PostFinder; -import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.NavigationPostVo; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo; import run.halo.app.theme.finders.vo.PostVo; -import run.halo.app.theme.finders.vo.StatsVo; /** * A finder for {@link Post}. @@ -50,25 +43,18 @@ import run.halo.app.theme.finders.vo.StatsVo; @AllArgsConstructor public class PostFinderImpl implements PostFinder { - public static final Predicate FIXED_PREDICATE = post -> post.isPublished() - && Objects.equals(false, post.getSpec().getDeleted()) - && Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible()); + private final ReactiveExtensionClient client; private final PostService postService; - private final TagFinder tagFinder; - - private final CategoryFinder categoryFinder; - - private final ContributorFinder contributorFinder; - - private final CounterService counterService; + private final PostPublicQueryService postPublicQueryService; @Override public Mono getByName(String postName) { - return client.fetch(Post.class, postName) - .flatMap(this::getListedPostVo) + return client.get(Post.class, postName) + .filter(FIXED_PREDICATE) + .flatMap(postPublicQueryService::convertToListedPostVo) .map(PostVo::from) .flatMap(postVo -> content(postName) .doOnNext(postVo::setContent) @@ -112,7 +98,7 @@ public class PostFinderImpl implements PostFinder { @Override public Flux listAll() { return client.list(Post.class, FIXED_PREDICATE, defaultComparator()) - .concatMap(this::getListedPostVo); + .concatMap(postPublicQueryService::convertToListedPostVo); } static Pair postPreviousNextPair(List postNames, @@ -189,25 +175,25 @@ public class PostFinderImpl implements PostFinder { @Override public Mono> list(Integer page, Integer size) { - return listPost(page, size, null, defaultComparator()); + return postPublicQueryService.list(page, size, null, defaultComparator()); } @Override public Mono> listByCategory(Integer page, Integer size, String categoryName) { - return listPost(page, size, + return postPublicQueryService.list(page, size, post -> contains(post.getSpec().getCategories(), categoryName), defaultComparator()); } @Override public Mono> listByTag(Integer page, Integer size, String tag) { - return listPost(page, size, + return postPublicQueryService.list(page, size, post -> contains(post.getSpec().getTags(), tag), defaultComparator()); } @Override public Mono> listByOwner(Integer page, Integer size, String owner) { - return listPost(page, size, + return postPublicQueryService.list(page, size, post -> post.getSpec().getOwner().equals(owner), defaultComparator()); } @@ -224,7 +210,7 @@ public class PostFinderImpl implements PostFinder { @Override public Mono> archives(Integer page, Integer size, String year, String month) { - return listPost(page, size, post -> { + return postPublicQueryService.list(page, size, post -> { Map labels = post.getMetadata().getLabels(); if (labels == null) { return false; @@ -277,84 +263,6 @@ public class PostFinderImpl implements PostFinder { return c.contains(key); } - private Mono> listPost(Integer page, Integer size, - Predicate postPredicate, - Comparator comparator) { - Predicate predicate = FIXED_PREDICATE - .and(postPredicate == null ? post -> true : postPredicate); - return client.list(Post.class, predicate, - comparator, pageNullSafe(page), sizeNullSafe(size)) - .flatMap(list -> Flux.fromStream(list.get()) - .concatMap(post -> getListedPostVo(post) - .flatMap(postVo -> populateStats(postVo) - .doOnNext(postVo::setStats).thenReturn(postVo) - ) - ) - .collectList() - .map(postVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), - postVos) - ) - ) - .defaultIfEmpty(new ListResult<>(page, size, 0L, List.of())); - } - - private Mono populateStats(T postVo) { - return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata() - .getName())) - .map(counter -> StatsVo.builder() - .visit(counter.getVisit()) - .upvote(counter.getUpvote()) - .comment(counter.getApprovedComment()) - .build() - ) - .defaultIfEmpty(StatsVo.empty()); - } - - private Mono getListedPostVo(@NonNull Post post) { - ListedPostVo postVo = ListedPostVo.from(post); - postVo.setCategories(List.of()); - postVo.setTags(List.of()); - postVo.setContributors(List.of()); - - return Mono.just(postVo) - .flatMap(lp -> populateStats(postVo) - .doOnNext(lp::setStats) - .thenReturn(lp) - ) - .flatMap(p -> { - String owner = p.getSpec().getOwner(); - return contributorFinder.getContributor(owner) - .doOnNext(p::setOwner) - .thenReturn(p); - }) - .flatMap(p -> { - List tagNames = p.getSpec().getTags(); - if (CollectionUtils.isEmpty(tagNames)) { - return Mono.just(p); - } - return tagFinder.getByNames(tagNames) - .collectList() - .doOnNext(p::setTags) - .thenReturn(p); - }) - .flatMap(p -> { - List categoryNames = p.getSpec().getCategories(); - if (CollectionUtils.isEmpty(categoryNames)) { - return Mono.just(p); - } - return categoryFinder.getByNames(categoryNames) - .collectList() - .doOnNext(p::setCategories) - .thenReturn(p); - }) - .flatMap(p -> contributorFinder.getContributors(p.getStatus().getContributors()) - .collectList() - .doOnNext(p::setContributors) - .thenReturn(p) - ) - .defaultIfEmpty(postVo); - } - static Comparator defaultComparator() { Function pinned = post -> Objects.requireNonNullElse(post.getSpec().getPinned(), false); @@ -378,12 +286,4 @@ public class PostFinderImpl implements PostFinder { .thenComparing(name) .reversed(); } - - int pageNullSafe(Integer page) { - return ObjectUtils.defaultIfNull(page, 1); - } - - int sizeNullSafe(Integer size) { - return ObjectUtils.defaultIfNull(size, 10); - } } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java new file mode 100644 index 000000000..faf2b8746 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java @@ -0,0 +1,128 @@ +package run.halo.app.theme.finders.impl; + +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Post; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; +import run.halo.app.theme.finders.CategoryFinder; +import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.TagFinder; +import run.halo.app.theme.finders.vo.ListedPostVo; +import run.halo.app.theme.finders.vo.StatsVo; + +@Component +@RequiredArgsConstructor +public class PostPublicQueryServiceImpl implements PostPublicQueryService { + + private final ReactiveExtensionClient client; + + private final TagFinder tagFinder; + + private final CategoryFinder categoryFinder; + + private final ContributorFinder contributorFinder; + + private final CounterService counterService; + + @Override + public Mono> list(Integer page, Integer size, + Predicate postPredicate, Comparator comparator) { + Predicate predicate = FIXED_PREDICATE + .and(postPredicate == null ? post -> true : postPredicate); + return client.list(Post.class, predicate, + comparator, pageNullSafe(page), sizeNullSafe(size)) + .flatMap(list -> Flux.fromStream(list.get()) + .concatMap(post -> convertToListedPostVo(post) + .flatMap(postVo -> populateStats(postVo) + .doOnNext(postVo::setStats).thenReturn(postVo) + ) + ) + .collectList() + .map(postVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), + postVos) + ) + ) + .defaultIfEmpty(new ListResult<>(page, size, 0L, List.of())); + } + + @Override + public Mono convertToListedPostVo(@NonNull Post post) { + Assert.notNull(post, "Post must not be null"); + ListedPostVo postVo = ListedPostVo.from(post); + postVo.setCategories(List.of()); + postVo.setTags(List.of()); + postVo.setContributors(List.of()); + + return Mono.just(postVo) + .flatMap(lp -> populateStats(postVo) + .doOnNext(lp::setStats) + .thenReturn(lp) + ) + .flatMap(p -> { + String owner = p.getSpec().getOwner(); + return contributorFinder.getContributor(owner) + .doOnNext(p::setOwner) + .thenReturn(p); + }) + .flatMap(p -> { + List tagNames = p.getSpec().getTags(); + if (CollectionUtils.isEmpty(tagNames)) { + return Mono.just(p); + } + return tagFinder.getByNames(tagNames) + .collectList() + .doOnNext(p::setTags) + .thenReturn(p); + }) + .flatMap(p -> { + List categoryNames = p.getSpec().getCategories(); + if (CollectionUtils.isEmpty(categoryNames)) { + return Mono.just(p); + } + return categoryFinder.getByNames(categoryNames) + .collectList() + .doOnNext(p::setCategories) + .thenReturn(p); + }) + .flatMap(p -> contributorFinder.getContributors(p.getStatus().getContributors()) + .collectList() + .doOnNext(p::setContributors) + .thenReturn(p) + ) + .defaultIfEmpty(postVo); + } + + private Mono populateStats(T postVo) { + return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata() + .getName()) + ) + .map(counter -> StatsVo.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .comment(counter.getApprovedComment()) + .build() + ) + .defaultIfEmpty(StatsVo.empty()); + } + + int pageNullSafe(Integer page) { + return ObjectUtils.defaultIfNull(page, 1); + } + + int sizeNullSafe(Integer size) { + return ObjectUtils.defaultIfNull(size, 10); + } +} diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java index 3433936bb..861ca46b1 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java @@ -4,10 +4,12 @@ import java.time.Instant; import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; import java.util.function.Predicate; import lombok.AllArgsConstructor; import org.apache.commons.lang3.ObjectUtils; +import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -50,7 +52,7 @@ public class SinglePageFinderImpl implements SinglePageFinder { @Override public Mono getByName(String pageName) { - return client.fetch(SinglePage.class, pageName) + return client.get(SinglePage.class, pageName) .filter(FIXED_PREDICATE) .map(page -> { SinglePageVo pageVo = SinglePageVo.from(page); @@ -82,8 +84,19 @@ public class SinglePageFinderImpl implements SinglePageFinder { @Override public Mono> list(Integer page, Integer size) { - return client.list(SinglePage.class, FIXED_PREDICATE, - defaultComparator(), pageNullSafe(page), sizeNullSafe(size)) + return list(page, size, null, null); + } + + @Override + public Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator) { + var predicateToUse = Optional.ofNullable(predicate) + .map(p -> p.and(FIXED_PREDICATE)) + .orElse(FIXED_PREDICATE); + var comparatorToUse = Optional.ofNullable(comparator) + .orElse(defaultComparator()); + return client.list(SinglePage.class, predicateToUse, + comparatorToUse, pageNullSafe(page), sizeNullSafe(size)) .flatMap(list -> Flux.fromStream(list.get()) .map(singlePage -> { ListedSinglePageVo pageVo = ListedSinglePageVo.from(singlePage); diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java index 99425bc83..d1c86b642 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java @@ -2,8 +2,11 @@ package run.halo.app.theme.finders.impl; import java.util.Comparator; import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.commons.lang3.ObjectUtils; +import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; @@ -45,15 +48,24 @@ public class TagFinderImpl implements TagFinder { @Override public Mono> list(Integer page, Integer size) { - return client.list(Tag.class, null, - DEFAULT_COMPARATOR.reversed(), pageNullSafe(page), sizeNullSafe(size)) + return list(page, size, null, null); + } + + @Override + public Mono> list(@Nullable Integer page, @Nullable Integer size, + @Nullable Predicate predicate, @Nullable Comparator comparator) { + Comparator comparatorToUse = Optional.ofNullable(comparator) + .orElse(DEFAULT_COMPARATOR.reversed()); + return client.list(Tag.class, predicate, + comparatorToUse, pageNullSafe(page), sizeNullSafe(size)) .map(list -> { List tagVos = list.get() .map(TagVo::from) .collect(Collectors.toList()); return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), tagVos); }) - .defaultIfEmpty(new ListResult<>(page, size, 0L, List.of())); + .defaultIfEmpty( + new ListResult<>(pageNullSafe(page), sizeNullSafe(size), 0L, List.of())); } @Override diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java index 523b7e3e1..be927d251 100644 --- a/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java +++ b/application/src/main/java/run/halo/app/theme/finders/vo/NavigationPostVo.java @@ -1,5 +1,8 @@ package run.halo.app.theme.finders.vo; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Value; @@ -13,10 +16,12 @@ import lombok.Value; @Builder public class NavigationPostVo { + @Schema(requiredMode = NOT_REQUIRED) PostVo previous; PostVo current; + @Schema(requiredMode = NOT_REQUIRED) PostVo next; public boolean hasNext() { diff --git a/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java index 0f400951b..fde1d6a0b 100644 --- a/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java +++ b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java @@ -2,6 +2,7 @@ package run.halo.app.theme.router.factories; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static run.halo.app.theme.finders.PostPublicQueryService.FIXED_PREDICATE; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; @@ -37,7 +38,6 @@ import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; -import run.halo.app.theme.finders.impl.PostFinderImpl; import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.router.ViewNameResolver; @@ -143,13 +143,13 @@ public class PostRouteFactory implements RouteFactory { private Flux fetchPostsByName(String name) { return client.fetch(Post.class, name) - .filter(PostFinderImpl.FIXED_PREDICATE) + .filter(FIXED_PREDICATE) .flux(); } private Flux fetchPostsBySlug(String slug) { return client.list(Post.class, - post -> PostFinderImpl.FIXED_PREDICATE.test(post) + post -> FIXED_PREDICATE.test(post) && matchIfPresent(slug, post.getSpec().getSlug()), null); } diff --git a/application/src/main/resources/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml index 0b97659a7..713576488 100644 --- a/application/src/main/resources/extensions/role-template-anonymous.yaml +++ b/application/src/main/resources/extensions/role-template-anonymous.yaml @@ -7,7 +7,7 @@ metadata: halo.run/hidden: "true" annotations: rbac.authorization.halo.run/dependencies: | - [ "role-template-own-permissions"] + [ "role-template-own-permissions", "role-template-public-apis" ] rules: - apiGroups: [ "api.halo.run" ] resources: [ "comments", "comments/reply" ] @@ -21,5 +21,23 @@ rules: verbs: [ "get" ] - nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ] verbs: [ "create" ] - - nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key"] + - nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key" ] verbs: [ "get" ] +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-public-apis + labels: + halo.run/role-template: "true" + halo.run/hidden: "true" +rules: + - apiGroups: [ "api.halo.run" ] + resources: [ "*" ] + verbs: [ "get", "list" ] + - apiGroups: [ "api.content.halo.run" ] + resources: [ "*" ] + verbs: [ "get", "list" ] + - apiGroups: [ "api.plugin.halo.run" ] + resources: [ "*" ] + verbs: [ "get", "list" ] \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java new file mode 100644 index 000000000..a44c02778 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java @@ -0,0 +1,105 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Category; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.vo.ListedPostVo; + +/** + * Tests for {@link CategoryQueryEndpoint}. + * + * @author guqing + * @since 2.5.0 + */ +@ExtendWith(MockitoExtension.class) +class CategoryQueryEndpointTest { + + @Mock + private ReactiveExtensionClient client; + + @Mock + private PostPublicQueryService postPublicQueryService; + private CategoryQueryEndpoint endpoint; + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + endpoint = new CategoryQueryEndpoint(client, postPublicQueryService); + RouterFunction routerFunction = endpoint.endpoint(); + webTestClient = WebTestClient.bindToRouterFunction(routerFunction).build(); + } + + @Test + void listCategories() { + ListResult listResult = new ListResult<>(List.of()); + when(client.list(eq(Category.class), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(listResult)); + + webTestClient.get() + .uri("/categories?page=1&size=10") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.total").isEqualTo(listResult.getTotal()) + .jsonPath("$.items").isArray(); + } + + @Test + void getByName() { + Category category = new Category(); + category.setMetadata(new Metadata()); + category.getMetadata().setName("test"); + when(client.get(eq(Category.class), eq("test"))).thenReturn(Mono.just(category)); + + webTestClient.get() + .uri("/categories/test") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.metadata.name").isEqualTo(category.getMetadata().getName()); + } + + @Test + void listPostsByCategoryName() { + ListResult listResult = new ListResult<>(List.of()); + when(postPublicQueryService.list(anyInt(), anyInt(), any(), any())) + .thenReturn(Mono.just(listResult)); + + webTestClient.get() + .uri("/categories/test/posts?page=1&size=10") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.total").isEqualTo(listResult.getTotal()) + .jsonPath("$.items").isArray(); + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java new file mode 100644 index 000000000..a6dbb503a --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java @@ -0,0 +1,122 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import lombok.NonNull; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Menu; +import run.halo.app.core.extension.MenuItem; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.Metadata; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.theme.finders.MenuFinder; +import run.halo.app.theme.finders.vo.MenuItemVo; +import run.halo.app.theme.finders.vo.MenuVo; + +/** + * Tests for {@link MenuQueryEndpoint}. + * + * @author guqing + * @since 2.5.0 + */ +@ExtendWith(MockitoExtension.class) +class MenuQueryEndpointTest { + + @Mock + private MenuFinder menuFinder; + + @Mock + private SystemConfigurableEnvironmentFetcher environmentFetcher; + + @InjectMocks + private MenuQueryEndpoint endpoint; + + private WebTestClient webClient; + + @BeforeEach + void setUp() { + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); + } + + @Test + void getPrimaryMenu() { + Metadata metadata = new Metadata(); + metadata.setName("fake-primary"); + MenuVo menuVo = MenuVo.builder() + .metadata(metadata) + .spec(new Menu.Spec()) + .menuItems(List.of(MenuItemVo.from(createMenuItem("item1")))) + .build(); + when(menuFinder.getByName(eq("fake-primary"))) + .thenReturn(Mono.just(menuVo)); + + SystemSetting.Menu menuSetting = new SystemSetting.Menu(); + menuSetting.setPrimary("fake-primary"); + when(environmentFetcher.fetch(eq(SystemSetting.Menu.GROUP), eq(SystemSetting.Menu.class))) + .thenReturn(Mono.just(menuSetting)); + + webClient.get().uri("/menus/-") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.metadata.name").isEqualTo("fake-primary") + .jsonPath("$.menuItems[0].metadata.name").isEqualTo("item1"); + + verify(menuFinder).getByName(eq("fake-primary")); + verify(environmentFetcher).fetch(eq(SystemSetting.Menu.GROUP), + eq(SystemSetting.Menu.class)); + } + + @NonNull + private static MenuItem createMenuItem(String name) { + MenuItem menuItem = new MenuItem(); + menuItem.setMetadata(new Metadata()); + menuItem.getMetadata().setName(name); + menuItem.setSpec(new MenuItem.MenuItemSpec()); + menuItem.getSpec().setDisplayName(name); + return menuItem; + } + + @Test + void getMenuByName() { + Metadata metadata = new Metadata(); + metadata.setName("test-menu"); + MenuVo menuVo = MenuVo.builder() + .metadata(metadata) + .spec(new Menu.Spec()) + .menuItems(List.of(MenuItemVo.from(createMenuItem("item2")))) + .build(); + when(menuFinder.getByName(eq("test-menu"))) + .thenReturn(Mono.just(menuVo)); + + webClient.get().uri("/menus/test-menu") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.metadata.name").isEqualTo("test-menu") + .jsonPath("$.menuItems[0].metadata.name").isEqualTo("item2"); + + verify(menuFinder).getByName(eq("test-menu")); + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java new file mode 100644 index 000000000..739b92ca9 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java @@ -0,0 +1,55 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import run.halo.app.extension.GroupVersion; +import run.halo.app.theme.finders.PluginFinder; + +/** + * Tests for {@link PluginQueryEndpoint}. + * + * @author guqing + * @since 2.5.0 + */ +@ExtendWith(MockitoExtension.class) +class PluginQueryEndpointTest { + + @Mock + private PluginFinder pluginFinder; + + @InjectMocks + private PluginQueryEndpoint endpoint; + + private WebTestClient webClient; + + @BeforeEach + void setUp() { + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); + } + + @Test + void available() { + when(pluginFinder.available("fake-plugin")).thenReturn(true); + webClient.get().uri("/plugins/fake-plugin/available") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$").isEqualTo(true); + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.plugin.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java new file mode 100644 index 000000000..4b4404b3e --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java @@ -0,0 +1,115 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.theme.finders.PostFinder; +import run.halo.app.theme.finders.PostPublicQueryService; +import run.halo.app.theme.finders.vo.ListedPostVo; +import run.halo.app.theme.finders.vo.NavigationPostVo; +import run.halo.app.theme.finders.vo.PostVo; + +/** + * Tests for {@link PostQueryEndpoint}. + * + * @author guqing + * @since 2.5.0 + */ +@ExtendWith(MockitoExtension.class) +class PostQueryEndpointTest { + + private WebTestClient webClient; + + @Mock + private PostFinder postFinder; + + @Mock + private PostPublicQueryService postPublicQueryService; + + @InjectMocks + private PostQueryEndpoint endpoint; + + @BeforeEach + public void setUp() { + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) + .build(); + } + + @Test + public void listPosts() { + ListResult result = new ListResult<>(List.of()); + when(postPublicQueryService.list(anyInt(), anyInt(), any(), any())) + .thenReturn(Mono.just(result)); + + webClient.get().uri("/posts") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.items").isArray(); + + verify(postPublicQueryService).list(anyInt(), anyInt(), any(), any()); + } + + @Test + public void getPostByName() { + Metadata metadata = new Metadata(); + metadata.setName("test"); + PostVo post = PostVo.builder() + .metadata(metadata) + .build(); + when(postFinder.getByName(anyString())).thenReturn(Mono.just(post)); + + webClient.get().uri("/posts/{name}", "test") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.metadata.name").isEqualTo("test"); + + verify(postFinder).getByName(anyString()); + } + + @Test + public void testGetPostNavigationByName() { + Metadata metadata = new Metadata(); + metadata.setName("test"); + NavigationPostVo navigation = NavigationPostVo.builder() + .current(PostVo.builder().metadata(metadata).build()) + .build(); + when(postFinder.cursor(anyString())) + .thenReturn(Mono.just(navigation)); + + webClient.get().uri("/posts/{name}/navigation", "test") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.current.metadata.name").isEqualTo("test"); + + verify(postFinder).cursor(anyString()); + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java new file mode 100644 index 000000000..56979ed7d --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java @@ -0,0 +1,48 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.extension.GroupVersion; + +/** + * Tests for {@link PublicApiUtils}. + * + * @author guqing + * @since 2.5.0 + */ +class PublicApiUtilsTest { + + @Test + void groupVersion() { + GroupVersion groupVersion = PublicApiUtils.groupVersion(new FakExtension()); + assertThat(groupVersion.toString()).isEqualTo("api.halo.run/v1alpha1"); + + groupVersion = PublicApiUtils.groupVersion(new FakeGroupExtension()); + assertThat(groupVersion.toString()).isEqualTo("api.fake.halo.run/v1"); + } + + @Test + void containsElement() { + assertThat(PublicApiUtils.containsElement(null, null)).isFalse(); + assertThat(PublicApiUtils.containsElement(null, "test")).isFalse(); + assertThat(PublicApiUtils.containsElement(List.of("test"), null)).isFalse(); + assertThat(PublicApiUtils.containsElement(List.of("test"), "test")).isTrue(); + assertThat(PublicApiUtils.containsElement(List.of("test"), "test1")).isFalse(); + } + + @GVK(group = "fake.halo.run", version = "v1", kind = "FakeGroupExtension", plural = + "fakegroupextensions", singular = "fakegroupextension") + static class FakeGroupExtension extends AbstractExtension { + + } + + @GVK(group = "", version = "v1alpha1", kind = "FakeExtension", plural = + "fakeextensions", singular = "fakeextension") + static class FakExtension extends AbstractExtension { + + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java new file mode 100644 index 000000000..d9d804a40 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java @@ -0,0 +1,106 @@ +package run.halo.app.theme.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.Metadata; +import run.halo.app.theme.finders.SinglePageFinder; +import run.halo.app.theme.finders.vo.ListedSinglePageVo; +import run.halo.app.theme.finders.vo.SinglePageVo; + +/** + * Tests for {@link SinglePageQueryEndpoint}. + * + * @author guqing + * @since 2.5.0 + */ +@ExtendWith(MockitoExtension.class) +class SinglePageQueryEndpointTest { + + @Mock + private SinglePageFinder singlePageFinder; + + @InjectMocks + private SinglePageQueryEndpoint endpoint; + + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + webTestClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()).build(); + } + + @Test + void listSinglePages() { + ListedSinglePageVo test = ListedSinglePageVo.builder() + .metadata(metadata("test")) + .spec(new SinglePage.SinglePageSpec()) + .build(); + + ListResult pageResult = new ListResult<>(List.of(test)); + + when(singlePageFinder.list(anyInt(), anyInt(), any(), any())) + .thenReturn(Mono.just(pageResult)); + + webTestClient.get() + .uri("/singlepages?page=0&size=10") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.total").isEqualTo(1) + .jsonPath("$.items[0].metadata.name").isEqualTo("test"); + + verify(singlePageFinder).list(eq(0), eq(10), any(), any()); + } + + @Test + void getByName() { + SinglePageVo singlePage = SinglePageVo.builder() + .metadata(metadata("fake-page")) + .spec(new SinglePage.SinglePageSpec()) + .build(); + + when(singlePageFinder.getByName(eq("fake-page"))) + .thenReturn(Mono.just(singlePage)); + + webTestClient.get() + .uri("/singlepages/fake-page") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.metadata.name").isEqualTo("fake-page"); + + verify(singlePageFinder).getByName("fake-page"); + } + + Metadata metadata(String name) { + Metadata metadata = new Metadata(); + metadata.setName(name); + return metadata; + } + + @Test + void groupVersion() { + GroupVersion groupVersion = endpoint.groupVersion(); + assertThat(groupVersion.toString()).isEqualTo("api.content.halo.run/v1alpha1"); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java index e5b74e38b..9120c7343 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import static run.halo.app.theme.finders.PostPublicQueryService.FIXED_PREDICATE; import java.time.Instant; import java.util.ArrayList; @@ -18,7 +19,6 @@ 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.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; @@ -29,8 +29,10 @@ import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.metrics.CounterService; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.ContributorFinder; +import run.halo.app.theme.finders.PostPublicQueryService; import run.halo.app.theme.finders.TagFinder; import run.halo.app.theme.finders.vo.ContentVo; +import run.halo.app.theme.finders.vo.ListedPostVo; import run.halo.app.theme.finders.vo.PostArchiveVo; import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo; @@ -61,6 +63,9 @@ class PostFinderImplTest { @Mock private ContributorFinder contributorFinder; + @Mock + private PostPublicQueryService publicQueryService; + @InjectMocks private PostFinderImpl postFinder; @@ -92,7 +97,7 @@ class PostFinderImplTest { @Test void predicate() { - List strings = posts().stream().filter(PostFinderImpl.FIXED_PREDICATE) + List strings = posts().stream().filter(FIXED_PREDICATE) .map(post -> post.getMetadata().getName()) .toList(); assertThat(strings).isEqualTo(List.of("post-1", "post-2", "post-6")); @@ -100,12 +105,12 @@ class PostFinderImplTest { @Test void archives() { - when(counterService.getByName(any())).thenReturn(Mono.empty()); - ListResult listResult = new ListResult<>(1, 10, 3, postsForArchives()); - when(client.list(eq(Post.class), any(), any(), anyInt(), anyInt())) + List listedPostVos = postsForArchives().stream() + .map(ListedPostVo::from) + .toList(); + ListResult listResult = new ListResult<>(1, 10, 3, listedPostVos); + when(publicQueryService.list(anyInt(), anyInt(), any(), any())) .thenReturn(Mono.just(listResult)); - when(contributorFinder.getContributor(any())).thenReturn(Mono.empty()); - when(contributorFinder.getContributors(any())).thenReturn(Flux.empty()); ListResult archives = postFinder.archives(1, 10).block(); assertThat(archives).isNotNull(); diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java index daab2ca40..245305614 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageFinderImplTest.java @@ -3,7 +3,6 @@ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -62,7 +61,7 @@ class SinglePageFinderImplTest { singlePage.getSpec().setDeleted(false); singlePage.getSpec().setVisible(Post.VisibleEnum.PUBLIC); singlePage.setStatus(new SinglePage.SinglePageStatus()); - when(client.fetch(eq(SinglePage.class), eq(fakePageName))) + when(client.get(eq(SinglePage.class), eq(fakePageName))) .thenReturn(Mono.just(singlePage)); when(counterService.getByName(anyString())).thenReturn(Mono.empty()); @@ -77,7 +76,7 @@ class SinglePageFinderImplTest { }) .verifyComplete(); - verify(client, times(1)).fetch(SinglePage.class, fakePageName); + verify(client).get(SinglePage.class, fakePageName); verify(counterService).getByName(anyString()); verify(singlePageService).getReleaseContent(anyString()); verify(contributorFinder).getContributor(anyString());