feat: add public APIs for client side (#3787)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.5.x
/kind api-change

#### What this PR does / why we need it:
为客户端提供一套 APIs

#### Which issue(s) this PR fixes:
Fixes #3661

#### Does this PR introduce a user-facing change?
```release-note
为访客端提供一套完整的 API
```
pull/3847/head
guqing 2023-04-24 20:16:15 +08:00 committed by GitHub
parent d589ce56cc
commit e412866749
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1709 additions and 138 deletions

View File

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

View File

@ -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<ServerResponse> 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<ServerResponse> listPostsByCategoryName(ServerRequest request) {
final var name = request.pathVariable("name");
final var query = new PostPublicQuery(request.exchange());
Predicate<Post> 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<ServerResponse> 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<ServerResponse> 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());
}
}

View File

@ -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<ServerResponse> 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<ServerResponse> getByName(ServerRequest request) {
return determineMenuName(request)
.flatMap(menuFinder::getByName)
.flatMap(menuVo -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(menuVo)
);
}
private Mono<String> 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());
}
}

View File

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

View File

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

View File

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

View File

@ -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 <code>api.{group}/{version}</code> if group is not empty,
* otherwise <code>api.halo.run/{version}</code>.
*/
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 <T> item type
* @param <R> converted item type
* @return converted list result
*/
public static <T, R> ListResult<R> toAnotherListResult(ListResult<T> listResult,
Function<T, R> mapper) {
Assert.notNull(listResult, "List result must not be null");
Assert.notNull(mapper, "The mapper must not be null");
List<R> mappedItems = listResult.get()
.map(mapper)
.toList();
return new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(),
mappedItems);
}
/**
* Checks whether collection contains element.
*
* @param <T> element type
* @return true if collection contains element, otherwise false.
*/
public static <T> boolean containsElement(@Nullable Collection<T> collection,
@Nullable T element) {
if (collection != null && element != null) {
return collection.contains(element);
}
return false;
}
}

View File

@ -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<ServerResponse> 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<ServerResponse> getByName(ServerRequest request) {
var name = request.pathVariable("name");
return singlePageFinder.getByName(name)
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)
);
}
private Mono<ServerResponse> 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());
}
}

View File

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

View File

@ -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 <T extends Extension> Predicate<T> toPredicate() {
return labelAndFieldSelectorToPredicate(getLabelSelector(), getFieldSelector());
}
/**
* Build comparator from sort.
*
* @param <T> Extension type
* @return comparator
*/
public <T extends Extension> Comparator<T> toComparator() {
var sort = getSort();
Stream<Comparator<T>> 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<T, Object> function = extension -> {
BeanWrapper beanWrapper = new BeanWrapperImpl(extension);
return beanWrapper.getPropertyValue(property);
};
Comparator<Object> nullsComparator =
direction.isAscending() ? Comparators.nullsLow() : Comparators.nullsHigh();
Comparator<T> comparator = Comparator.comparing(function, nullsComparator);
if (direction.isDescending()) {
comparator = comparator.reversed();
}
return comparator;
});
return Stream.concat(comparatorStream, fallbackComparator)
.reduce(Comparator::thenComparing)
.orElse(null);
}
}

View File

@ -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<ServerResponse> 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<ServerResponse> getTagByName(ServerRequest request) {
String name = request.pathVariable("name");
return tagFinder.getByName(name)
.flatMap(tag -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(tag)
);
}
private Mono<ServerResponse> listPostsByTagName(ServerRequest request) {
final var name = request.pathVariable("name");
final var query = new PostPublicQuery(request.exchange());
final Predicate<Post> 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<ServerResponse> 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());
}
}

View File

@ -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<Post> 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<ListResult<ListedPostVo>> list(Integer page, Integer size,
Predicate<Post> postPredicate,
Comparator<Post> comparator);
/**
* Converts post to listed post vo.
*
* @param post post must not be null
* @return listed post vo
*/
Mono<ListedPostVo> convertToListedPostVo(@NonNull Post post);
}

View File

