feat: add single page extension module (#2381)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
/milestone 2.0
/area core
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
新增自定义页面模块

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

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #2379

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2382/head^2
guqing 2022-09-07 15:48:12 +08:00 committed by GitHub
parent 53ee2ea547
commit 969fcde641
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1372 additions and 96 deletions

View File

@ -18,6 +18,7 @@ import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Tag;
import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.User;
@ -29,6 +30,7 @@ import run.halo.app.core.extension.reconciler.PluginReconciler;
import run.halo.app.core.extension.reconciler.PostReconciler;
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.reconciler.SinglePageReconciler;
import run.halo.app.core.extension.reconciler.SystemSettingReconciler;
import run.halo.app.core.extension.reconciler.TagReconciler;
import run.halo.app.core.extension.reconciler.ThemeReconciler;
@ -50,6 +52,7 @@ import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.resources.JsBundleRuleProvider;
import run.halo.app.theme.router.TemplateRouteManager;
@Configuration(proxyBeanMethods = false)
public class ExtensionConfiguration {
@ -181,6 +184,17 @@ public class ExtensionConfiguration {
.extension(new Attachment())
.build();
}
@Bean
Controller singlePageController(ExtensionClient client, ContentService contentService,
ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) {
return new ControllerBuilder("single-page-controller", client)
.reconciler(new SinglePageReconciler(client, contentService,
applicationContext, templateRouteManager)
)
.extension(new SinglePage())
.build();
}
}
}

View File

@ -0,0 +1,23 @@
package run.halo.app.content;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.Data;
import run.halo.app.core.extension.SinglePage;
/**
* An aggregate object of {@link SinglePage} and {@link Contributor} single page list.
*
* @author guqing
* @since 2.0.0
*/
@Data
public class ListedSinglePage {
@Schema(required = true)
private SinglePage page;
@Schema(required = true)
private List<Contributor> contributors;
}

View File

@ -1,10 +1,11 @@
package run.halo.app.content;
import java.util.List;
import java.util.Set;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.router.ListRequest;
import run.halo.app.extension.router.IListRequest;
/**
* A query object for {@link Post} list.
@ -12,14 +13,29 @@ import run.halo.app.extension.router.ListRequest;
* @author guqing
* @since 2.0.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PostQuery extends ListRequest {
public class PostQuery extends IListRequest.QueryListRequest {
private Set<String> contributors;
public PostQuery(MultiValueMap<String, String> queryParams) {
super(queryParams);
}
private Set<String> categories;
@Nullable
public Set<String> getContributors() {
return listToSet(queryParams.get("contributor"));
}
private Set<String> tags;
// TODO add more query fields
@Nullable
public Set<String> getCategories() {
return listToSet(queryParams.get("category"));
}
@Nullable
public Set<String> getTags() {
return listToSet(queryParams.get("tag"));
}
@Nullable
Set<String> listToSet(List<String> param) {
return param == null ? null : Set.copyOf(param);
}
}

View File

@ -0,0 +1,27 @@
package run.halo.app.content;
import java.util.List;
import java.util.Set;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.router.IListRequest;
/**
* Query parameter for {@link SinglePage} list.
*
* @author guqing
* @since 2.0.0
*/
public class SinglePageQuery extends IListRequest.QueryListRequest {
public SinglePageQuery(MultiValueMap<String, String> queryParams) {
super(queryParams);
}
@Nullable
public Set<String> getContributors() {
List<String> contributorList = queryParams.get("contributor");
return contributorList == null ? null : Set.copyOf(contributorList);
}
}

View File

@ -0,0 +1,25 @@
package run.halo.app.content;
import io.swagger.v3.oas.annotations.media.Schema;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot;
/**
* A request parameter for {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
public record SinglePageRequest(@Schema(required = true) SinglePage page,
@Schema(required = true) Content content) {
public ContentRequest contentRequest() {
Snapshot.SubjectRef subjectRef =
Snapshot.SubjectRef.of(SinglePage.KIND, page.getMetadata().getName());
return new ContentRequest(subjectRef, page.getSpec().getHeadSnapshot(), content.raw,
content.content, content.rawType);
}
public record Content(String raw, String content, String rawType) {
}
}

View File

@ -0,0 +1,22 @@
package run.halo.app.content;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.ListResult;
/**
* Single page service.
*
* @author guqing
* @since 2.0.0
*/
public interface SinglePageService {
Mono<ListResult<ListedSinglePage>> list(SinglePageQuery listRequest);
Mono<SinglePage> draft(SinglePageRequest pageRequest);
Mono<SinglePage> update(SinglePageRequest pageRequest);
Mono<SinglePage> publish(String name);
}

View File