@ -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<ContentVo> content(String pageName);
Mono<ListResult<ListedSinglePageVo>> list(@Nullable Integer page, @Nullable Integer size);
Mono<ListResult<ListedSinglePageVo>> list(@Nullable Integer page, @Nullable Integer size,
@Nullable Predicate<SinglePage> predicate, @Nullable Comparator<SinglePage> comparator);
}

View File

@ -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<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size);
Mono<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size,
@Nullable Predicate<Tag> predicate, @Nullable Comparator<Tag> comparator);
Flux<TagVo> listAll();
}

View File

@ -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<MenuVo> 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<MenuVo> listAll() {

View File

@ -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<Post> 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<PostVo> 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<ListedPostVo> listAll() {
return client.list(Post.class, FIXED_PREDICATE, defaultComparator())
.concatMap(this::getListedPostVo);
.concatMap(postPublicQueryService::convertToListedPostVo);
}
static Pair<String, String> postPreviousNextPair(List<String> postNames,
@ -189,25 +175,25 @@ public class PostFinderImpl implements PostFinder {
@Override
public Mono<ListResult<ListedPostVo>> list(Integer page, Integer size) {
return listPost(page, size, null, defaultComparator());
return postPublicQueryService.list(page, size, null, defaultComparator());
}
@Override
public Mono<ListResult<ListedPostVo>> 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<ListResult<ListedPostVo>> 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<ListResult<ListedPostVo>> 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<ListResult<PostArchiveVo>> archives(Integer page, Integer size, String year,
String month) {
return listPost(page, size, post -> {
return postPublicQueryService.list(page, size, post -> {
Map<String, String> labels = post.getMetadata().getLabels();
if (labels == null) {
return false;
@ -277,84 +263,6 @@ public class PostFinderImpl implements PostFinder {
return c.contains(key);
}
private Mono<ListResult<ListedPostVo>> listPost(Integer page, Integer size,
Predicate<Post> postPredicate,
Comparator<Post> comparator) {
Predicate<Post> 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 <T extends ListedPostVo> Mono<StatsVo> 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<ListedPostVo> 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<String> 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<String> 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<Post> defaultComparator() {
Function<Post, Boolean> 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);
}
}

View File

@ -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<ListResult<ListedPostVo>> list(Integer page, Integer size,
Predicate<Post> postPredicate, Comparator<Post> comparator) {
Predicate<Post> 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<ListedPostVo> 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<String> 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<String> 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 <T extends ListedPostVo> Mono<StatsVo> 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);
}
}

View File

@ -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<SinglePageVo> 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<ListResult<ListedSinglePageVo>> 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<ListResult<ListedSinglePageVo>> list(@Nullable Integer page, @Nullable Integer size,
@Nullable Predicate<SinglePage> predicate, @Nullable Comparator<SinglePage> 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);

View File

@ -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<ListResult<TagVo>> 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<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size,
@Nullable Predicate<Tag> predicate, @Nullable Comparator<Tag> comparator) {
Comparator<Tag> comparatorToUse = Optional.ofNullable(comparator)
.orElse(DEFAULT_COMPARATOR.reversed());
return client.list(Tag.class, predicate,
comparatorToUse, pageNullSafe(page), sizeNullSafe(size))
.map(list -> {
List<TagVo> 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

View File

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

View File

@ -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<Post> fetchPostsByName(String name) {
return client.fetch(Post.class, name)
.filter(PostFinderImpl.FIXED_PREDICATE)
.filter(FIXED_PREDICATE)
.flux();
}
private Flux<Post> 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);
}

View File

@ -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" ]

View File

@ -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<ServerResponse> routerFunction = endpoint.endpoint();
webTestClient = WebTestClient.bindToRouterFunction(routerFunction).build();
}
@Test
void listCategories() {
ListResult<Category> 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<ListedPostVo> 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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> strings = posts().stream().filter(PostFinderImpl.FIXED_PREDICATE)
List<String> 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<Post> listResult = new ListResult<>(1, 10, 3, postsForArchives());
when(client.list(eq(Post.class), any(), any(), anyInt(), anyInt()))
List<ListedPostVo> listedPostVos = postsForArchives().stream()
.map(ListedPostVo::from)
.toList();
ListResult<ListedPostVo> 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<PostArchiveVo> archives = postFinder.archives(1, 10).block();
assertThat(archives).isNotNull();

View File

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