@ -0,0 +1,200 @@
package run.halo.app.content.impl;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.security.Principal;
import java.time.Instant;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentService;
import run.halo.app.content.Contributor;
import run.halo.app.content.ListedSinglePage;
import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
/**
* Single page service implementation.
*
* @author guqing
* @since 2.0.0
*/
@Service
public class SinglePageServiceImpl implements SinglePageService {
private static final Comparator<SinglePage> DEFAULT_PAGE_COMPARATOR =
Comparator.comparing(page -> page.getMetadata().getCreationTimestamp());
private final ContentService contentService;
private final ReactiveExtensionClient client;
public SinglePageServiceImpl(ContentService contentService, ReactiveExtensionClient client) {
this.contentService = contentService;
this.client = client;
}
@Override
public Mono<ListResult<ListedSinglePage>> list(SinglePageQuery query) {
return client.list(SinglePage.class, pageListPredicate(query),
DEFAULT_PAGE_COMPARATOR.reversed(), query.getPage(), query.getSize())
.flatMap(listResult -> Flux.fromStream(
listResult.get().map(this::getListedSinglePage)
)
.flatMap(Function.identity())
.collectList()
.map(listedSinglePages -> new ListResult<>(listResult.getPage(),
listResult.getSize(),
listResult.getTotal(), listedSinglePages)
)
);
}
@Override
public Mono<SinglePage> draft(SinglePageRequest pageRequest) {
return contentService.draftContent(pageRequest.contentRequest())
.flatMap(contentWrapper -> getContextUsername()
.flatMap(username -> {
SinglePage page = pageRequest.page();
page.getSpec().setBaseSnapshot(contentWrapper.snapshotName());
page.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
page.getSpec().setOwner(username);
appendPublishedCondition(page, Post.PostPhase.DRAFT);
return client.create(page)
.then(Mono.defer(() ->
client.fetch(SinglePage.class,
pageRequest.page().getMetadata().getName())));
}));
}
@Override
public Mono<SinglePage> update(SinglePageRequest pageRequest) {
SinglePage page = pageRequest.page();
return contentService.updateContent(pageRequest.contentRequest())
.flatMap(contentWrapper -> {
page.getSpec().setHeadSnapshot(contentWrapper.snapshotName());
return client.update(page);
})
.then(Mono.defer(() -> client.fetch(SinglePage.class, page.getMetadata().getName())));
}
@Override
public Mono<SinglePage> publish(String name) {
return client.fetch(SinglePage.class, name)
.flatMap(page -> {
SinglePage.SinglePageSpec spec = page.getSpec();
if (Objects.equals(true, spec.getPublished())) {
// has been published before
spec.setVersion(spec.getVersion() + 1);
} else {
spec.setPublished(true);
}
if (spec.getPublishTime() == null) {
spec.setPublishTime(Instant.now());
}
Snapshot.SubjectRef subjectRef =
Snapshot.SubjectRef.of(SinglePage.KIND, page.getMetadata().getName());
return contentService.publish(spec.getHeadSnapshot(), subjectRef)
.flatMap(contentWrapper -> {
// update release snapshot name and condition
appendPublishedCondition(page, Post.PostPhase.PUBLISHED);
page.getSpec().setReleaseSnapshot(contentWrapper.snapshotName());
return client.update(page);
})
.then(Mono.defer(() -> client.fetch(SinglePage.class, name)));
});
}
private Mono<String> getContextUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
}
Predicate<SinglePage> pageListPredicate(SinglePageQuery query) {
Predicate<SinglePage> paramPredicate = singlePage -> contains(query.getContributors(),
singlePage.getStatusOrDefault().getContributors());
Predicate<SinglePage> predicate = labelAndFieldSelectorToPredicate(query.getLabelSelector(),
query.getFieldSelector());
return predicate.and(paramPredicate);
}
private Mono<ListedSinglePage> getListedSinglePage(SinglePage singlePage) {
Assert.notNull(singlePage, "The singlePage must not be null.");
ListedSinglePage listedSinglePage = new ListedSinglePage();
listedSinglePage.setPage(singlePage);
return Mono.just(listedSinglePage)
.flatMap(page -> listContributors(singlePage.getStatusOrDefault().getContributors())
.map(contributors -> {
page.setContributors(contributors);
return page;
}));
}
private Mono<List<Contributor>> listContributors(List<String> usernames) {
if (usernames == null) {
return Mono.empty();
}
return Flux.fromIterable(usernames)
.map(username -> client.fetch(User.class, username)
.map(user -> {
Contributor contributor = new Contributor();
contributor.setName(username);
contributor.setDisplayName(user.getSpec().getDisplayName());
contributor.setAvatar(user.getSpec().getAvatar());
return contributor;
})
)
.flatMap(Function.identity())
.collectList();
}
boolean contains(Collection<String> left, List<String> right) {
// parameter is null, it means that ignore this condition
if (left == null) {
return true;
}
// else, it means that right is empty
if (left.isEmpty()) {
return right.isEmpty();
}
if (right == null) {
return false;
}
return right.stream().anyMatch(left::contains);
}
void appendPublishedCondition(SinglePage page, Post.PostPhase phase) {
Assert.notNull(page, "The singlePage must not be null.");
SinglePage.SinglePageStatus status = page.getStatusOrDefault();
status.setPhase(phase.name());
List<Condition> conditions = status.getConditionsOrDefault();
Condition condition = new Condition();
conditions.add(condition);
condition.setType(phase.name());
condition.setReason(phase.name());
condition.setMessage("");
condition.setStatus(ConditionStatus.TRUE);
condition.setLastTransitionTime(Instant.now());
}
}

View File

@ -0,0 +1,103 @@
package run.halo.app.core.extension;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
/**
* <p>Single page extension.</p>
*
* @author guqing
* @since 2.0.0
*/
@Data
@ToString(callSuper = true)
@GVK(group = "content.halo.run", version = "v1alpha1", kind = SinglePage.KIND,
plural = "singlepages", singular = "singlepage")
@EqualsAndHashCode(callSuper = true)
public class SinglePage extends AbstractExtension {
public static final String KIND = "SinglePage";
public static final String DELETED_LABEL = "content.halo.run/deleted";
public static final String OWNER_LABEL = "content.halo.run/owner";
public static final String VISIBLE_LABEL = "content.halo.run/visible";
public static final String PHASE_LABEL = "content.halo.run/phase";
@Schema(required = true)
private SinglePageSpec spec;
@Schema
private SinglePageStatus status;
@JsonIgnore
public SinglePageStatus getStatusOrDefault() {
if (this.status == null) {
this.status = new SinglePageStatus();
}
return this.status;
}
@Data
public static class SinglePageSpec {
@Schema(required = true, minLength = 1)
private String title;
@Schema(required = true, minLength = 1)
private String slug;
/**
* .
*/
private String releaseSnapshot;
private String headSnapshot;
private String baseSnapshot;
private String owner;
private String template;
private String cover;
@Schema(required = true, defaultValue = "false")
private Boolean deleted;
@Schema(required = true, defaultValue = "false")
private Boolean published;
private Instant publishTime;
@Schema(required = true, defaultValue = "false")
private Boolean pinned;
@Schema(required = true, defaultValue = "true")
private Boolean allowComment;
@Schema(required = true, defaultValue = "PUBLIC")
private Post.VisibleEnum visible;
@Schema(required = true, defaultValue = "1")
private Integer version;
@Schema(required = true, defaultValue = "0")
private Integer priority;
@Schema(required = true)
private Post.Excerpt excerpt;
private List<Map<String, String>> htmlMetas;
}
@Data
@EqualsAndHashCode(callSuper = true)
public static class SinglePageStatus extends Post.PostStatus {
}
}

View File

@ -8,7 +8,6 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
@ -117,23 +116,7 @@ public class PostEndpoint implements CustomEndpoint {
}
Mono<ServerResponse> listPost(ServerRequest request) {
var conversionService = ApplicationConversionService.getSharedInstance();
var page =
request.queryParam("page")
.map(pageString -> conversionService.convert(pageString, Integer.class))
.orElse(0);
var size = request.queryParam("size")
.map(sizeString -> conversionService.convert(sizeString, Integer.class))
.orElse(0);
var labelSelectors = request.queryParams().get("labelSelector");
var fieldSelectors = request.queryParams().get("fieldSelector");
PostQuery postQuery = new PostQuery();
postQuery.setPage(page);
postQuery.setSize(size);
postQuery.setLabelSelector(labelSelectors);
postQuery.setFieldSelector(fieldSelectors);
PostQuery postQuery = new PostQuery(request.queryParams());
return postService.listPost(postQuery)
.flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts));
}

View File

@ -0,0 +1,123 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.springdoc.core.fn.builders.schema.Builder;
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.content.ListedSinglePage;
import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.router.QueryParamBuildUtil;
/**
* Endpoint for managing {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class SinglePageEndpoint implements CustomEndpoint {
private final SinglePageService singlePageService;
public SinglePageEndpoint(SinglePageService singlePageService) {
this.singlePageService = singlePageService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.halo.run/v1alpha1/SinglePage";
return SpringdocRouteBuilder.route()
.GET("singlepages", this::listSinglePage, builder -> {
builder.operationId("ListSinglePages")
.description("List single pages.")
.tag(tag)
.response(responseBuilder()
.implementation(ListResult.generateGenericClass(ListedSinglePage.class))
);
QueryParamBuildUtil.buildParametersFromType(builder, SinglePageQuery.class);
}
)
.POST("singlepages", this::draftSinglePage,
builder -> builder.operationId("DraftSinglePage")
.description("Draft a single page.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(SinglePageRequest.class))
))
.response(responseBuilder()
.implementation(SinglePage.class))
)
.PUT("singlepages/{name}", this::updateSinglePage,
builder -> builder.operationId("UpdateDraftSinglePage")
.description("Update a single page.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(SinglePageRequest.class))
))
.response(responseBuilder()
.implementation(SinglePage.class))
)
.PUT("singlepages/{name}/publish", this::publishSinglePage,
builder -> builder.operationId("PublishSinglePage")
.description("Publish a single page.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(SinglePage.class))
)
.build();
}
Mono<ServerResponse> draftSinglePage(ServerRequest request) {
return request.bodyToMono(SinglePageRequest.class)
.flatMap(singlePageService::draft)
.flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage));
}
Mono<ServerResponse> updateSinglePage(ServerRequest request) {
return request.bodyToMono(SinglePageRequest.class)
.flatMap(singlePageService::update)
.flatMap(page -> ServerResponse.ok().bodyValue(page));
}
Mono<ServerResponse> publishSinglePage(ServerRequest request) {
String name = request.pathVariable("name");
return singlePageService.publish(name)
.flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage));
}
Mono<ServerResponse> listSinglePage(ServerRequest request) {
var listRequest = new SinglePageQuery(request.queryParams());
return singlePageService.list(listRequest)
.flatMap(listedPages -> ServerResponse.ok().bodyValue(listedPages));
}
}

View File

@ -7,6 +7,8 @@ import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Reconciler for {@link Category}.
*
* @author guqing
* @since 2.0.0
*/
@ -36,10 +38,8 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
}
private void reconcilePermalink(Category category) {
if (category.getStatusOrDefault().getPermalink() == null) {
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
}
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
if (category.getMetadata().getDeletionTimestamp() != null) {
categoryPermalinkPolicy.onPermalinkDelete(category);

View File

@ -61,10 +61,8 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
}
private void permalinkReconcile(Post post) {
if (post.getStatusOrDefault().getPermalink() == null) {
post.getStatusOrDefault()
.setPermalink(postPermalinkPolicy.permalink(post));
}
post.getStatusOrDefault()
.setPermalink(postPermalinkPolicy.permalink(post));
if (Objects.equals(true, post.getSpec().getDeleted())
|| post.getMetadata().getDeletionTimestamp() != null
@ -143,7 +141,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
// handle logic delete
Map<String, String> labels = getLabelsOrDefault(post);
if (Objects.equals(spec.getDeleted(), true)) {
if (isDeleted(post)) {
labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString());
// TODO do more about logic delete such as remove router
} else {
@ -176,4 +174,9 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
private boolean isPublished(Snapshot snapshot) {
return snapshot.getSpec().getPublishTime() != null;
}
private boolean isDeleted(Post post) {
return Objects.equals(true, post.getSpec().getDeleted())
|| post.getMetadata().getDeletionTimestamp() != null;
}
}

View File

@ -0,0 +1,259 @@
package run.halo.app.core.extension.reconciler;
import java.time.Instant;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.context.ApplicationContext;
import org.springframework.util.Assert;
import run.halo.app.content.ContentService;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.TemplateRouteManager;
/**
* <p>Reconciler for {@link SinglePage}.</p>
*
* <p>things to do:</p>
* <ul>
* 1. generate permalink
* 2. generate excerpt if auto generate is enabled
* </ul>
*
* @author guqing
* @since 2.0.0
*/
public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER_NAME = "single-page-protection";
private static final GroupVersionKind GVK = GroupVersionKind.fromExtension(SinglePage.class);
private final ExtensionClient client;
private final ContentService contentService;
private final ApplicationContext applicationContext;
private final TemplateRouteManager templateRouteManager;
public SinglePageReconciler(ExtensionClient client, ContentService contentService,
ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) {
this.client = client;
this.contentService = contentService;
this.applicationContext = applicationContext;
this.templateRouteManager = templateRouteManager;
}
@Override
public Result reconcile(Request request) {
client.fetch(SinglePage.class, request.name())
.ifPresent(singlePage -> {
SinglePage oldPage = JsonUtils.deepCopy(singlePage);
if (isDeleted(oldPage)) {
cleanUpResourcesAndRemoveFinalizer(request.name());
return;
}
addFinalizerIfNecessary(oldPage);
reconcileStatus(request.name());
reconcileMetadata(request.name());
});
return new Result(false, null);
}
private void addFinalizerIfNecessary(SinglePage oldSinglePage) {
Set<String> finalizers = oldSinglePage.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(SinglePage.class, oldSinglePage.getMetadata().getName())
.ifPresent(singlePage -> {
Set<String> newFinalizers = singlePage.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
singlePage.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(singlePage);
});
}
private void cleanUpResources(SinglePage singlePage) {
// remove permalink from permalink indexer
permalinkOnDelete(singlePage);
}
private void cleanUpResourcesAndRemoveFinalizer(String pageName) {
client.fetch(SinglePage.class, pageName).ifPresent(singlePage -> {
cleanUpResources(singlePage);
if (singlePage.getMetadata().getFinalizers() != null) {
singlePage.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(singlePage);
});
}
private void reconcileMetadata(String name) {
client.fetch(SinglePage.class, name).ifPresent(singlePage -> {
final SinglePage oldPage = JsonUtils.deepCopy(singlePage);
SinglePage.SinglePageSpec spec = singlePage.getSpec();
// handle logic delete
Map<String, String> labels = getLabelsOrDefault(singlePage);
if (isDeleted(singlePage)) {
labels.put(SinglePage.DELETED_LABEL, Boolean.TRUE.toString());
} else {
labels.put(SinglePage.DELETED_LABEL, Boolean.FALSE.toString());
}
// synchronize some fields to labels to query
labels.put(SinglePage.PHASE_LABEL, singlePage.getStatusOrDefault().getPhase());
labels.put(SinglePage.VISIBLE_LABEL,
Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name());
labels.put(SinglePage.OWNER_LABEL, spec.getOwner());
if (!oldPage.equals(singlePage)) {
client.update(singlePage);
}
});
}
private void permalinkOnDelete(SinglePage singlePage) {
singlePage.getStatusOrDefault()
.setPermalink(PathUtils.combinePath(singlePage.getSpec().getSlug()));
ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(),
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, locator,
singlePage.getStatusOrDefault().getPermalink()));
templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue());
}
private void permalinkOnAdd(SinglePage singlePage) {
ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(),
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, locator,
singlePage.getStatusOrDefault().getPermalink()));
templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue());
}
private void reconcileStatus(String name) {
client.fetch(SinglePage.class, name).ifPresent(singlePage -> {
final SinglePage oldPage = JsonUtils.deepCopy(singlePage);
permalinkOnDelete(oldPage);
singlePage.getStatusOrDefault()
.setPermalink(PathUtils.combinePath(singlePage.getSpec().getSlug()));
if (isPublished(singlePage)) {
permalinkOnAdd(singlePage);
}
SinglePage.SinglePageSpec spec = singlePage.getSpec();
SinglePage.SinglePageStatus status = singlePage.getStatusOrDefault();
if (status.getPhase() == null) {
status.setPhase(Post.PostPhase.DRAFT.name());
}
// handle excerpt
Post.Excerpt excerpt = spec.getExcerpt();
if (excerpt == null) {
excerpt = new Post.Excerpt();
excerpt.setAutoGenerate(true);
spec.setExcerpt(excerpt);
}
if (excerpt.getAutoGenerate()) {
contentService.getContent(spec.getHeadSnapshot())
.subscribe(content -> {
String contentRevised = content.content();
status.setExcerpt(getExcerpt(contentRevised));
});
} else {
status.setExcerpt(excerpt.getRaw());
}
// handle contributors
String headSnapshot = singlePage.getSpec().getHeadSnapshot();
contentService.listSnapshots(Snapshot.SubjectRef.of(SinglePage.KIND, name))
.collectList()
.subscribe(snapshots -> {
List<String> contributors = snapshots.stream()
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
() -> new HashSet<String>());
})
.flatMap(Set::stream)
.distinct()
.sorted()
.toList();
status.setContributors(contributors);
// update in progress status
snapshots.stream()
.filter(snapshot -> snapshot.getMetadata().getName().equals(headSnapshot))
.findAny()
.ifPresent(snapshot -> {
status.setInProgress(!isPublished(snapshot));
});
});
// handle cancel publish,has released version and published is false and not handled
if (StringUtils.isNotBlank(spec.getReleaseSnapshot())
&& Objects.equals(false, spec.getPublished())
&& !StringUtils.equals(status.getPhase(), Post.PostPhase.DRAFT.name())) {
Condition condition = new Condition();
condition.setType("CancelledPublish");
condition.setStatus(ConditionStatus.TRUE);
condition.setReason(condition.getType());
condition.setMessage(StringUtils.EMPTY);
condition.setLastTransitionTime(Instant.now());
status.getConditionsOrDefault().add(condition);
status.setPhase(Post.PostPhase.DRAFT.name());
}
if (!oldPage.equals(singlePage)) {
client.update(singlePage);
}
});
}
private Map<String, String> getLabelsOrDefault(SinglePage singlePage) {
Assert.notNull(singlePage, "The singlePage must not be null.");
Map<String, String> labels = singlePage.getMetadata().getLabels();
if (labels == null) {
labels = new LinkedHashMap<>();
singlePage.getMetadata().setLabels(labels);
}
return labels;
}
private String getExcerpt(String htmlContent) {
String shortHtmlContent = StringUtils.substring(htmlContent, 0, 500);
String text = Jsoup.parse(shortHtmlContent).text();
// TODO The default capture 150 words as excerpt
return StringUtils.substring(text, 0, 150);
}
private boolean isPublished(Snapshot snapshot) {
return snapshot.getSpec().getPublishTime() != null;
}
private boolean isPublished(SinglePage singlePage) {
return Objects.equals(true, singlePage.getSpec().getPublished());
}
private boolean isDeleted(SinglePage singlePage) {
return Objects.equals(true, singlePage.getSpec().getDeleted())
|| singlePage.getMetadata().getDeletionTimestamp() != null;
}
}

View File

@ -7,6 +7,8 @@ import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Reconciler for {@link Tag}.
*
* @author guqing
* @since 2.0.0
*/
@ -35,10 +37,8 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
}
private void reconcilePermalink(Tag tag) {
if (tag.getStatusOrDefault().getPermalink() == null) {
tag.getStatusOrDefault()
.setPermalink(tagPermalinkPolicy.permalink(tag));
}
tag.getStatusOrDefault()
.setPermalink(tagPermalinkPolicy.permalink(tag));
if (tag.getMetadata().getDeletionTimestamp() != null) {
tagPermalinkPolicy.onPermalinkDelete(tag);

View File

@ -15,6 +15,7 @@ import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot;
import run.halo.app.core.extension.Tag;
import run.halo.app.core.extension.Theme;
@ -55,6 +56,7 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
schemeManager.register(Snapshot.class);
schemeManager.register(Comment.class);
schemeManager.register(Reply.class);
schemeManager.register(SinglePage.class);
// storage.halo.run
schemeManager.register(Group.class);
schemeManager.register(Policy.class);

View File

@ -17,7 +17,9 @@ public enum DefaultTemplateEnum {
TAG("tag"),
TAGS("tags");
TAGS("tags"),
SINGLE_PAGE("page");
private final String value;

View File

@ -0,0 +1,22 @@
package run.halo.app.theme.finders;
import org.springframework.lang.Nullable;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.ListResult;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* A finder for {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
public interface SinglePageFinder {
SinglePageVo getByName(String pageName);
ContentVo content(String pageName);
ListResult<SinglePageVo> list(@Nullable Integer page, @Nullable Integer size);
}

View File

@ -0,0 +1,115 @@
package run.halo.app.theme.finders.impl;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.apache.commons.lang3.ObjectUtils;
import run.halo.app.content.ContentService;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.Contributor;
import run.halo.app.theme.finders.vo.SinglePageVo;
/**
* A default implementation of {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
@Finder("singlePageFinder")
public class SinglePageFinderImpl implements SinglePageFinder {
public static final Predicate<SinglePage> FIXED_PREDICATE = page ->
Objects.equals(false, page.getSpec().getDeleted())
&& Objects.equals(true, page.getSpec().getPublished())
&& Post.VisibleEnum.PUBLIC.equals(page.getSpec().getVisible());
private final ReactiveExtensionClient client;
private final ContentService contentService;
private final ContributorFinder contributorFinder;
public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService,
ContributorFinder contributorFinder) {
this.client = client;
this.contentService = contentService;
this.contributorFinder = contributorFinder;
}
@Override
public SinglePageVo getByName(String pageName) {
SinglePage page = client.fetch(SinglePage.class, pageName)
.block();
if (page == null) {
return null;
}
List<Contributor> contributors =
contributorFinder.getContributors(page.getStatus().getContributors());
SinglePageVo pageVo = SinglePageVo.from(page);
pageVo.setContributors(contributors);
return pageVo;
}
@Override
public ContentVo content(String pageName) {
return client.fetch(SinglePage.class, pageName)
.map(page -> page.getSpec().getReleaseSnapshot())
.flatMap(contentService::getContent)
.map(wrapper -> ContentVo.builder().content(wrapper.content())
.raw(wrapper.raw()).build())
.block();
}
@Override
public ListResult<SinglePageVo> list(Integer page, Integer size) {
ListResult<SinglePage> list = client.list(SinglePage.class, FIXED_PREDICATE,
defaultComparator(), pageNullSafe(page), sizeNullSafe(size))
.block();
if (list == null) {
return new ListResult<>(0, 0, 0, List.of());
}
List<SinglePageVo> postVos = list.get()
.map(sp -> {
List<Contributor> contributors =
contributorFinder.getContributors(sp.getStatus().getContributors());
SinglePageVo pageVo = SinglePageVo.from(sp);
pageVo.setContributors(contributors);
return pageVo;
})
.toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos);
}
static Comparator<SinglePage> defaultComparator() {
Function<SinglePage, Boolean> pinned =
page -> Objects.requireNonNullElse(page.getSpec().getPinned(), false);
Function<SinglePage, Integer> priority =
page -> Objects.requireNonNullElse(page.getSpec().getPriority(), 0);
Function<SinglePage, Instant> creationTimestamp =
page -> page.getMetadata().getCreationTimestamp();
Function<SinglePage, String> name = page -> page.getMetadata().getName();
return Comparator.comparing(pinned)
.thenComparing(priority)
.thenComparing(creationTimestamp)
.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,61 @@
package run.halo.app.theme.finders.vo;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.Data;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import run.halo.app.core.extension.Post;
/**
* a base entity for post.
*
* @author guqing
* @since 2.0.0
*/
@Data
@ToString
@SuperBuilder
public abstract class BasePostVo {
String name;
String title;
String slug;
String owner;
String template;
String cover;
Boolean published;
Instant publishTime;
Boolean pinned;
Boolean allowComment;
Post.VisibleEnum visible;
Integer version;
Integer priority;
String excerpt;
List<Map<String, String>> htmlMetas;
String permalink;
List<Contributor> contributors;
Map<String, String> annotations;
static <T> List<T> nullSafe(List<T> t) {
return Objects.requireNonNullElse(t, List.of());
}
}

View File

@ -1,11 +1,9 @@
package run.halo.app.theme.finders.vo;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.SuperBuilder;
import org.springframework.util.Assert;
import run.halo.app.core.extension.Post;
@ -16,49 +14,14 @@ import run.halo.app.core.extension.Post;
* @since 2.0.0
*/
@Data
@Builder
public class PostVo {
String name;
String title;
String slug;
String owner;
String template;
String cover;
Boolean published;
Instant publishTime;
Boolean pinned;
Boolean allowComment;
Post.VisibleEnum visible;
Integer version;
Integer priority;
String excerpt;
@SuperBuilder
@EqualsAndHashCode(callSuper = true)
public class PostVo extends BasePostVo {
List<CategoryVo> categories;
List<TagVo> tags;
List<Map<String, String>> htmlMetas;
String permalink;
List<Contributor> contributors;
Map<String, String> annotations;
/**
* Convert {@link Post} to {@link PostVo}.
*
@ -92,8 +55,4 @@ public class PostVo {
.contributors(List.of())
.build();
}
static <T> List<T> nullSafe(List<T> t) {
return Objects.requireNonNullElse(t, List.of());
}
}

View File

@ -0,0 +1,54 @@
package run.halo.app.theme.finders.vo;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import org.springframework.util.Assert;
import run.halo.app.core.extension.SinglePage;
/**
* A value object for {@link SinglePage}.
*
* @author guqing
* @since 2.0.0
*/
@Data
@SuperBuilder
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class SinglePageVo extends BasePostVo {
/**
* Convert {@link SinglePage} to {@link SinglePageVo}.
*
* @param singlePage single page extension
* @return special page value object
*/
public static SinglePageVo from(SinglePage singlePage) {
Assert.notNull(singlePage, "The singlePage must not be null.");
SinglePage.SinglePageSpec spec = singlePage.getSpec();
SinglePage.SinglePageStatus pageStatus = singlePage.getStatus();
return SinglePageVo.builder()
.name(singlePage.getMetadata().getName())
.annotations(singlePage.getMetadata().getAnnotations())
.title(spec.getTitle())
.cover(spec.getCover())
.allowComment(spec.getAllowComment())
.owner(spec.getOwner())
.pinned(spec.getPinned())
.slug(spec.getSlug())
.htmlMetas(nullSafe(spec.getHtmlMetas()))
.published(spec.getPublished())
.publishTime(spec.getPublishTime())
.priority(spec.getPriority())
.version(spec.getVersion())
.visible(spec.getVisible())
.template(spec.getTemplate())
.permalink(pageStatus.getPermalink())
.excerpt(pageStatus.getExcerpt())
.contributors(List.of())
.build();
}
}

View File

@ -51,7 +51,7 @@ public class PermalinkPatternProvider {
public String getPattern(DefaultTemplateEnum defaultTemplateEnum) {
SystemSetting.ThemeRouteRules permalinkRules = getPermalinkRules();
return switch (defaultTemplateEnum) {
case INDEX -> null;
case INDEX, SINGLE_PAGE -> null;
case POST -> permalinkRules.getPost();
case ARCHIVES -> permalinkRules.getArchives();
case CATEGORY, CATEGORIES -> permalinkRules.getCategories();

View File

@ -16,6 +16,7 @@ import run.halo.app.theme.router.strategy.CategoriesRouteStrategy;
import run.halo.app.theme.router.strategy.CategoryRouteStrategy;
import run.halo.app.theme.router.strategy.IndexRouteStrategy;
import run.halo.app.theme.router.strategy.PostRouteStrategy;
import run.halo.app.theme.router.strategy.SinglePageRouteStrategy;
import run.halo.app.theme.router.strategy.TagRouteStrategy;
import run.halo.app.theme.router.strategy.TagsRouteStrategy;
@ -113,6 +114,7 @@ public class TemplateRouteManager implements ApplicationListener<ApplicationRead
case TAG -> new TagRouteStrategy(permalinkIndexer);
case CATEGORIES -> new CategoriesRouteStrategy();
case CATEGORY -> new CategoryRouteStrategy(permalinkIndexer);
case SINGLE_PAGE -> new SinglePageRouteStrategy(permalinkIndexer);
};
}

View File

@ -0,0 +1,59 @@
package run.halo.app.theme.router.strategy;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexer;
import run.halo.app.theme.router.TemplateRouterStrategy;
/**
* The {@link SinglePageRouteStrategy} for generate {@link RouterFunction} specific to the template
* <code>page.html</code>.
*
* @author guqing
* @since 2.0.0
*/
public class SinglePageRouteStrategy implements TemplateRouterStrategy {
private final PermalinkIndexer permalinkIndexer;
public SinglePageRouteStrategy(PermalinkIndexer permalinkIndexer) {
this.permalinkIndexer = permalinkIndexer;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) {
GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
RequestPredicate requestPredicate = request -> false;
List<String> permalinks =
Objects.requireNonNullElse(permalinkIndexer.getPermalinks(gvk), List.of());
for (String permalink : permalinks) {
requestPredicate = requestPredicate.or(RequestPredicates.GET(permalink));
}
return RouterFunctions
.route(requestPredicate.and(accept(MediaType.TEXT_HTML)), request -> {
String slug = StringUtils.removeStart(request.path(), "/");
String name = permalinkIndexer.getNameBySlug(gvk, slug);
if (name == null) {
return ServerResponse.notFound().build();
}
return ServerResponse.ok()
.render(DefaultTemplateEnum.SINGLE_PAGE.getValue(), Map.of("name", name));
});
}
}

View File

@ -5,12 +5,13 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import run.halo.app.content.ContentService;
import run.halo.app.content.PostQuery;
import run.halo.app.content.TestPost;
@ -40,8 +41,9 @@ class PostServiceImplTest {
@Test
void listPredicate() {
PostQuery postQuery = new PostQuery();
postQuery.setCategories(Set.of("category1", "category2"));
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();
multiValueMap.put("category", List.of("category1", "category2"));
PostQuery postQuery = new PostQuery(multiValueMap);
Post post = TestPost.postV1();
post.getSpec().setTags(null);
@ -55,21 +57,23 @@ class PostServiceImplTest {
assertThat(test).isTrue();
// Do not include tags
postQuery.setTags(Set.of("tag2"));
multiValueMap.put("tag", List.of("tag2"));
post.getSpec().setTags(List.of("tag1"));
post.getSpec().setCategories(null);
test = postService.postListPredicate(postQuery).test(post);
assertThat(test).isFalse();
postQuery.setTags(Set.of());
multiValueMap.put("tag", List.of());
multiValueMap.remove("category");
postQuery = new PostQuery(multiValueMap);
post.getSpec().setTags(List.of());
test = postService.postListPredicate(postQuery).test(post);
assertThat(test).isTrue();
postQuery.setLabelSelector(List.of("hello"));
multiValueMap.put("labelSelector", List.of("hello"));
test = postService.postListPredicate(postQuery).test(post);
assertThat(test).isFalse();
postQuery.setLabelSelector(List.of("hello"));
post.getMetadata().setLabels(Map.of("hello", "world"));
test = postService.postListPredicate(postQuery).test(post);
assertThat(test).isTrue();

View File

@ -0,0 +1,122 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.content.TestPost.snapshotV1;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentService;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
import run.halo.app.theme.router.TemplateRouteManager;
/**
* Tests for {@link SinglePageReconciler}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageReconcilerTest {
@Mock
private ExtensionClient client;
@Mock
private ContentService contentService;
@Mock
private ApplicationContext applicationContext;
@Mock
private TemplateRouteManager templateRouteManager;
private SinglePageReconciler singlePageReconciler;
@BeforeEach
void setUp() {
singlePageReconciler = new SinglePageReconciler(client, contentService, applicationContext,
templateRouteManager);
}
@Test
void reconcile() {
String name = "page-A";
SinglePage page = pageV1();
page.getSpec().setHeadSnapshot("page-A-head-snapshot");
when(client.fetch(eq(SinglePage.class), eq(name)))
.thenReturn(Optional.of(page));
when(contentService.getContent(eq(page.getSpec().getHeadSnapshot())))
.thenReturn(Mono.just(
new ContentWrapper(page.getSpec().getHeadSnapshot(), "hello world",
"<p>hello world</p>", "markdown")));
Snapshot snapshotV1 = snapshotV1();
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV1.getSpec().setContributors(Set.of("guqing"));
snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan"));
when(contentService.listSnapshots(any()))
.thenReturn(Flux.just(snapshotV1, snapshotV2));
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);
singlePageReconciler.reconcile(new Reconciler.Request(name));
verify(client, times(3)).update(captor.capture());
SinglePage value = captor.getValue();
assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world");
assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan"));
verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexAddCommand.class));
verify(applicationContext, times(1)).publishEvent(isA(PermalinkIndexDeleteCommand.class));
verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexUpdateCommand.class));
verify(templateRouteManager, times(1))
.changeTemplatePattern(eq(DefaultTemplateEnum.SINGLE_PAGE.getValue()));
}
public static SinglePage pageV1() {
SinglePage page = new SinglePage();
page.setKind(Post.KIND);
page.setApiVersion("content.halo.run/v1alpha1");
Metadata metadata = new Metadata();
metadata.setName("page-A");
page.setMetadata(metadata);
SinglePage.SinglePageSpec spec = new SinglePage.SinglePageSpec();
page.setSpec(spec);
spec.setTitle("page-A");
spec.setSlug("page-slug");
spec.setVersion(1);
spec.setBaseSnapshot(snapshotV1().getMetadata().getName());
spec.setHeadSnapshot("base-snapshot");
spec.setReleaseSnapshot(null);
return page;
}
}

View File

@ -0,0 +1,76 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
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.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexer;
/**
* Tests for {@link SinglePageRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageRouteStrategyTest {
@Mock
private PermalinkIndexer permalinkIndexer;
@Mock
private ViewResolver viewResolver;
private SinglePageRouteStrategy strategy;
@BeforeEach
void setUp() {
strategy = new SinglePageRouteStrategy(permalinkIndexer);
when(permalinkIndexer.getPermalinks(any()))
.thenReturn(List.of("/fake-slug"));
when(permalinkIndexer.getNameBySlug(any(), eq("fake-slug")))
.thenReturn("fake-name");
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
strategy.getRouteFunction(DefaultTemplateEnum.SINGLE_PAGE.getValue(),
null);
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.SINGLE_PAGE.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
client.get()
.uri("/fake-slug")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}