perf: cannot be accessed for a long time during startup (#3300)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.3.x
#### What this PR does / why we need it:
能通过注册 pattern 作为路由的就直接注册 pattern 以避免 Reconciler 还没结束而无法注册路由导致的访问问题。
1. 文章、标签、分类的 permalink 规则会记录在对应 extension  的 annotations 中为  `content.halo.run/permalink-pattern: some-pattern` ,当系统设置中路由规则改变时会刷一遍这些资源的 `content.halo.run/permalink-pattern` annotation。
3. 自定义页面的访问是通过 SinglePageRoute 控制,它会在 single page 添加、更新、删除时维护 quickRouteMap 的集合,在路由到它时直接通过 request path 查找 map key,找到则直接返回 HandleFunction。
4. 除了自定义页面外其他都是通过 ThemeCompositeRouterFunction 作为路由管理器,系统启动时文章等的 RouterFunction 会被生成并缓存到 cachedRouters 的一个 List 集合中,当路由规则改变会清理它重新赋值。

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

Fixes #3254 

#### Special notes for your reviewer:
how  to test it?
1.  测试首页、文章、标签、分类、归档页、自定义页面和作者页等的访问。
6. 测试添加、删除资源和修改系统路由规则后上述资源的访问。
7. 测试分页路径如 /tags/slug/page/1 的访问。

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
 优化启动时页面长时间无法访问的问题
```
pull/3363/head
guqing 2023-02-24 17:38:44 +08:00 committed by GitHub
parent ce80ed4283
commit 9fff768134
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 1547 additions and 3776 deletions

View File

@ -3,79 +3,46 @@ package run.halo.app.content.permalinks;
import static org.springframework.web.util.UriUtils.encode;
import java.nio.charset.StandardCharsets;
import org.springframework.context.ApplicationContext;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
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.PermalinkIndexUpdateCommand;
import run.halo.app.theme.router.PermalinkPatternProvider;
import run.halo.app.theme.router.PermalinkWatch;
/**
* @author guqing
* @since 2.0.0
*/
@Component
public class CategoryPermalinkPolicy
implements PermalinkPolicy<Category>, PermalinkWatch<Category> {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Category.class);
private final ApplicationContext applicationContext;
private final PermalinkPatternProvider permalinkPatternProvider;
@RequiredArgsConstructor
public class CategoryPermalinkPolicy implements PermalinkPolicy<Category> {
public static final String DEFAULT_PERMALINK_PREFIX =
SystemSetting.ThemeRouteRules.empty().getCategories();
private final ExternalUrlSupplier externalUrlSupplier;
public CategoryPermalinkPolicy(ApplicationContext applicationContext,
PermalinkPatternProvider permalinkPatternProvider,
ExternalUrlSupplier externalUrlSupplier) {
this.applicationContext = applicationContext;
this.permalinkPatternProvider = permalinkPatternProvider;
this.externalUrlSupplier = externalUrlSupplier;
}
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public String permalink(Category category) {
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(category);
String permalinkPrefix =
annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PREFIX);
String slug = encode(category.getSpec().getSlug(), StandardCharsets.UTF_8);
String path = PathUtils.combinePath(pattern(), slug);
String path = PathUtils.combinePath(permalinkPrefix, slug);
return externalUrlSupplier.get()
.resolve(path)
.normalize().toString();
}
@Override
public String templateName() {
return DefaultTemplateEnum.CATEGORY.getValue();
}
@Override
public String pattern() {
return permalinkPatternProvider.getPattern(DefaultTemplateEnum.CATEGORY);
}
@Override
public void onPermalinkAdd(Category category) {
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, getLocator(category),
category.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkUpdate(Category category) {
applicationContext.publishEvent(new PermalinkIndexUpdateCommand(this, getLocator(category),
category.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkDelete(Category category) {
applicationContext.publishEvent(
new PermalinkIndexDeleteCommand(this, getLocator(category)));
}
private ExtensionLocator getLocator(Category category) {
return new ExtensionLocator(gvk, category.getMetadata().getName(),
category.getSpec().getSlug());
return environmentFetcher.fetchRouteRules()
.map(SystemSetting.ThemeRouteRules::getCategories)
.blockOptional()
.orElse(DEFAULT_PERMALINK_PREFIX);
}
}

View File

@ -13,8 +13,4 @@ public interface PermalinkPolicy<T extends AbstractExtension> {
new PropertyPlaceholderHelper("{", "}");
String permalink(T extension);
String templateName();
String pattern();
}

View File

@ -8,79 +8,45 @@ import java.text.NumberFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Objects;
import java.util.Map;
import java.util.Properties;
import org.springframework.context.ApplicationContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
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.PermalinkIndexUpdateCommand;
import run.halo.app.theme.router.PermalinkPatternProvider;
import run.halo.app.theme.router.PermalinkWatch;
/**
* @author guqing
* @since 2.0.0
*/
@Component
public class PostPermalinkPolicy implements PermalinkPolicy<Post>, PermalinkWatch<Post> {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Post.class);
@RequiredArgsConstructor
public class PostPermalinkPolicy implements PermalinkPolicy<Post> {
public static final String DEFAULT_PERMALINK_PATTERN =
SystemSetting.ThemeRouteRules.empty().getPost();
private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00");
private final PermalinkPatternProvider permalinkPatternProvider;
private final ApplicationContext applicationContext;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final ExternalUrlSupplier externalUrlSupplier;
public PostPermalinkPolicy(PermalinkPatternProvider permalinkPatternProvider,
ApplicationContext applicationContext, ExternalUrlSupplier externalUrlSupplier) {
this.permalinkPatternProvider = permalinkPatternProvider;
this.applicationContext = applicationContext;
this.externalUrlSupplier = externalUrlSupplier;
}
@Override
public String permalink(Post post) {
return createPermalink(post, pattern());
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(post);
String permalinkPattern =
annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PATTERN);
return createPermalink(post, permalinkPattern);
}
@Override
public String templateName() {
return DefaultTemplateEnum.POST.getValue();
}
@Override
public String pattern() {
return permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST);
}
@Override
public void onPermalinkAdd(Post post) {
if (!post.isPublished() || Objects.equals(true, post.getSpec().getDeleted())) {
return;
}
// publish when post is published and not deleted
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, getLocator(post),
post.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkUpdate(Post post) {
applicationContext.publishEvent(new PermalinkIndexUpdateCommand(this, getLocator(post),
post.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkDelete(Post post) {
applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, getLocator(post)));
}
private ExtensionLocator getLocator(Post post) {
return new ExtensionLocator(gvk, post.getMetadata().getName(), post.getSpec().getSlug());
return environmentFetcher.fetchRouteRules()
.map(SystemSetting.ThemeRouteRules::getPost)
.blockOptional()
.orElse(DEFAULT_PERMALINK_PATTERN);
}
private String createPermalink(Post post, String pattern) {

View File

@ -1,76 +1,47 @@
package run.halo.app.content.permalinks;
import java.nio.charset.StandardCharsets;
import org.springframework.context.ApplicationContext;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriUtils;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
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.PermalinkIndexUpdateCommand;
import run.halo.app.theme.router.PermalinkPatternProvider;
import run.halo.app.theme.router.PermalinkWatch;
/**
* @author guqing
* @since 2.0.0
*/
@Component
public class TagPermalinkPolicy implements PermalinkPolicy<Tag>, PermalinkWatch<Tag> {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Tag.class);
private final PermalinkPatternProvider permalinkPatternProvider;
private final ApplicationContext applicationContext;
@RequiredArgsConstructor
public class TagPermalinkPolicy implements PermalinkPolicy<Tag> {
public static final String DEFAULT_PERMALINK_PREFIX =
SystemSetting.ThemeRouteRules.empty().getTags();
private final ExternalUrlSupplier externalUrlSupplier;
public TagPermalinkPolicy(PermalinkPatternProvider permalinkPatternProvider,
ApplicationContext applicationContext, ExternalUrlSupplier externalUrlSupplier) {
this.permalinkPatternProvider = permalinkPatternProvider;
this.applicationContext = applicationContext;
this.externalUrlSupplier = externalUrlSupplier;
}
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public String permalink(Tag tag) {
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(tag);
String permalinkPrefix =
annotations.getOrDefault(Constant.PERMALINK_PATTERN_ANNO, DEFAULT_PERMALINK_PREFIX);
String slug = UriUtils.encode(tag.getSpec().getSlug(), StandardCharsets.UTF_8);
String path = PathUtils.combinePath(pattern(), slug);
String path = PathUtils.combinePath(permalinkPrefix, slug);
return externalUrlSupplier.get()
.resolve(path)
.normalize().toString();
}
@Override
public String templateName() {
return DefaultTemplateEnum.TAG.getValue();
}
@Override
public String pattern() {
return permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAG);
}
@Override
public void onPermalinkAdd(Tag tag) {
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, getLocator(tag),
tag.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkUpdate(Tag tag) {
applicationContext.publishEvent(new PermalinkIndexUpdateCommand(this, getLocator(tag),
tag.getStatusOrDefault().getPermalink()));
}
@Override
public void onPermalinkDelete(Tag tag) {
applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, getLocator(tag)));
}
private ExtensionLocator getLocator(Tag tag) {
return new ExtensionLocator(gvk, tag.getMetadata().getName(), tag.getSpec().getSlug());
return environmentFetcher.fetchRouteRules()
.map(SystemSetting.ThemeRouteRules::getTags)
.blockOptional()
.orElse(DEFAULT_PERMALINK_PREFIX);
}
}

View File

@ -7,4 +7,5 @@ public enum Constant {
public static final String VERSION = "v1alpha1";
public static final String LAST_READ_TIME_ANNO = "content.halo.run/last-read-time";
public static final String PERMALINK_PATTERN_ANNO = "content.halo.run/permalink-pattern";
}

View File

@ -4,6 +4,7 @@ import static java.lang.Boolean.parseBoolean;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@ -48,8 +49,9 @@ public class Post extends AbstractExtension {
public static final String ARCHIVE_YEAR_LABEL = "content.halo.run/archive-year";
public static final String ARCHIVE_MONTH_LABEL = "content.halo.run/archive-month";
public static final String ARCHIVE_DAY_LABEL = "content.halo.run/archive-day";
@Schema(required = true)
@Schema(requiredMode = RequiredMode.REQUIRED)
private PostSpec spec;
@Schema
@ -81,10 +83,10 @@ public class Post extends AbstractExtension {
@Data
public static class PostSpec {
@Schema(required = true, minLength = 1)
@Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1)
private String title;
@Schema(required = true, minLength = 1)
@Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1)
private String slug;
/**
@ -102,27 +104,27 @@ public class Post extends AbstractExtension {
private String cover;
@Schema(required = true, defaultValue = "false")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false")
private Boolean deleted;
@Schema(required = true, defaultValue = "false")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false")
private Boolean publish;
private Instant publishTime;
@Schema(required = true, defaultValue = "false")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false")
private Boolean pinned;
@Schema(required = true, defaultValue = "true")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "true")
private Boolean allowComment;
@Schema(required = true, defaultValue = "PUBLIC")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "PUBLIC")
private VisibleEnum visible;
@Schema(required = true, defaultValue = "0")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "0")
private Integer priority;
@Schema(required = true)
@Schema(requiredMode = RequiredMode.REQUIRED)
private Excerpt excerpt;
private List<String> categories;
@ -134,7 +136,7 @@ public class Post extends AbstractExtension {
@Data
public static class PostStatus {
@Schema(required = true)
@Schema(requiredMode = RequiredMode.REQUIRED)
private String phase;
@Schema
@ -164,7 +166,7 @@ public class Post extends AbstractExtension {
@Data
public static class Excerpt {
@Schema(required = true, defaultValue = "true")
@Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "true")
private Boolean autoGenerate;
private String raw;

View File

@ -11,12 +11,16 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
@ -29,17 +33,12 @@ import run.halo.app.infra.utils.JsonUtils;
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class CategoryReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER_NAME = "category-protection";
private final ExtensionClient client;
private final CategoryPermalinkPolicy categoryPermalinkPolicy;
public CategoryReconciler(ExtensionClient client,
CategoryPermalinkPolicy categoryPermalinkPolicy) {
this.client = client;
this.categoryPermalinkPolicy = categoryPermalinkPolicy;
}
@Override
public Result reconcile(Request request) {
return client.fetch(Category.class, request.name())
@ -50,6 +49,8 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
}
addFinalizerIfNecessary(category);
reconcileMetadata(request.name());
reconcileStatusPermalink(request.name());
reconcileStatusPosts(request.name());
@ -65,6 +66,20 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
.build();
}
void reconcileMetadata(String name) {
client.fetch(Category.class, name).ifPresent(category -> {
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(category);
String oldPermalinkPattern = annotations.get(Constant.PERMALINK_PATTERN_ANNO);
String newPattern = categoryPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!StringUtils.equals(oldPermalinkPattern, newPattern)) {
client.update(category);
}
});
}
private void addFinalizerIfNecessary(Category oldCategory) {
Set<String> finalizers = oldCategory.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
@ -82,14 +97,8 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
});
}
private void cleanUpResources(Category category) {
// remove permalink from permalink indexer
categoryPermalinkPolicy.onPermalinkDelete(category);
}
private void cleanUpResourcesAndRemoveFinalizer(String categoryName) {
client.fetch(Category.class, categoryName).ifPresent(category -> {
cleanUpResources(category);
if (category.getMetadata().getFinalizers() != null) {
category.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
@ -101,11 +110,8 @@ public class CategoryReconciler implements Reconciler<Reconciler.Request> {
client.fetch(Category.class, name)
.ifPresent(category -> {
Category oldCategory = JsonUtils.deepCopy(category);
categoryPermalinkPolicy.onPermalinkDelete(oldCategory);
category.getStatusOrDefault()
.setPermalink(categoryPermalinkPolicy.permalink(category));
categoryPermalinkPolicy.onPermalinkAdd(category);
if (!oldCategory.equals(category)) {
client.update(category);

View File

@ -16,6 +16,7 @@ import org.springframework.util.Assert;
import run.halo.app.content.PostService;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.event.post.PostPublishedEvent;
@ -237,10 +238,16 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}
if (!labels.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, Boolean.FALSE.toString());
}
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(post);
String newPattern = postPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!oldPost.equals(post)) {
client.update(post);
}
@ -250,11 +257,9 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
private void reconcileStatus(String name) {
client.fetch(Post.class, name).ifPresent(post -> {
final Post oldPost = JsonUtils.deepCopy(post);
postPermalinkPolicy.onPermalinkDelete(oldPost);
post.getStatusOrDefault()
.setPermalink(postPermalinkPolicy.permalink(post));
postPermalinkPolicy.onPermalinkAdd(post);
Post.PostStatus status = post.getStatusOrDefault();
if (status.getPhase() == null) {
@ -344,9 +349,6 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
}
private void cleanUpResources(Post post) {
// remove permalink from permalink indexer
postPermalinkPolicy.onPermalinkDelete(post);
// clean up snapshots
final Ref ref = Ref.of(post);
client.list(Snapshot.class,

View File

@ -14,11 +14,9 @@ import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.content.SinglePageService;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
@ -26,7 +24,6 @@ import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
@ -38,8 +35,6 @@ import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
/**
* <p>Reconciler for {@link SinglePage}.</p>
@ -58,10 +53,8 @@ import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
@Component
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 SinglePageService singlePageService;
private final ApplicationContext applicationContext;
private final CounterService counterService;
private final ExternalUrlSupplier externalUrlSupplier;
@ -237,9 +230,6 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
}
private void cleanUpResources(SinglePage singlePage) {
// remove permalink from permalink indexer
permalinkOnDelete(singlePage);
// clean up snapshot
Ref ref = Ref.of(singlePage);
client.list(Snapshot.class,
@ -291,36 +281,18 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
});
}
private void permalinkOnDelete(SinglePage singlePage) {
ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(),
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, locator));
}
String createPermalink(SinglePage page) {
var permalink = encodePath(page.getSpec().getSlug(), UTF_8);
permalink = StringUtils.prependIfMissing(permalink, "/");
return externalUrlSupplier.get().resolve(permalink).normalize().toString();
}
private void permalinkOnAdd(SinglePage singlePage) {
if (!singlePage.isPublished() || Objects.equals(true, singlePage.getSpec().getDeleted())) {
return;
}
ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(),
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, locator,
singlePage.getStatusOrDefault().getPermalink()));
}
private void reconcileStatus(String name) {
client.fetch(SinglePage.class, name).ifPresent(singlePage -> {
final SinglePage oldPage = JsonUtils.deepCopy(singlePage);
permalinkOnDelete(oldPage);
singlePage.getStatusOrDefault()
.setPermalink(createPermalink(singlePage));
permalinkOnAdd(singlePage);
SinglePage.SinglePageSpec spec = singlePage.getSpec();
SinglePage.SinglePageStatus status = singlePage.getStatusOrDefault();

View File

@ -2,13 +2,17 @@ package run.halo.app.core.extension.reconciler;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
@ -41,6 +45,10 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
}
addFinalizerIfNecessary(tag);
reconcileMetadata(request.name());
this.reconcileStatusPermalink(request.name());
reconcileStatusPosts(request.name());
});
return new Result(false, null);
@ -54,9 +62,18 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
.build();
}
private void cleanUpResources(Tag tag) {
// remove permalink from permalink indexer
tagPermalinkPolicy.onPermalinkDelete(tag);
void reconcileMetadata(String name) {
client.fetch(Tag.class, name).ifPresent(tag -> {
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(tag);
String oldPermalinkPattern = annotations.get(Constant.PERMALINK_PATTERN_ANNO);
String newPattern = tagPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, newPattern);
if (!StringUtils.equals(oldPermalinkPattern, newPattern)) {
client.update(tag);
}
});
}
private void addFinalizerIfNecessary(Tag oldTag) {
@ -78,7 +95,6 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
private void cleanUpResourcesAndRemoveFinalizer(String tagName) {
client.fetch(Tag.class, tagName).ifPresent(tag -> {
cleanUpResources(tag);
if (tag.getMetadata().getFinalizers() != null) {
tag.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
@ -86,6 +102,19 @@ public class TagReconciler implements Reconciler<Reconciler.Request> {
});
}
private void reconcileStatusPermalink(String tagName) {
client.fetch(Tag.class, tagName)
.ifPresent(tag -> {
String oldPermalink = tag.getStatusOrDefault().getPermalink();
String permalink = tagPermalinkPolicy.permalink(tag);
tag.getStatusOrDefault().setPermalink(permalink);
if (!StringUtils.equals(permalink, oldPermalink)) {
client.update(tag);
}
});
}
private void reconcileStatusPosts(String tagName) {
client.fetch(Tag.class, tagName).ifPresent(tag -> {
Tag oldTag = JsonUtils.deepCopy(tag);

View File

@ -1,56 +0,0 @@
package run.halo.app.core.extension.reconciler;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
@Component
@RequiredArgsConstructor
public class TagRouteReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
private final TagPermalinkPolicy tagPermalinkPolicy;
@Override
public Result reconcile(Request request) {
client.fetch(Tag.class, request.name())
.ifPresent(tag -> {
if (tag.getMetadata().getDeletionTimestamp() != null) {
// TagReconciler already did it, so there is no need to remove permalink
return;
}
reconcilePermalinkRoute(request.name());
});
return new Result(false, null);
}
private void reconcilePermalinkRoute(String tagName) {
client.fetch(Tag.class, tagName)
.ifPresent(tag -> {
final String oldPermalink = tag.getStatusOrDefault().getPermalink();
tagPermalinkPolicy.onPermalinkDelete(tag);
String permalink = tagPermalinkPolicy.permalink(tag);
tag.getStatusOrDefault().setPermalink(permalink);
tagPermalinkPolicy.onPermalinkAdd(tag);
if (!StringUtils.equals(permalink, oldPermalink)) {
client.update(tag);
}
});
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Tag())
.build();
}
}

View File

@ -7,10 +7,8 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
@ -18,8 +16,6 @@ import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
@Slf4j
@Component
@ -58,21 +54,12 @@ public class UserReconciler implements Reconciler<Request> {
status.setPermalink(getUserPermalink(user));
ExtensionLocator extensionLocator = getExtensionLocator(name);
eventPublisher.publishEvent(
new PermalinkIndexUpdateCommand(this, extensionLocator, status.getPermalink()));
if (!StringUtils.equals(oldPermalink, status.getPermalink())) {
client.update(user);
}
});
}
private static ExtensionLocator getExtensionLocator(String name) {
return new ExtensionLocator(GroupVersionKind.fromExtension(User.class), name,
name);
}
private String getUserPermalink(User user) {
return externalUrlSupplier.get()
.resolve(PathUtils.combinePath("authors", user.getMetadata().getName()))
@ -98,9 +85,6 @@ public class UserReconciler implements Reconciler<Request> {
private void cleanUpResourcesAndRemoveFinalizer(String userName) {
client.fetch(User.class, userName).ifPresent(user -> {
eventPublisher.publishEvent(
new PermalinkIndexDeleteCommand(this, getExtensionLocator(userName)));
if (user.getMetadata().getFinalizers() != null) {
user.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}

View File

@ -59,6 +59,10 @@ public class SystemConfigurableEnvironmentFetcher {
.switchIfEmpty(Mono.just(new SystemSetting.Post()));
}
public Mono<SystemSetting.ThemeRouteRules> fetchRouteRules() {
return fetch(SystemSetting.ThemeRouteRules.GROUP, SystemSetting.ThemeRouteRules.class);
}
@NonNull
private Mono<Map<String, String>> getValuesInternal() {
return getConfigMap()

View File

@ -32,6 +32,15 @@ public class SystemSetting {
private String archives;
private String post;
private String tags;
public static ThemeRouteRules empty() {
ThemeRouteRules rules = new ThemeRouteRules();
rules.setPost("/archives/{slug}");
rules.setArchives("/archives");
rules.setTags("/tags");
rules.setCategories("/categories");
return rules;
}
}
@Data

View File

@ -1,21 +1,27 @@
package run.halo.app.infra.exception;
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ResponseStatusException;
/**
* Not found exception.
*
* @author guqing
* @since 2.0.0
*/
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
public class NotFoundException extends ResponseStatusException {
public NotFoundException(@Nullable String reason) {
this(reason, null);
}
public NotFoundException(String message, Throwable cause) {
super(message, cause);
public NotFoundException(@Nullable String reason,
@Nullable Throwable cause) {
super(HttpStatus.NOT_FOUND, reason, cause);
}
public NotFoundException(Throwable cause) {
super(cause);
public NotFoundException(@Nullable Throwable cause) {
this(cause == null ? "" : cause.getMessage(), cause);
}
}

View File

@ -54,6 +54,12 @@ public class HaloUtils {
return StringUtils.defaultString(userAgent, "unknown");
}
public static String getDayText(Instant instant) {
Assert.notNull(instant, "Instant must not be null");
int dayValue = instant.atZone(ZoneId.systemDefault()).getDayOfMonth();
return StringUtils.leftPad(String.valueOf(dayValue), 2, '0');
}
public static String getMonthText(Instant instant) {
Assert.notNull(instant, "Instant must not be null");
int monthValue = instant.atZone(ZoneId.systemDefault()).getMonthValue();

View File

@ -13,7 +13,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.strategy.ModelConst;
import run.halo.app.theme.router.factories.ModelConst;
/**
* <p>The <code>head</code> html snippet injection processor for content template such as post

View File

@ -10,7 +10,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.strategy.ModelConst;
import run.halo.app.theme.router.factories.ModelConst;
/**
* <p>Global custom head snippet injection for theme global setting.</p>

View File

@ -20,7 +20,6 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.comparator.Comparators;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
@ -135,7 +134,6 @@ public class PostFinderImpl implements PostFinder {
}
List<String> elements = window.elements();
Tuple2<String, String> previousNext;
// current post index
int index = elements.indexOf(currentName);

View File

@ -51,6 +51,7 @@ public class SinglePageFinderImpl implements SinglePageFinder {
@Override
public Mono<SinglePageVo> getByName(String pageName) {
return client.fetch(SinglePage.class, pageName)
.filter(FIXED_PREDICATE)
.map(page -> {
SinglePageVo pageVo = SinglePageVo.from(page);
pageVo.setContributors(List.of());

View File

@ -0,0 +1,82 @@
package run.halo.app.theme.router;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* {@link ExtensionPermalinkPatternUpdater} to update the value of key
* {@link Constant#PERMALINK_PATTERN_ANNO} in {@link MetadataOperator#getAnnotations()}
* of {@link Extension} when the pattern changed.
*
* @author guqing
* @see Post
* @see Category
* @see Tag
* @since 2.0.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ExtensionPermalinkPatternUpdater
implements ApplicationListener<PermalinkRuleChangedEvent> {
private final ExtensionClient client;
@Override
public void onApplicationEvent(@NonNull PermalinkRuleChangedEvent event) {
DefaultTemplateEnum template = event.getTemplate();
log.debug("Refresh permalink for template [{}]", template.getValue());
String pattern = event.getRule();
switch (template) {
case POST -> updatePostPermalink(pattern);
case CATEGORY -> updateCategoryPermalink(pattern);
case TAG -> updateTagPermalink(pattern);
default -> {
}
}
}
private void updatePostPermalink(String pattern) {
log.debug("Update post permalink by new policy [{}]", pattern);
client.list(Post.class, null, null)
.forEach(post -> updateIfPermalinkPatternChanged(post, pattern));
}
private void updateIfPermalinkPatternChanged(AbstractExtension extension, String pattern) {
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(extension);
String oldPattern = annotations.get(Constant.PERMALINK_PATTERN_ANNO);
annotations.put(Constant.PERMALINK_PATTERN_ANNO, pattern);
if (StringUtils.equals(oldPattern, pattern) && StringUtils.isNotBlank(oldPattern)) {
return;
}
// update permalink pattern annotation
client.update(extension);
}
private void updateCategoryPermalink(String pattern) {
log.debug("Update category and categories permalink by new policy [{}]", pattern);
client.list(Category.class, null, null)
.forEach(category -> updateIfPermalinkPatternChanged(category, pattern));
}
private void updateTagPermalink(String pattern) {
log.debug("Update tag and tags permalink by new policy [{}]", pattern);
client.list(Tag.class, null, null)
.forEach(tag -> updateIfPermalinkPatternChanged(tag, pattern));
}
}

View File

@ -1,12 +0,0 @@
package run.halo.app.theme.router;
import run.halo.app.extension.GroupVersionKind;
/**
* A record class for holding gvk and name.
*
* @author guqing
* @since 2.0.0
*/
public record GvkName(GroupVersionKind gvk, String name) {
}

View File

@ -1,212 +0,0 @@
package run.halo.app.theme.router;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriUtils;
import run.halo.app.core.extension.reconciler.SystemSettingReconciler;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.strategy.DetailsPageRouteHandlerStrategy;
import run.halo.app.theme.router.strategy.IndexRouteStrategy;
import run.halo.app.theme.router.strategy.ListPageRouteHandlerStrategy;
/**
* Permalink router for http get method.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class PermalinkHttpGetRouter implements InitializingBean {
private final ReentrantLock lock = new ReentrantLock();
private final RadixRouterTree routeTree = new RadixRouterTree();
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final ReactiveExtensionClient client;
private final ApplicationContext applicationContext;
private final ExternalUrlSupplier externalUrlSupplier;
/**
* Match permalink according to {@link ServerRequest}.
*
* @param request http request
* @return a handler function if matched, otherwise null
*/
public HandlerFunction<ServerResponse> route(ServerRequest request) {
return routeTree.match(request);
}
public void insert(String key, HandlerFunction<ServerResponse> handlerFunction) {
routeTree.insert(key, handlerFunction);
}
/**
* Watch permalink changed event to refresh route tree.
*
* @param event permalink changed event
*/
@EventListener(PermalinkIndexChangedEvent.class)
public void onPermalinkChanged(PermalinkIndexChangedEvent event) {
String oldPath = getPath(event.getOldPermalink());
String path = getPath(event.getPermalink());
GvkName gvkName = event.getGvkName();
lock.lock();
try {
if (oldPath == null && path != null) {
onPermalinkAdded(gvkName, path);
return;
}
if (oldPath != null) {
if (path == null) {
onPermalinkDeleted(oldPath);
} else {
onPermalinkUpdated(gvkName, oldPath, path);
}
}
} finally {
lock.unlock();
}
}
/**
* Watch permalink rule changed event to refresh route tree for list style templates.
*
* @param event permalink changed event
*/
@EventListener(PermalinkRuleChangedEvent.class)
public void onPermalinkRuleChanged(PermalinkRuleChangedEvent event) {
final String rule = event.getRule();
final String oldRule = event.getOldRule();
lock.lock();
try {
if (StringUtils.isNotBlank(oldRule)) {
routeTree.delete(oldRule);
}
registerByTemplate(event.getTemplate(), rule);
} finally {
lock.unlock();
}
}
/**
* delete theme route old rules to trigger register.
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
environmentFetcher.getConfigMap().flatMap(configMap -> {
Map<String, String> annotations = configMap.getMetadata().getAnnotations();
if (annotations != null) {
annotations.remove(SystemSettingReconciler.OLD_THEME_ROUTE_RULES);
}
return client.update(configMap);
}).block();
}
private void registerByTemplate(DefaultTemplateEnum template, String rule) {
ListPageRouteHandlerStrategy routeStrategy = getRouteStrategy(template);
if (routeStrategy == null) {
return;
}
List<String> routerPaths = routeStrategy.getRouterPaths(rule);
routeTreeBatchOperation(routerPaths, routeTree::delete);
if (StringUtils.isNotBlank(rule)) {
routeTreeBatchOperation(routeStrategy.getRouterPaths(rule),
path -> routeTree.insert(path, routeStrategy.getHandler()));
}
}
void init() {
// Index route need to be added first
IndexRouteStrategy indexRouteStrategy =
applicationContext.getBean(IndexRouteStrategy.class);
List<String> routerPaths = indexRouteStrategy.getRouterPaths("/");
routeTreeBatchOperation(routerPaths,
path -> routeTree.insert(path, indexRouteStrategy.getHandler()));
}
private void routeTreeBatchOperation(List<String> paths,
Consumer<String> templateFunction) {
if (paths == null) {
return;
}
paths.forEach(templateFunction);
}
private void onPermalinkAdded(GvkName gvkName, String path) {
routeTree.insert(path, getRouteHandler(gvkName));
}
private void onPermalinkUpdated(GvkName gvkName, String oldPath, String path) {
routeTree.delete(oldPath);
routeTree.insert(path, getRouteHandler(gvkName));
}
private void onPermalinkDeleted(String path) {
routeTree.delete(path);
}
private String getPath(@Nullable String permalink) {
if (permalink == null) {
return null;
}
String decode = UriUtils.decode(permalink, StandardCharsets.UTF_8);
URI externalUrl = externalUrlSupplier.get();
if (externalUrl != null) {
String externalAsciiUrl = externalUrl.toASCIIString();
return StringUtils.prependIfMissing(
StringUtils.removeStart(decode, externalAsciiUrl), "/");
}
return decode;
}
private HandlerFunction<ServerResponse> getRouteHandler(GvkName gvkName) {
GroupVersionKind gvk = gvkName.gvk();
return applicationContext.getBeansOfType(DetailsPageRouteHandlerStrategy.class)
.values()
.stream()
.filter(strategy -> strategy.supports(gvk))
.findFirst()
.map(strategy -> strategy.getHandler(getThemeRouteRules(), gvkName.name()))
.orElse(null);
}
private ListPageRouteHandlerStrategy getRouteStrategy(DefaultTemplateEnum template) {
return applicationContext.getBeansOfType(ListPageRouteHandlerStrategy.class)
.values()
.stream()
.filter(strategy -> strategy.supports(template))
.findFirst()
.orElse(null);
}
public SystemSetting.ThemeRouteRules getThemeRouteRules() {
return environmentFetcher.fetch(SystemSetting.ThemeRouteRules.GROUP,
SystemSetting.ThemeRouteRules.class).block();
}
@Override
public void afterPropertiesSet() throws Exception {
init();
}
}

View File

@ -1,30 +0,0 @@
package run.halo.app.theme.router;
import org.springframework.context.ApplicationEvent;
import run.halo.app.content.permalinks.ExtensionLocator;
/**
* <p>Send a command to add a piece of data from {@link PermalinkIndexer}.</p>
*
* @author guqing
* @see PermalinkIndexer
* @since 2.0.0
*/
public class PermalinkIndexAddCommand extends ApplicationEvent {
private final ExtensionLocator locator;
private final String permalink;
public PermalinkIndexAddCommand(Object source, ExtensionLocator locator, String permalink) {
super(source);
this.locator = locator;
this.permalink = permalink;
}
public ExtensionLocator getLocator() {
return locator;
}
public String getPermalink() {
return permalink;
}
}

View File

@ -1,28 +0,0 @@
package run.halo.app.theme.router;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import org.springframework.util.Assert;
/**
* Permalink index changed event.
*
* @author guqing
* @since 2.0.0
*/
@Getter
public class PermalinkIndexChangedEvent extends ApplicationEvent {
private final String oldPermalink;
private final String permalink;
private final GvkName gvkName;
public PermalinkIndexChangedEvent(Object source,
GvkName gvkName, String oldPermalink, String permalink) {
super(source);
Assert.notNull(gvkName, "The gvkName must not be null.");
this.oldPermalink = oldPermalink;
this.permalink = permalink;
this.gvkName = gvkName;
}
}

View File

@ -1,24 +0,0 @@
package run.halo.app.theme.router;
import org.springframework.context.ApplicationEvent;
import run.halo.app.content.permalinks.ExtensionLocator;
/**
* <p>Send a command to delete a piece of data from {@link PermalinkIndexer}.</p>
*
* @author guqing
* @see PermalinkIndexer
* @since 2.0.0
*/
public class PermalinkIndexDeleteCommand extends ApplicationEvent {
private final ExtensionLocator locator;
public PermalinkIndexDeleteCommand(Object source, ExtensionLocator locator) {
super(source);
this.locator = locator;
}
public ExtensionLocator getLocator() {
return locator;
}
}

View File

@ -1,30 +0,0 @@
package run.halo.app.theme.router;
import org.springframework.context.ApplicationEvent;
import run.halo.app.content.permalinks.ExtensionLocator;
/**
* <p>Send a command to update a piece of data from {@link PermalinkIndexer}.</p>
*
* @author guqing
* @see PermalinkIndexer
* @since 2.0.0
*/
public class PermalinkIndexUpdateCommand extends ApplicationEvent {
private final ExtensionLocator locator;
private final String permalink;
public PermalinkIndexUpdateCommand(Object source, ExtensionLocator locator, String permalink) {
super(source);
this.locator = locator;
this.permalink = permalink;
}
public ExtensionLocator getLocator() {
return locator;
}
public String getPermalink() {
return permalink;
}
}

View File

@ -1,251 +0,0 @@
package run.halo.app.theme.router;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.extension.GroupVersionKind;
/**
* <p>Permalink indexer for lookup extension's name and slug by permalink.</p>
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Component
public class PermalinkIndexer {
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Map<GvkName, String> gvkNamePermalinkLookup = new HashMap<>();
private final Map<String, ExtensionLocator> permalinkLocatorLookup = new HashMap<>();
private final ApplicationContext applicationContext;
public PermalinkIndexer(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
* Register extension and permalink mapping.
*
* @param locator extension locator to hold the gvk and name and slug
* @param permalink extension permalink for template route
*/
public void register(ExtensionLocator locator, String permalink) {
readWriteLock.writeLock().lock();
try {
GvkName gvkName = new GvkName(locator.gvk(), locator.name());
gvkNamePermalinkLookup.put(gvkName, permalink);
permalinkLocatorLookup.put(permalink, locator);
publishEvent(gvkName, null, permalink);
} finally {
readWriteLock.writeLock().unlock();
}
}
private void publishEvent(GvkName gvkName, String oldPermalink, String newPermalink) {
applicationContext.publishEvent(
new PermalinkIndexChangedEvent(this, gvkName, oldPermalink, newPermalink));
}
/**
* Remove extension and permalink mapping.
*
* @param locator extension info
*/
public void remove(ExtensionLocator locator) {
readWriteLock.writeLock().lock();
try {
GvkName gvkName = new GvkName(locator.gvk(), locator.name());
String permalink = gvkNamePermalinkLookup.remove(gvkName);
if (permalink != null) {
permalinkLocatorLookup.remove(permalink);
publishEvent(gvkName, permalink, null);
}
} finally {
readWriteLock.writeLock().unlock();
}
}
/**
* Gets permalink by {@link GroupVersionKind}.
*
* @param gvk group version kind
* @return permalinks
*/
@NonNull
public List<String> getPermalinks(GroupVersionKind gvk) {
readWriteLock.readLock().lock();
try {
return gvkNamePermalinkLookup.entrySet()
.stream()
.filter(entry -> entry.getKey().gvk().equals(gvk))
.map(Map.Entry::getValue)
.toList();
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Lookup extension info by permalink.
*
* @param permalink extension permalink for theme template route
* @return extension locator
*/
@Nullable
public ExtensionLocator lookup(String permalink) {
readWriteLock.readLock().lock();
try {
return permalinkLocatorLookup.get(permalink);
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Lookup extension permalink by {@link GroupVersionKind} and {@code name}.
*
* @param gvk group version kind
* @param name extension name
* @return {@code true} if contains, otherwise {@code false}
*/
public boolean containsName(GroupVersionKind gvk, String name) {
readWriteLock.readLock().lock();
try {
return gvkNamePermalinkLookup.containsKey(new GvkName(gvk, name));
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Lookup extension permalink by {@link GroupVersionKind} and {@code slug}.
*
* @param gvk group version kind
* @param slug extension slug
* @return {@code true} if contains, otherwise {@code false}
*/
public boolean containsSlug(GroupVersionKind gvk, String slug) {
readWriteLock.readLock().lock();
try {
return permalinkLocatorLookup.values()
.stream()
.anyMatch(locator -> locator.gvk().equals(gvk)
&& locator.slug().equals(slug));
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Lookup extension name by resource slug.
*
* @param gvk extension's {@link GroupVersionKind}
* @param slug extension resource slug
* @return extension resource name specified by resource slug
*/
public String getNameBySlug(GroupVersionKind gvk, String slug) {
readWriteLock.readLock().lock();
try {
return permalinkLocatorLookup.values()
.stream()
.filter(locator -> locator.gvk().equals(gvk)
&& locator.slug().equals(slug))
.findFirst()
.map(ExtensionLocator::name)
.orElseThrow();
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Get extension name by permalink.
*
* @param gvk is GroupVersionKind of extension
* @param permalink is encoded permalink
* @return extension name or null
*/
@Nullable
public String getNameByPermalink(GroupVersionKind gvk, String permalink) {
readWriteLock.readLock().lock();
try {
var locator = permalinkLocatorLookup.get(permalink);
return locator == null ? null : locator.name();
} finally {
readWriteLock.readLock().unlock();
}
}
/**
* Only for test.
*
* @return permalinkLookup map size
*/
protected long gvkNamePermalinkMapSize() {
return gvkNamePermalinkLookup.size();
}
/**
* Only for test.
*
* @return permalinkLocatorMap map size
*/
protected long permalinkLocatorMapSize() {
return permalinkLocatorLookup.size();
}
/**
* Add a record to the {@link PermalinkIndexer}.
* If permalink already exists, it will not be added to indexer
*
* @param addCommand a command to add a record to {@link PermalinkIndexer}
*/
@EventListener(PermalinkIndexAddCommand.class)
public void onPermalinkAdd(PermalinkIndexAddCommand addCommand) {
if (checkPermalinkExists(addCommand.getLocator(), addCommand.getPermalink())) {
// TODO send an extension event to log this error
log.error("Permalink [{}] already exists, you can try to change the slug [{}].",
addCommand.getPermalink(), addCommand.getLocator());
return;
}
register(addCommand.getLocator(), addCommand.getPermalink());
}
@EventListener(PermalinkIndexDeleteCommand.class)
public void onPermalinkDelete(PermalinkIndexDeleteCommand deleteCommand) {
remove(deleteCommand.getLocator());
}
/**
* Update a {@link PermalinkIndexer} record by {@link ExtensionLocator} and permalink.
* If permalink already exists, it will not be updated
*
* @param updateCommand a command to update an indexer record
*/
@EventListener(PermalinkIndexUpdateCommand.class)
public void onPermalinkUpdate(PermalinkIndexUpdateCommand updateCommand) {
if (checkPermalinkExists(updateCommand.getLocator(), updateCommand.getPermalink())) {
// TODO send an extension event to log this error
log.error("Permalink [{}] already exists, you can try to change the slug [{}].",
updateCommand.getPermalink(), updateCommand.getLocator());
return;
}
remove(updateCommand.getLocator());
register(updateCommand.getLocator(), updateCommand.getPermalink());
}
private boolean checkPermalinkExists(ExtensionLocator locator, String permalink) {
ExtensionLocator lookup = lookup(permalink);
return lookup != null && !lookup.equals(locator);
}
}

View File

@ -1,60 +0,0 @@
package run.halo.app.theme.router;
import java.util.Map;
import org.springframework.stereotype.Component;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* <p>The {@link PermalinkPatternProvider} used to obtain permalink rules according to specific
* template names.</p>
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PermalinkPatternProvider {
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public PermalinkPatternProvider(SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.environmentFetcher = environmentFetcher;
}
private SystemSetting.ThemeRouteRules getPermalinkRules() {
return environmentFetcher.getConfigMapBlocking()
.map(configMap -> {
Map<String, String> data = configMap.getData();
return data.get(SystemSetting.ThemeRouteRules.GROUP);
})
.map(routeRulesJson -> JsonUtils.jsonToObject(routeRulesJson,
SystemSetting.ThemeRouteRules.class))
.orElseGet(() -> {
SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
themeRouteRules.setArchives("archives");
themeRouteRules.setPost("/archives/{slug}");
themeRouteRules.setTags("tags");
themeRouteRules.setCategories("categories");
return themeRouteRules;
});
}
/**
* Get permalink pattern by template name.
*
* @param defaultTemplateEnum default templates
* @return a pattern specified by the template name
*/
public String getPattern(DefaultTemplateEnum defaultTemplateEnum) {
SystemSetting.ThemeRouteRules permalinkRules = getPermalinkRules();
return switch (defaultTemplateEnum) {
case INDEX, SINGLE_PAGE, AUTHOR -> null;
case POST -> permalinkRules.getPost();
case ARCHIVES -> permalinkRules.getArchives();
case CATEGORY, CATEGORIES -> permalinkRules.getCategories();
case TAG, TAGS -> permalinkRules.getTags();
};
}
}

View File

@ -1,107 +0,0 @@
package run.halo.app.theme.router;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.content.permalinks.CategoryPermalinkPolicy;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.content.permalinks.TagPermalinkPolicy;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* Permalink refresh handler.
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Component
public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRuleChangedEvent> {
private final ExtensionClient client;
private final PostPermalinkPolicy postPermalinkPolicy;
private final TagPermalinkPolicy tagPermalinkPolicy;
private final CategoryPermalinkPolicy categoryPermalinkPolicy;
public PermalinkRefreshHandler(ExtensionClient client,
PostPermalinkPolicy postPermalinkPolicy,
TagPermalinkPolicy tagPermalinkPolicy,
CategoryPermalinkPolicy categoryPermalinkPolicy) {
this.client = client;
this.postPermalinkPolicy = postPermalinkPolicy;
this.tagPermalinkPolicy = tagPermalinkPolicy;
this.categoryPermalinkPolicy = categoryPermalinkPolicy;
}
@Override
public void onApplicationEvent(@NonNull PermalinkRuleChangedEvent event) {
DefaultTemplateEnum template = event.getTemplate();
log.debug("Refresh permalink for template [{}]", template.getValue());
switch (template) {
case POST -> updatePostPermalink();
case CATEGORIES, CATEGORY -> updateCategoryPermalink();
case TAGS, TAG -> updateTagPermalink();
default -> {
}
}
}
private void updatePostPermalink() {
String pattern = postPermalinkPolicy.pattern();
log.debug("Update post permalink by new policy [{}]", pattern);
client.list(Post.class, null, null)
.forEach(post -> {
String oldPermalink = post.getStatusOrDefault().getPermalink();
String permalink = postPermalinkPolicy.permalink(post);
post.getStatusOrDefault().setPermalink(permalink);
if (StringUtils.equals(oldPermalink, permalink)) {
return;
}
// update permalink
client.update(post);
postPermalinkPolicy.onPermalinkUpdate(post);
});
}
private void updateCategoryPermalink() {
String pattern = categoryPermalinkPolicy.pattern();
log.debug("Update category and categories permalink by new policy [{}]", pattern);
client.list(Category.class, null, null)
.forEach(category -> {
String oldPermalink = category.getStatusOrDefault().getPermalink();
String permalink = categoryPermalinkPolicy.permalink(category);
category.getStatusOrDefault().setPermalink(permalink);
if (StringUtils.equals(oldPermalink, permalink)) {
return;
}
// update permalink
client.update(category);
categoryPermalinkPolicy.onPermalinkUpdate(category);
});
}
private void updateTagPermalink() {
String pattern = tagPermalinkPolicy.pattern();
log.debug("Update tag and tags permalink by new policy [{}]", pattern);
client.list(Tag.class, null, null)
.forEach(tag -> {
String oldPermalink = tag.getStatusOrDefault().getPermalink();
String permalink = tagPermalinkPolicy.permalink(tag);
tag.getStatusOrDefault().setPermalink(permalink);
if (StringUtils.equals(oldPermalink, permalink)) {
return;
}
// update permalink
client.update(tag);
tagPermalinkPolicy.onPermalinkUpdate(tag);
});
}
}

View File

@ -1,224 +0,0 @@
package run.halo.app.theme.router;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.PathContainer;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
/**
* A router tree implementation based on radix tree.
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
public class RadixRouterTree extends RadixTree<HandlerFunction<ServerResponse>> {
@Override
public void insert(String key, HandlerFunction<ServerResponse> value)
throws IllegalArgumentException {
super.insert(key, value);
if (log.isDebugEnabled()) {
checkIndices();
}
}
@Override
public boolean delete(String key) {
boolean result = super.delete(key);
if (log.isDebugEnabled()) {
checkIndices();
}
return result;
}
/**
* <p>Find the tree node according to the request path.</p>
* There are the following situations:
* <ul>
* <li>If found, return to the {@link HandlerFunction} directly.</li>
* <li>If the request path is not found in tree, it may be this request path corresponds
* to a pattern,so all the patterns will be iterated to match the current request path. If
* they match, the handler will be returned.
* Otherwise, null will be returned.</li>
* </ul>
* for example, there is a tree as follows:
* <pre>
* / [indices=tca, priority=4]
* tags/ [indices=h{, priority=2]
* halo [value=tags-halo, priority=1]*
* {slug}/page/{page} [value=post by tags, priority=1]*
* categories/default [value=categories-default, priority=1]*
* about [value=about, priority=1]*
* </pre>
* <p>1. find the request path "/categories/default" in tree, return the handler directly.</p>
* <p>2. but find the request path "/categories/default/page/1", it will be iterated to
* match</p>
* TODO Optimize matching algorithm to improve efficiency and try your best to get results
* through one search
*
* @param request server request
* @return a handler function if matched, otherwise null
*/
public HandlerFunction<ServerResponse> match(ServerRequest request) {
String path = pathToFind(request);
HandlerFunction<ServerResponse> result = find(path);
if (result != null) {
return result;
}
PathContainer pathContainer = PathContainer.parsePath(path);
List<PathPattern> matches = new ArrayList<>();
for (String pathPattern : getKeys()) {
if (!hasPatternSyntax(pathPattern)) {
continue;
}
log.trace("PathPatternParser handle pathPattern [{}]", pathPattern);
PathPattern parse = PathPatternParser.defaultInstance.parse(pathPattern);
if (parse.matches(pathContainer)) {
matches.add(parse);
}
}
if (matches.isEmpty()) {
return null;
}
matches.sort(PathPattern.SPECIFICITY_COMPARATOR);
PathPattern bestMatch = matches.get(0);
if (matches.size() > 1) {
if (log.isTraceEnabled()) {
log.trace("request [GET {}] matching mappings: [{}]", path, matches);
}
PathPattern secondBestMatch = matches.get(1);
if (PathPattern.SPECIFICITY_COMPARATOR.compare(bestMatch, secondBestMatch) == 0) {
throw new IllegalStateException(
"Ambiguous mapping mapped for '" + path + "': {" + bestMatch + ", "
+ secondBestMatch + "}");
}
}
PathPattern.PathMatchInfo info =
bestMatch.matchAndExtract(request.requestPath().pathWithinApplication());
if (info != null) {
mergeAttributes(request, info.getUriVariables(), bestMatch);
}
return find(bestMatch.getPatternString());
}
/**
* TODO Optimize parameter route matching query.
* Router URL , /?p=post-name URL query URL
*/
static String pathToFind(ServerRequest request) {
String requestPath = processRequestPath(request.path());
MultiValueMap<String, String> queryParams = request.queryParams();
// 文章的 permalink 规则需要对 p 参数规则特殊处理
if (requestPath.equals("/") && queryParams.containsKey("p")) {
// post special route path
String postSlug = queryParams.getFirst("p");
requestPath = requestPath + "?p=" + postSlug;
}
// /categories/{slug}/page/{page} 和 /tags/{slug}/page/{page} 需要去掉 page 部分
if (PageUrlUtils.isPageUrl(requestPath)) {
int i = requestPath.lastIndexOf("/page/");
if (i != -1) {
requestPath = requestPath.substring(0, i);
}
}
requestPath = StringUtils.removeEnd(requestPath, "/");
return StringUtils.prependIfMissing(requestPath, "/");
}
private static void mergeAttributes(ServerRequest request, Map<String, String> variables,
PathPattern pattern) {
Map<String, String> pathVariables = mergePathVariables(request.pathVariables(), variables);
request.attributes().put(RouterFunctions.URI_TEMPLATE_VARIABLES_ATTRIBUTE,
Collections.unmodifiableMap(pathVariables));
pattern = mergePatterns(
(PathPattern) request.attributes().get(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE),
pattern);
request.attributes().put(RouterFunctions.MATCHING_PATTERN_ATTRIBUTE, pattern);
}
private static PathPattern mergePatterns(@Nullable PathPattern oldPattern,
PathPattern newPattern) {
if (oldPattern != null) {
return oldPattern.combine(newPattern);
} else {
return newPattern;
}
}
private static Map<String, String> mergePathVariables(Map<String, String> oldVariables,
Map<String, String> newVariables) {
if (!newVariables.isEmpty()) {
Map<String, String> mergedVariables = new LinkedHashMap<>(oldVariables);
mergedVariables.putAll(newVariables);
return mergedVariables;
} else {
return oldVariables;
}
}
private static String processRequestPath(String requestPath) {
String path = StringUtils.prependIfMissing(requestPath, "/");
return UriUtils.decode(path, StandardCharsets.UTF_8);
}
public boolean hasPatternSyntax(String pathPattern) {
return pathPattern.indexOf('{') != -1 || pathPattern.indexOf(':') != -1
|| pathPattern.indexOf('*') != -1;
}
/**
* Get all keys(paths) in trie, call recursion function
* Time O(n), Space O(n), n is number of nodes in trie.
*/
public List<String> getKeys() {
List<String> res = new ArrayList<>();
keysHelper(root, res, "");
return res;
}
/**
* Similar to pre-order (DFS, depth first search) of the tree,
* recursion is used to traverse all nodes in trie. When visiting the node,
* the method concatenates characters from previously visited nodes with
* the character of the current node. When the node's isReal is true,
* the recursion reaches the last character of the <code>path</code>.
* Add the <code>path</code> to the result list.
* recursion function, Time O(n), Space O(n), n is number of nodes in trie
*/
void keysHelper(RadixTreeNode<HandlerFunction<ServerResponse>> node, List<String> res,
String prefix) {
if (node == null) {
//base condition
return;
}
if (node.isReal()) {
String path = prefix + node.getKey();
res.add(path);
}
for (RadixTreeNode<HandlerFunction<ServerResponse>> child : node.getChildren()) {
keysHelper(child, res, prefix + node.getKey());
}
}
}

View File

@ -1,360 +0,0 @@
package run.halo.app.theme.router;
import java.util.ArrayList;
import java.util.Iterator;
import lombok.Data;
/**
* Implementation for {@link RadixTree Radix tree}.
*
* @author guqing
*/
@Data
public class RadixTree<T> {
protected RadixTreeNode<T> root;
protected long size;
/**
* Create a Radix Tree with only the default node root.
*/
public RadixTree() {
root = new RadixTreeNode<T>();
root.setKey("/");
root.setIndices("");
size = 0;
}
/**
* Find the node value with the given key.
*
* @param key the key to search
* @return value of the node with the given key if found, otherwise null
*/
public T find(String key) {
Visitor<T, T> visitor = new Visitor<>() {
public void visit(String key, RadixTreeNode<T> parent,
RadixTreeNode<T> node) {
if (node.isReal()) {
result = node.getValue();
}
}
};
visit(key, visitor);
return visitor.getResult();
}
/**
* Replace value by the given key.
*
* @param key the key to search
* @param value the value to replace
* @return {@code true} if replaced, otherwise {@code false}
*/
public boolean replace(String key, final T value) {
Visitor<T, T> visitor = new Visitor<>() {
public void visit(String key, RadixTreeNode<T> parent, RadixTreeNode<T> node) {
if (node.isReal()) {
node.setValue(value);
result = value;
} else {
result = null;
}
}
};
visit(key, visitor);
return visitor.getResult() != null;
}
/**
* Delete the tree node with the given key.
*
* @param key the key to delete
* @return @{code true} if deleted, otherwise {@code false}
*/
public boolean delete(String key) {
Visitor<T, Boolean> visitor = new Visitor<>(Boolean.FALSE) {
public void visit(String key, RadixTreeNode<T> parent,
RadixTreeNode<T> node) {
result = node.isReal();
// if it is a real node
if (result) {
// If there are no children of the node we need to
// delete it from the parent children list
if (node.getChildren().size() == 0) {
Iterator<RadixTreeNode<T>> it = parent.getChildren()
.iterator();
for (int index = 0; it.hasNext(); index++) {
if (it.next().getKey().equals(node.getKey())) {
// delete node
it.remove();
// update indices
StringBuilder indices = new StringBuilder(parent.getIndices());
indices.deleteCharAt(index);
parent.setIndices(indices.toString());
break;
}
}
// if parent is not real node and has only one child
// then they need to be merged.
if (parent.getChildren().size() == 1
&& !parent.isReal()) {
mergeNodes(parent, parent.getChildren().get(0));
}
} else if (node.getChildren().size() == 1) {
// we need to merge the only child of this node with
// itself
mergeNodes(node, node.getChildren().get(0));
} else { // we jus need to mark the node as non-real.
node.setReal(false);
}
}
}
/**
* Merge a child into its parent node. Operation only valid if it is
* only child of the parent node and parent node is not a real node.
*
* @param parent The parent Node
* @param child The child Node
*/
private void mergeNodes(RadixTreeNode<T> parent,
RadixTreeNode<T> child) {
parent.setKey(parent.getKey() + child.getKey());
parent.setReal(child.isReal());
parent.setValue(child.getValue());
parent.setChildren(child.getChildren());
parent.setIndices(child.getIndices());
}
};
visit(key, visitor);
if (visitor.getResult()) {
size--;
}
return visitor.getResult();
}
/**
* Recursively insert the key in the radix tree.
*
* @see #insert(String, Object)
*/
public void insert(String key, T value) throws IllegalArgumentException {
try {
insert(key, root, value);
} catch (IllegalArgumentException e) {
// re-throw the exception with 'key' in the message
throw new IllegalArgumentException("A handle is already registered for key:" + key);
}
size++;
}
/**
* Recursively insert the key in the radix tree.
*
* @param key The key to be inserted
* @param node The current node
* @param value The value associated with the key
* @throws IllegalArgumentException If the key already exists in the database.
*/
private void insert(String key, RadixTreeNode<T> node, T value)
throws IllegalArgumentException {
int numberOfMatchingCharacters = node.getNumberOfMatchingCharacters(key);
// we are either at the root node
// or we need to go down the tree
if (node.getKey().equals("") || numberOfMatchingCharacters == 0
|| (numberOfMatchingCharacters < key.length()
&& numberOfMatchingCharacters >= node.getKey().length())) {
boolean flag = false;
String newText = key.substring(numberOfMatchingCharacters);
// 递归查找插入位置
char idxc = newText.charAt(0);
for (int i = 0; i < node.getIndices().length(); i++) {
if (node.getIndices().charAt(i) == idxc) {
RadixTreeNode<T> child = node.getChildren().get(i);
flag = true;
insert(newText, child, value);
break;
}
}
// just add the node as the child of the current node
if (!flag) {
RadixTreeNode<T> n = new RadixTreeNode<T>();
n.setKey(newText);
n.setReal(true);
n.setValue(value);
// 往后追加与child对于的首字母到 indices
node.setIndices(node.getIndices() + idxc);
node.getChildren().add(n);
}
} else if (numberOfMatchingCharacters == key.length()
&& numberOfMatchingCharacters == node.getKey().length()) {
// there is an exact match just make the current node as data node
if (node.isReal()) {
throw new IllegalArgumentException("Duplicate key.");
}
node.setReal(true);
node.setValue(value);
} else if (numberOfMatchingCharacters > 0 && numberOfMatchingCharacters < node.getKey()
.length()) {
// This node need to be split as the key to be inserted
// is a prefix of the current node key
RadixTreeNode<T> n1 = new RadixTreeNode<>();
n1.setKey(node.getKey().substring(numberOfMatchingCharacters));
n1.setReal(node.isReal());
n1.setValue(node.getValue());
n1.setIndices(node.getIndices());
n1.setChildren(node.getChildren());
node.setKey(key.substring(0, numberOfMatchingCharacters));
node.setReal(false);
node.setChildren(new ArrayList<>());
node.getChildren().add(n1);
node.setIndices("");
// 往后追加与child对于的首字母到 indices
node.setIndices(node.getIndices() + n1.getKey().charAt(0));
// 新公共前缀比原公共前缀短,需要将当前的节点按公共前缀分开
if (numberOfMatchingCharacters < key.length()) {
RadixTreeNode<T> n2 = new RadixTreeNode<>();
n2.setKey(key.substring(numberOfMatchingCharacters));
n2.setReal(true);
n2.setValue(value);
node.getChildren().add(n2);
node.setIndices(node.getIndices() + n2.getKey().charAt(0));
} else {
node.setValue(value);
node.setReal(true);
}
} else {
// this key need to be added as the child of the current node
RadixTreeNode<T> n = new RadixTreeNode<T>();
n.setKey(node.getKey().substring(numberOfMatchingCharacters));
n.setChildren(node.getChildren());
n.setReal(node.isReal());
n.setValue(node.getValue());
node.setKey(key);
node.setReal(true);
node.setValue(value);
node.getChildren().add(n);
char idxc = node.getKey().charAt(0);
// 往后追加与child对于的首字母到 indices
n.setIndices(n.getIndices() + idxc);
}
}
/**
* The tree contains the key.
*
* @param key the key to search
* @return {@code true} if the tree contains the key, otherwise {@code false}
*/
public boolean contains(String key) {
Visitor<T, Boolean> visitor = new Visitor<>(Boolean.FALSE) {
public void visit(String key, RadixTreeNode<T> parent,
RadixTreeNode<T> node) {
result = node.isReal();
}
};
visit(key, visitor);
return visitor.getResult();
}
/**
* visit the node those key matches the given key.
*
* @param key The key that need to be visited
* @param visitor The visitor object
*/
public <R> void visit(String key, Visitor<T, R> visitor) {
if (root != null) {
visit(key, visitor, null, root);
}
}
/**
* recursively visit the tree based on the supplied "key". calls the Visitor
* for the node those key matches the given prefix.
*
* @param prefix The key of prefix to search in the tree
* @param visitor The Visitor that will be called if a node with "key" as its key is found
* @param node The Node from where onward to search
*/
<R> void visit(String prefix, Visitor<T, R> visitor,
RadixTreeNode<T> parent, RadixTreeNode<T> node) {
int numberOfMatchingCharacters = node.getNumberOfMatchingCharacters(prefix);
// if the node key and prefix match, we found a match!
if (numberOfMatchingCharacters == prefix.length()
&& numberOfMatchingCharacters == node.getKey().length()) {
visitor.visit(prefix, parent, node);
} else if (node.getKey().equals("") // either we are at the
// root
|| (numberOfMatchingCharacters < prefix.length()
&& numberOfMatchingCharacters >= node.getKey().length())) {
// OR we need to traverse the children
String newText = prefix.substring(numberOfMatchingCharacters);
for (RadixTreeNode<T> child : node.getChildren()) {
// recursively search the child nodes
if (child.getKey().startsWith(newText.charAt(0) + "")) {
visit(newText, visitor, node, child);
break;
}
}
}
}
public long getSize() {
return size;
}
/**
* <p>Display the Trie on console.</p>
* WARNING! Do not use this for a large Trie, it's for testing purpose only.
*/
@Deprecated
public String display() {
StringBuilder buffer = new StringBuilder();
root.print(buffer, "", "");
return buffer.toString();
}
/**
* Only used for testing purpose.
*/
public void checkIndices() {
this.checkIndices(root);
}
void checkIndices(RadixTreeNode<T> node) {
if (node == null) {
//base condition
return;
}
node.checkIndices();
for (RadixTreeNode<T> child : node.getChildren()) {
checkIndices(child);
}
}
public abstract static class Visitor<T, R> {
protected R result;
public Visitor() {
this.result = null;
}
public Visitor(R initialValue) {
this.result = initialValue;
}
public R getResult() {
return result;
}
public abstract void visit(String key, RadixTreeNode<T> parent, RadixTreeNode<T> node);
}
}

View File

@ -1,85 +0,0 @@
package run.halo.app.theme.router;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
/**
* Represents a node of a Radix tree {@link RadixTree}.
*
* @param <T> value type
* @author guqing
*/
@Data
public class RadixTreeNode<T> {
private String key;
private List<RadixTreeNode<T>> children;
private boolean real;
private T value;
protected String indices;
/**
* intailize the fields with default values to avoid null reference checks
* all over the places.
*/
public RadixTreeNode() {
key = "";
children = new ArrayList<>();
real = false;
indices = "";
}
protected int getNumberOfMatchingCharacters(String key) {
int numberOfMatchingCharacters = 0;
while (numberOfMatchingCharacters < key.length()
&& numberOfMatchingCharacters < this.getKey().length()) {
if (key.charAt(numberOfMatchingCharacters) != this.getKey()
.charAt(numberOfMatchingCharacters)) {
break;
}
numberOfMatchingCharacters++;
}
return numberOfMatchingCharacters;
}
void print(StringBuilder buffer, String prefix, String childrenPrefix) {
buffer.append(prefix);
buffer.append(printNode());
buffer.append('\n');
for (Iterator<RadixTreeNode<T>> it = children.iterator(); it.hasNext(); ) {
RadixTreeNode<T> next = it.next();
if (it.hasNext()) {
next.print(buffer, childrenPrefix + "├── ", childrenPrefix + "│ ");
} else {
next.print(buffer, childrenPrefix + "└── ", childrenPrefix + " ");
}
}
}
String printNode() {
if (isReal()) {
return String.format("%s [value=%s]*", getKey(), getValue());
} else {
return String.format("%s [indices=%s]", getKey(), getIndices());
}
}
/**
* Check whether {@link #indices} matches the {@link #children} items prefix.
*/
public void checkIndices() {
StringBuilder indices = new StringBuilder();
for (RadixTreeNode<T> child : this.getChildren()) {
indices.append(child.getKey().charAt(0));
}
if (!StringUtils.equals(this.getIndices(), indices.toString())) {
throw new IllegalStateException(
String.format("indices mismatch for node '%s': is %s, should be %s", this.getKey(),
this.getIndices(), indices));
}
}
}

View File

@ -0,0 +1,148 @@
package run.halo.app.theme.router;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.util.UriUtils.encodePath;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.factories.ModelConst;
/**
* The {@link SinglePageRoute} for route request to specific template <code>page.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@RequiredArgsConstructor
public class SinglePageRoute
implements RouterFunction<ServerResponse>, Reconciler<Reconciler.Request>, DisposableBean {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
private final Map<NameSlugPair, HandlerFunction<ServerResponse>> quickRouteMap =
new ConcurrentHashMap<>();
private final ExtensionClient client;
private final SinglePageFinder singlePageFinder;
private final ViewNameResolver viewNameResolver;
@Override
@NonNull
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
return Flux.fromIterable(routerFunctions())
.concatMap(routerFunction -> routerFunction.route(request))
.next();
}
@Override
public void accept(@NonNull RouterFunctions.Visitor visitor) {
routerFunctions().forEach(routerFunction -> routerFunction.accept(visitor));
}
private List<RouterFunction<ServerResponse>> routerFunctions() {
return quickRouteMap.keySet().stream()
.map(nameSlugPair -> {
String routePath = singlePageRoute(nameSlugPair.slug());
return RouterFunctions.route(GET(routePath)
.and(RequestPredicates.accept(MediaType.TEXT_HTML)),
handlerFunction(nameSlugPair.name()));
})
.collect(Collectors.toList());
}
@Override
public Result reconcile(Request request) {
client.fetch(SinglePage.class, request.name())
.ifPresent(page -> {
if (ExtensionOperator.isDeleted(page)
|| BooleanUtils.isTrue(page.getSpec().getDeleted())) {
quickRouteMap.remove(NameSlugPair.from(page));
return;
}
// put new one
quickRouteMap.entrySet()
.removeIf(entry -> entry.getKey().name().equals(request.name()));
quickRouteMap.put(NameSlugPair.from(page), handlerFunction(request.name()));
});
return new Result(false, null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new SinglePage())
.build();
}
@Override
public void destroy() throws Exception {
quickRouteMap.clear();
}
record NameSlugPair(String name, String slug) {
public static NameSlugPair from(SinglePage page) {
return new NameSlugPair(page.getMetadata().getName(), page.getSpec().getSlug());
}
}
String singlePageRoute(String slug) {
var permalink = encodePath(slug, UTF_8);
return StringUtils.prependIfMissing(permalink, "/");
}
HandlerFunction<ServerResponse> handlerFunction(String name) {
return request -> singlePageFinder.getByName(name)
.flatMap(singlePageVo -> {
Map<String, Object> model = new HashMap<>();
model.put("groupVersionKind", gvk);
model.put("plural", getPlural());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.SINGLE_PAGE.getValue());
model.put("singlePage", singlePageVo);
String template = singlePageVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.SINGLE_PAGE.getValue())
.flatMap(viewName -> ServerResponse.ok().render(viewName, model));
})
.switchIfEmpty(
Mono.error(new NotFoundException("Single page not found"))
);
}
private String getPlural() {
GVK gvk = Scheme.getGvkFromType(SinglePage.class);
return gvk.plural();
}
}

View File

@ -1,40 +1,132 @@
package run.halo.app.theme.router;
import org.springframework.http.HttpMethod;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SchemeInitializedEvent;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.factories.ArchiveRouteFactory;
import run.halo.app.theme.router.factories.AuthorPostsRouteFactory;
import run.halo.app.theme.router.factories.CategoriesRouteFactory;
import run.halo.app.theme.router.factories.CategoryPostRouteFactory;
import run.halo.app.theme.router.factories.IndexRouteFactory;
import run.halo.app.theme.router.factories.PostRouteFactory;
import run.halo.app.theme.router.factories.TagPostRouteFactory;
import run.halo.app.theme.router.factories.TagsRouteFactory;
/**
* <p>Theme template composite {@link RouterFunction} for manage routers for default templates.</p>
* It routes specific requests to the {@link RouterFunction} maintained by the
* {@link PermalinkHttpGetRouter}.
* <p>The combination router of theme templates is used to render theme templates, but does not
* include <code>page.html</code> templates which is processed separately.</p>
*
* @author guqing
* @see PermalinkHttpGetRouter
* @see SinglePageRoute
* @since 2.0.0
*/
@Component
public class ThemeCompositeRouterFunction implements
RouterFunction<ServerResponse> {
@RequiredArgsConstructor
public class ThemeCompositeRouterFunction implements RouterFunction<ServerResponse> {
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final PermalinkHttpGetRouter permalinkHttpGetRouter;
private final ArchiveRouteFactory archiveRouteFactory;
private final PostRouteFactory postRouteFactory;
private final CategoriesRouteFactory categoriesRouteFactory;
private final CategoryPostRouteFactory categoryPostRouteFactory;
private final TagPostRouteFactory tagPostRouteFactory;
private final TagsRouteFactory tagsRouteFactory;
private final AuthorPostsRouteFactory authorPostsRouteFactory;
private final IndexRouteFactory indexRouteFactory;
public ThemeCompositeRouterFunction(PermalinkHttpGetRouter permalinkHttpGetRouter) {
this.permalinkHttpGetRouter = permalinkHttpGetRouter;
}
private List<RouterFunction<ServerResponse>> cachedRouters = List.of();
@Override
@NonNull
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
// this router function only supports GET method
if (!request.method().equals(HttpMethod.GET)) {
return Mono.empty();
}
return Mono.justOrEmpty(permalinkHttpGetRouter.route(request));
return Flux.fromIterable(cachedRouters)
.concatMap(routerFunction -> routerFunction.route(request))
.next();
}
@Override
public void accept(@NonNull RouterFunctions.Visitor visitor) {
cachedRouters.forEach(routerFunction -> routerFunction.accept(visitor));
}
List<RouterFunction<ServerResponse>> routerFunctions() {
return transformedPatterns()
.stream()
.map(this::createRouterFunction)
.collect(Collectors.toList());
}
private RouterFunction<ServerResponse> createRouterFunction(RoutePattern routePattern) {
return switch (routePattern.identifier()) {
case POST -> postRouteFactory.create(routePattern.pattern());
case ARCHIVES -> archiveRouteFactory.create(routePattern.pattern());
case CATEGORIES -> categoriesRouteFactory.create(routePattern.pattern());
case CATEGORY -> categoryPostRouteFactory.create(routePattern.pattern());
case TAGS -> tagsRouteFactory.create(routePattern.pattern());
case TAG -> tagPostRouteFactory.create(routePattern.pattern());
case AUTHOR -> authorPostsRouteFactory.create(routePattern.pattern());
case INDEX -> indexRouteFactory.create(routePattern.pattern());
default ->
throw new IllegalStateException("Unexpected value: " + routePattern.identifier());
};
}
/**
* Refresh the {@link #cachedRouters} when the permalink rule is changed.
*
* @param event {@link SchemeInitializedEvent} or {@link PermalinkRuleChangedEvent}
*/
@EventListener({SchemeInitializedEvent.class, PermalinkRuleChangedEvent.class})
public void onSchemeInitializedEvent(@NonNull ApplicationEvent event) {
this.cachedRouters = routerFunctions();
}
record RoutePattern(DefaultTemplateEnum identifier, String pattern) {
}
private List<RoutePattern> transformedPatterns() {
List<RoutePattern> routePatterns = new ArrayList<>();
SystemSetting.ThemeRouteRules rules =
environmentFetcher.fetch(SystemSetting.ThemeRouteRules.GROUP,
SystemSetting.ThemeRouteRules.class)
.blockOptional()
.orElse(SystemSetting.ThemeRouteRules.empty());
String post = rules.getPost();
routePatterns.add(new RoutePattern(DefaultTemplateEnum.POST, post));
String archives = rules.getArchives();
routePatterns.add(
new RoutePattern(DefaultTemplateEnum.ARCHIVES, archives));
String categories = rules.getCategories();
routePatterns.add(
new RoutePattern(DefaultTemplateEnum.CATEGORIES, categories));
routePatterns.add(
new RoutePattern(DefaultTemplateEnum.CATEGORY, categories));
String tags = rules.getTags();
routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAGS, tags));
routePatterns.add(new RoutePattern(DefaultTemplateEnum.TAG, tags));
// Add the index route to the end to prevent conflict with the queryParam rule of the post
routePatterns.add(new RoutePattern(DefaultTemplateEnum.INDEX, "/"));
return routePatterns;
}
}

View File

@ -0,0 +1,102 @@
package run.halo.app.theme.router.factories;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
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.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link ArchiveRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>posts.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class ArchiveRouteFactory implements RouteFactory {
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public RouterFunction<ServerResponse> create(String prefix) {
RequestPredicate requestPredicate = patterns(prefix).stream()
.map(RequestPredicates::GET)
.reduce(req -> false, RequestPredicate::or)
.and(accept(MediaType.TEXT_HTML));
return RouterFunctions.route(requestPredicate, handlerFunction());
}
HandlerFunction<ServerResponse> handlerFunction() {
return request -> {
String templateName = DefaultTemplateEnum.ARCHIVES.getValue();
return ServerResponse.ok()
.render(templateName,
Map.of("archives", archivePosts(request),
ModelConst.TEMPLATE_ID, templateName)
);
};
}
private List<String> patterns(String prefix) {
return List.of(
StringUtils.prependIfMissing(prefix, "/"),
PathUtils.combinePath(prefix, "/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}"),
PathUtils.combinePath(prefix,
"/{year:\\d{4}}/{month:\\d{2}}/page/{page:\\d+}")
);
}
private Mono<UrlContextListResult<PostArchiveVo>> archivePosts(ServerRequest request) {
ArchivePathVariables variables = ArchivePathVariables.from(request);
int pageNum = pageNumInPathVariable(request);
String requestPath = request.path();
return configuredPageSize(environmentFetcher)
.flatMap(pageSize -> postFinder.archives(pageNum, pageSize, variables.getYear(),
variables.getMonth()))
.map(list -> new UrlContextListResult.Builder<PostArchiveVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(requestPath, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(requestPath))
.build());
}
@Data
static class ArchivePathVariables {
String year;
String month;
String page;
static ArchivePathVariables from(ServerRequest request) {
Map<String, String> variables = request.pathVariables();
return JsonUtils.mapToObject(variables, ArchivePathVariables.class);
}
}
}

View File

@ -0,0 +1,78 @@
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.router.PageUrlUtils.totalPage;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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.User;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link AuthorPostsRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>index.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class AuthorPostsRouteFactory implements RouteFactory {
private final PostFinder postFinder;
private final ReactiveExtensionClient client;
private SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public RouterFunction<ServerResponse> create(String pattern) {
return RouterFunctions
.route(GET("/authors/{name}").or(GET("/authors/{name}/page/{page}"))
.and(accept(MediaType.TEXT_HTML)), handlerFunction());
}
HandlerFunction<ServerResponse> handlerFunction() {
return request -> {
String name = request.pathVariable("name");
return ServerResponse.ok()
.render(DefaultTemplateEnum.AUTHOR.getValue(),
Map.of("author", getByName(name),
"posts", postList(request, name),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue()
)
);
};
}
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request, String name) {
String path = request.path();
int pageNum = pageNumInPathVariable(request);
return configuredPageSize(environmentFetcher)
.flatMap(pageSize -> postFinder.listByOwner(pageNum, pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
private Mono<UserVo> getByName(String name) {
return client.get(User.class, name)
.map(UserVo::from);
}
}

View File

@ -1,17 +1,21 @@
package run.halo.app.theme.router.strategy;
package run.halo.app.theme.router.factories;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.CategoryFinder;
/**
* Categories router strategy for generate {@link HandlerFunction} specific to the template
* The {@link CategoriesRouteFactory} for generate {@link RouterFunction} specific to the
* template
* <code>categories.html</code>.
*
* @author guqing
@ -19,24 +23,20 @@ import run.halo.app.theme.finders.CategoryFinder;
*/
@Component
@AllArgsConstructor
public class CategoriesRouteStrategy implements ListPageRouteHandlerStrategy {
public class CategoriesRouteFactory implements RouteFactory {
private final CategoryFinder categoryFinder;
@Override
public HandlerFunction<ServerResponse> getHandler() {
public RouterFunction<ServerResponse> create(String prefix) {
return RouterFunctions.route(GET(StringUtils.prependIfMissing(prefix, "/")),
handlerFunction());
}
HandlerFunction<ServerResponse> handlerFunction() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.CATEGORIES.getValue(),
Map.of("categories", categoryFinder.listAsTree(),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.CATEGORIES.getValue()));
}
@Override
public List<String> getRouterPaths(String prefix) {
return List.of(StringUtils.prependIfMissing(prefix, "/"));
}
@Override
public boolean supports(DefaultTemplateEnum template) {
return DefaultTemplateEnum.CATEGORIES.equals(template);
}
}

View File

@ -0,0 +1,95 @@
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.router.PageUrlUtils.totalPage;
import java.util.HashMap;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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.Category;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.CategoryVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
import run.halo.app.theme.router.ViewNameResolver;
/**
* The {@link CategoryPostRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>category.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class CategoryPostRouteFactory implements RouteFactory {
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final ReactiveExtensionClient client;
private final ViewNameResolver viewNameResolver;
@Override
public RouterFunction<ServerResponse> create(String prefix) {
return RouterFunctions.route(GET(PathUtils.combinePath(prefix, "/{slug}"))
.or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}")))
.and(accept(MediaType.TEXT_HTML)), handlerFunction());
}
HandlerFunction<ServerResponse> handlerFunction() {
return request -> {
String slug = request.pathVariable("slug");
return fetchBySlug(slug)
.flatMap(categoryVo -> {
Map<String, Object> model = new HashMap<>();
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.CATEGORY.getValue());
model.put("posts",
postListByCategoryName(categoryVo.getMetadata().getName(), request));
model.put("category", categoryVo);
String template = categoryVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.CATEGORY.getValue())
.flatMap(viewName -> ServerResponse.ok().render(viewName, model));
})
.switchIfEmpty(
Mono.error(new NotFoundException("Category not found with slug: " + slug)));
};
}
Mono<CategoryVo> fetchBySlug(String slug) {
return client.list(Category.class, category -> category.getSpec().getSlug().equals(slug)
&& category.getMetadata().getDeletionTimestamp() == null, null)
.next()
.map(CategoryVo::from);
}
private Mono<UrlContextListResult<ListedPostVo>> postListByCategoryName(String name,
ServerRequest request) {
String path = request.path();
int pageNum = pageNumInPathVariable(request);
return configuredPageSize(environmentFetcher)
.flatMap(pageSize -> postFinder.listByCategory(pageNum, pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build()
);
}
}

View File

@ -1,15 +1,16 @@
package run.halo.app.theme.router.strategy;
package run.halo.app.theme.router.factories;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
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.router.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.strategy.ModelConst.DEFAULT_PAGE_SIZE;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@ -21,7 +22,7 @@ import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link IndexRouteStrategy} for generate {@link HandlerFunction} specific to the template
* The {@link IndexRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>index.html</code>.
*
* @author guqing
@ -29,38 +30,35 @@ import run.halo.app.theme.router.UrlContextListResult;
*/
@Component
@AllArgsConstructor
public class IndexRouteStrategy implements ListPageRouteHandlerStrategy {
public class IndexRouteFactory implements RouteFactory {
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request) {
String path = request.path();
return environmentFetcher.fetchPost()
.map(p -> defaultIfNull(p.getPostPageSize(), DEFAULT_PAGE_SIZE))
.flatMap(pageSize -> postFinder.list(pageNum(request), pageSize))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
@Override
public RouterFunction<ServerResponse> create(String pattern) {
return RouterFunctions
.route(GET("/").or(GET("/page/{page}")
.or(GET("/index")).or(GET("/index/page/{page}"))
.and(accept(MediaType.TEXT_HTML))), handlerFunction());
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
HandlerFunction<ServerResponse> handlerFunction() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.INDEX.getValue(),
Map.of("posts", postList(request),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.INDEX.getValue()));
}
@Override
public List<String> getRouterPaths(String pattern) {
return List.of("/", "/index");
}
@Override
public boolean supports(DefaultTemplateEnum template) {
return DefaultTemplateEnum.INDEX.equals(template);
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request) {
String path = request.path();
return configuredPageSize(environmentFetcher)
.flatMap(pageSize -> postFinder.list(pageNumInPathVariable(request), pageSize))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build()
);
}
}

View File

@ -1,4 +1,4 @@
package run.halo.app.theme.router.strategy;
package run.halo.app.theme.router.factories;
/**
* Static variable keys for view model.

View File

@ -0,0 +1,236 @@
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 com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
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;
/**
* The {@link PostRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>post.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class PostRouteFactory implements RouteFactory {
private final PostFinder postFinder;
private final ViewNameResolver viewNameResolver;
private final ReactiveExtensionClient client;
@Override
public RouterFunction<ServerResponse> create(String pattern) {
PatternParser postParamPredicate =
new PatternParser(pattern);
if (postParamPredicate.isQueryParamPattern()) {
RequestPredicate requestPredicate = postParamPredicate.toRequestPredicate();
return RouterFunctions.route(GET("/")
.and(requestPredicate), queryParamHandlerFunction(postParamPredicate));
}
return RouterFunctions
.route(GET(pattern).and(accept(MediaType.TEXT_HTML)), handlerFunction());
}
HandlerFunction<ServerResponse> queryParamHandlerFunction(PatternParser paramPredicate) {
return request -> {
Map<String, String> variables = mergedVariables(request);
PostPatternVariable patternVariable = new PostPatternVariable();
Optional.ofNullable(variables.get(paramPredicate.getQueryParamName()))
.ifPresent(value -> {
switch (paramPredicate.getPlaceholderName()) {
case "name" -> patternVariable.setName(value);
case "slug" -> patternVariable.setSlug(value);
default ->
throw new IllegalArgumentException("Unsupported query param predicate");
}
});
return postResponse(request, patternVariable);
};
}
HandlerFunction<ServerResponse> handlerFunction() {
return request -> {
PostPatternVariable patternVariable = PostPatternVariable.from(request);
return postResponse(request, patternVariable);
};
}
@NonNull
private Mono<ServerResponse> postResponse(ServerRequest request,
PostPatternVariable patternVariable) {
Mono<PostVo> postVoMono = bestMatchPost(patternVariable);
return postVoMono
.flatMap(postVo -> {
Map<String, Object> model = new HashMap<>();
model.put("groupVersionKind", GroupVersionKind.fromExtension(Post.class));
GVK gvk = Post.class.getAnnotation(GVK.class);
model.put("plural", gvk.plural());
model.put("post", postVo);
String template = postVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.POST.getValue())
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
});
}
Mono<PostVo> bestMatchPost(PostPatternVariable variable) {
return postsByPredicates(variable)
.filter(post -> {
Map<String, String> labels = ExtensionUtil.nullSafeLabels(post);
return matchIfPresent(variable.getName(), post.getMetadata().getName())
&& matchIfPresent(variable.getSlug(), post.getSpec().getSlug())
&& matchIfPresent(variable.getYear(), labels.get(Post.ARCHIVE_YEAR_LABEL))
&& matchIfPresent(variable.getMonth(), labels.get(Post.ARCHIVE_MONTH_LABEL))
&& matchIfPresent(variable.getDay(), labels.get(Post.ARCHIVE_DAY_LABEL));
})
.next()
.flatMap(post -> postFinder.getByName(post.getMetadata().getName()))
.switchIfEmpty(Mono.error(new NotFoundException("Post not found")));
}
Flux<Post> postsByPredicates(PostPatternVariable patternVariable) {
if (StringUtils.isNotBlank(patternVariable.getName())) {
return fetchPostsByName(patternVariable.getName());
}
if (StringUtils.isNotBlank(patternVariable.getSlug())) {
return fetchPostsBySlug(patternVariable.getSlug());
}
return Flux.empty();
}
private Flux<Post> fetchPostsByName(String name) {
return client.fetch(Post.class, name)
.filter(PostFinderImpl.FIXED_PREDICATE)
.flux();
}
private Flux<Post> fetchPostsBySlug(String slug) {
return client.list(Post.class,
post -> PostFinderImpl.FIXED_PREDICATE.test(post)
&& matchIfPresent(slug, post.getSpec().getSlug()),
null);
}
private boolean matchIfPresent(String variable, String target) {
return StringUtils.isBlank(variable) || StringUtils.equals(target, variable);
}
@Data
static class PostPatternVariable {
String name;
String slug;
String year;
String month;
String day;
static PostPatternVariable from(ServerRequest request) {
Map<String, String> variables = mergedVariables(request);
return JsonUtils.mapToObject(variables, PostPatternVariable.class);
}
}
static Map<String, String> mergedVariables(ServerRequest request) {
Map<String, String> pathVariables = request.pathVariables();
MultiValueMap<String, String> queryParams = request.queryParams();
Map<String, String> mergedVariables = new LinkedHashMap<>();
for (String paramKey : queryParams.keySet()) {
mergedVariables.put(paramKey, queryParams.getFirst(paramKey));
}
// path variables higher priority will override query params
mergedVariables.putAll(pathVariables);
return mergedVariables;
}
static class PatternParser {
private static final Pattern PATTERN_COMPILE = Pattern.compile("([^&?]*)=\\{(.*?)\\}(&|$)");
private static final Cache<String, Matcher> MATCHER_CACHE = CacheBuilder.newBuilder()
.maximumSize(5)
.build();
private final String pattern;
private String paramName;
private String placeholderName;
private final boolean isQueryParamPattern;
PatternParser(String pattern) {
this.pattern = pattern;
Matcher matcher = patternToMatcher(pattern);
if (matcher.find()) {
this.paramName = matcher.group(1);
this.placeholderName = matcher.group(2);
this.isQueryParamPattern = true;
} else {
this.isQueryParamPattern = false;
}
}
Matcher patternToMatcher(String pattern) {
try {
return MATCHER_CACHE.get(pattern, () -> PATTERN_COMPILE.matcher(pattern));
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
RequestPredicate toRequestPredicate() {
if (!this.isQueryParamPattern) {
throw new IllegalStateException("Not a query param pattern: " + pattern);
}
return RequestPredicates.queryParam(paramName, value -> true);
}
public String getPlaceholderName() {
return this.placeholderName;
}
public String getQueryParamName() {
return this.paramName;
}
public boolean isQueryParamPattern() {
return isQueryParamPattern;
}
}
}

View File

@ -0,0 +1,29 @@
package run.halo.app.theme.router.factories;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import org.apache.commons.lang3.math.NumberUtils;
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.infra.SystemConfigurableEnvironmentFetcher;
/**
* @author guqing
* @since 2.0.0
*/
public interface RouteFactory {
RouterFunction<ServerResponse> create(String pattern);
default Mono<Integer> configuredPageSize(
SystemConfigurableEnvironmentFetcher environmentFetcher) {
return environmentFetcher.fetchPost()
.map(p -> defaultIfNull(p.getTagPageSize(), ModelConst.DEFAULT_PAGE_SIZE));
}
default int pageNumInPathVariable(ServerRequest request) {
String page = request.pathVariables().get("page");
return NumberUtils.toInt(page, 1);
}
}

View File

@ -0,0 +1,89 @@
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.router.PageUrlUtils.totalPage;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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 reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.TagVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link TagPostRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>tag.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class TagPostRouteFactory implements RouteFactory {
private final ReactiveExtensionClient client;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final TagFinder tagFinder;
private final PostFinder postFinder;
@Override
public RouterFunction<ServerResponse> create(String prefix) {
return RouterFunctions
.route(GET(PathUtils.combinePath(prefix, "/{slug}"))
.or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}")))
.and(accept(MediaType.TEXT_HTML)), handlerFunction());
}
private HandlerFunction<ServerResponse> handlerFunction() {
return request -> tagBySlug(request.pathVariable("slug"))
.flatMap(tagVo -> {
int pageNum = pageNumInPathVariable(request);
String path = request.path();
var postList = postList(tagVo.getMetadata().getName(), pageNum, path);
return ServerResponse.ok()
.render(DefaultTemplateEnum.TAG.getValue(),
Map.of("name", tagVo.getMetadata().getName(),
"posts", postList,
"tag", tagVo)
);
});
}
private Mono<UrlContextListResult<ListedPostVo>> postList(String name, Integer page,
String requestPath) {
return configuredPageSize(environmentFetcher)
.flatMap(pageSize -> postFinder.listByTag(page, pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(requestPath, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(requestPath))
.build()
);
}
private Mono<TagVo> tagBySlug(String slug) {
return client.list(Tag.class, tag -> tag.getSpec().getSlug().equals(slug)
&& tag.getMetadata().getDeletionTimestamp() == null, null)
.next()
.flatMap(tag -> tagFinder.getByName(tag.getMetadata().getName()))
.switchIfEmpty(
Mono.error(new NotFoundException("Tag not found with slug: " + slug)));
}
}

View File

@ -0,0 +1,46 @@
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 java.util.Map;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
/**
* The {@link TagsRouteFactory} for generate {@link RouterFunction} specific to the template
* <code>tags.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class TagsRouteFactory implements RouteFactory {
private final TagFinder tagFinder;
@Override
public RouterFunction<ServerResponse> create(String prefix) {
return RouterFunctions
.route(GET(StringUtils.prependIfMissing(prefix, "/"))
.and(accept(MediaType.TEXT_HTML)), handlerFunction());
}
private HandlerFunction<ServerResponse> handlerFunction() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.TAGS.getValue(),
Map.of("tags", tagFinder.listAll(),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.TAGS.getValue()
)
);
}
}

View File

@ -1,88 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.strategy.ModelConst.DEFAULT_PAGE_SIZE;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link ArchivesRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>posts.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class ArchivesRouteStrategy implements ListPageRouteHandlerStrategy {
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public ArchivesRouteStrategy(PostFinder postFinder,
SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.postFinder = postFinder;
this.environmentFetcher = environmentFetcher;
}
private Mono<UrlContextListResult<PostArchiveVo>> postList(ServerRequest request) {
String year = pathVariable(request, "year");
String month = pathVariable(request, "month");
String path = request.path();
return environmentFetcher.fetchPost()
.map(postSetting -> defaultIfNull(postSetting.getArchivePageSize(), DEFAULT_PAGE_SIZE))
.flatMap(pageSize -> postFinder.archives(pageNum(request), pageSize, year, month))
.map(list -> new UrlContextListResult.Builder<PostArchiveVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
private String pathVariable(ServerRequest request, String name) {
Map<String, String> pathVariables = request.pathVariables();
if (pathVariables.containsKey(name)) {
return pathVariables.get(name);
}
return null;
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.ARCHIVES.getValue(),
Map.of("archives", postList(request)));
}
@Override
public List<String> getRouterPaths(String prefix) {
return List.of(
StringUtils.prependIfMissing(prefix, "/"),
PathUtils.combinePath(prefix, "/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/page/{page:\\d+}"),
PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}"),
PathUtils.combinePath(prefix,
"/{year:\\d{4}}/{month:\\d{2}}/page/{page:\\d+}")
);
}
@Override
public boolean supports(DefaultTemplateEnum template) {
return DefaultTemplateEnum.ARCHIVES.equals(template);
}
}

View File

@ -1,76 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.User;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* Author route strategy.
*
* @author guqing
* @since 2.0.1
*/
@Component
@AllArgsConstructor
public class AuthorRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final ReactiveExtensionClient client;
private final PostFinder postFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.AUTHOR.getValue(),
Map.of("name", name,
"author", getByName(name),
"posts", postList(request, name),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.AUTHOR.getValue()
)
);
}
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request, String name) {
String path = request.path();
return environmentFetcher.fetchPost()
.map(p -> defaultIfNull(p.getPostPageSize(), ModelConst.DEFAULT_PAGE_SIZE))
.flatMap(pageSize -> postFinder.listByOwner(pageNum(request), pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
private Mono<UserVo> getByName(String name) {
return client.fetch(User.class, name)
.map(UserVo::from);
}
@Override
public boolean supports(GroupVersionKind gvk) {
return GroupVersionKind.fromExtension(User.class).equals(gvk);
}
}

View File

@ -1,83 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.HashMap;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.Category;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
import run.halo.app.theme.router.ViewNameResolver;
/**
* The {@link CategoryRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>category.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class CategoryRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Category.class);
private final PostFinder postFinder;
private final CategoryFinder categoryFinder;
private final ViewNameResolver viewNameResolver;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private Mono<UrlContextListResult<ListedPostVo>> postListByCategoryName(String name,
ServerRequest request) {
String path = request.path();
return environmentFetcher.fetchPost()
.map(post -> defaultIfNull(post.getCategoryPageSize(), ModelConst.DEFAULT_PAGE_SIZE))
.flatMap(
pageSize -> postFinder.listByCategory(pageNum(request), pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> {
Map<String, Object> model = new HashMap<>();
model.put("name", name);
model.put("posts", postListByCategoryName(name, request));
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.CATEGORY.getValue());
return categoryFinder.getByName(name).flatMap(categoryVo -> {
model.put("category", categoryVo);
String template = categoryVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.CATEGORY.getValue())
.flatMap(viewName -> ServerResponse.ok().render(viewName, model));
});
};
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -1,21 +0,0 @@
package run.halo.app.theme.router.strategy;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.SystemSetting;
/**
* The {@link DetailsPageRouteHandlerStrategy} for generate {@link HandlerFunction} specific to the
* template.
*
* @author guqing
* @since 2.0.0
*/
public interface DetailsPageRouteHandlerStrategy {
HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name);
boolean supports(GroupVersionKind gvk);
}

View File

@ -1,22 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.List;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* The {@link ListPageRouteHandlerStrategy} for generate {@link HandlerFunction} specific to the
* template.
*
* @author guqing
* @since 2.0.0
*/
public interface ListPageRouteHandlerStrategy {
HandlerFunction<ServerResponse> getHandler();
List<String> getRouterPaths(String pattern);
boolean supports(DefaultTemplateEnum template);
}

View File

@ -1,72 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.server.PathContainer;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.router.ViewNameResolver;
/**
* The {@link PostRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>post.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PostRouteStrategy implements DetailsPageRouteHandlerStrategy {
static final String NAME_PARAM = "name";
private final GroupVersionKind groupVersionKind = GroupVersionKind.fromExtension(Post.class);
private final PostFinder postFinder;
private final ViewNameResolver viewNameResolver;
public PostRouteStrategy(PostFinder postFinder, ViewNameResolver viewNameResolver) {
this.postFinder = postFinder;
this.viewNameResolver = viewNameResolver;
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
final String name) {
return request -> {
String pattern = routeRules.getPost();
final GVK gvk = Post.class.getAnnotation(GVK.class);
PathPattern parse = PathPatternParser.defaultInstance.parse(pattern);
PathPattern.PathMatchInfo pathMatchInfo =
parse.matchAndExtract(PathContainer.parsePath(request.path()));
Map<String, Object> model = new HashMap<>();
model.put(NAME_PARAM, name);
if (pathMatchInfo != null) {
model.putAll(pathMatchInfo.getUriVariables());
}
// used by HaloTrackerProcessor
model.put("groupVersionKind", groupVersionKind);
model.put("plural", gvk.plural());
// used by TemplateGlobalHeadProcessor and PostTemplateHeadProcessor
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.POST.getValue());
return postFinder.getByName(name)
.flatMap(postVo -> {
model.put("post", postVo);
String template = postVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.POST.getValue())
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
});
};
}
@Override
public boolean supports(GroupVersionKind gvk) {
return groupVersionKind.equals(gvk);
}
}

View File

@ -1,64 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.ViewNameResolver;
/**
* The {@link SinglePageRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>page.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class SinglePageRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
private final SinglePageFinder singlePageFinder;
private final ViewNameResolver viewNameResolver;
public SinglePageRouteStrategy(SinglePageFinder singlePageFinder,
ViewNameResolver viewNameResolver) {
this.singlePageFinder = singlePageFinder;
this.viewNameResolver = viewNameResolver;
}
private String getPlural() {
GVK annotation = SinglePage.class.getAnnotation(GVK.class);
return annotation.plural();
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> {
Map<String, Object> model = new HashMap<>();
model.put("name", name);
model.put("groupVersionKind", gvk);
model.put("plural", getPlural());
model.put(ModelConst.TEMPLATE_ID, DefaultTemplateEnum.SINGLE_PAGE.getValue());
return singlePageFinder.getByName(name).flatMap(singlePageVo -> {
model.put("singlePage", singlePageVo);
String template = singlePageVo.getSpec().getTemplate();
return viewNameResolver.resolveViewNameOrDefault(request, template,
DefaultTemplateEnum.SINGLE_PAGE.getValue())
.flatMap(viewName -> ServerResponse.ok().render(viewName, model));
});
};
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -1,72 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
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.Tag;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link TagRouteStrategy} for generate {@link RouterFunction} specific to the template
* <code>tag.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class TagRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Tag.class);
private final PostFinder postFinder;
private final TagFinder tagFinder;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private Mono<UrlContextListResult<ListedPostVo>> postList(ServerRequest request, String name) {
String path = request.path();
return environmentFetcher.fetchPost()
.map(p -> defaultIfNull(p.getTagPageSize(), ModelConst.DEFAULT_PAGE_SIZE))
.flatMap(pageSize -> postFinder.listByTag(pageNum(request), pageSize, name))
.map(list -> new UrlContextListResult.Builder<ListedPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.TAG.getValue(),
Map.of("name", name,
"posts", postList(request, name),
"tag", tagFinder.getByName(name),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.TAG.getValue()
)
);
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -1,47 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
/**
* The {@link TagsRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>tags.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class TagsRouteStrategy implements ListPageRouteHandlerStrategy {
private final TagFinder tagFinder;
public TagsRouteStrategy(TagFinder tagFinder) {
this.tagFinder = tagFinder;
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.TAGS.getValue(),
Map.of("tags", tagFinder.listAll(),
ModelConst.TEMPLATE_ID, DefaultTemplateEnum.TAGS.getValue()
)
);
}
@Override
public List<String> getRouterPaths(String prefix) {
return List.of(StringUtils.prependIfMissing(prefix, "/"));
}
@Override
public boolean supports(DefaultTemplateEnum template) {
return DefaultTemplateEnum.TAGS.equals(template);
}
}

View File

@ -1,7 +1,6 @@
package run.halo.app.content.permalinks;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.net.URI;
@ -10,12 +9,10 @@ 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.context.ApplicationContext;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkPatternProvider;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
/**
* Tests for {@link CategoryPermalinkPolicy}.
@ -26,28 +23,22 @@ import run.halo.app.theme.router.PermalinkPatternProvider;
@ExtendWith(MockitoExtension.class)
class CategoryPermalinkPolicyTest {
@Mock
private PermalinkPatternProvider permalinkPatternProvider;
@Mock
private ApplicationContext applicationContext;
@Mock
private ExternalUrlSupplier externalUrlSupplier;
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
private CategoryPermalinkPolicy categoryPermalinkPolicy;
@BeforeEach
void setUp() {
categoryPermalinkPolicy =
new CategoryPermalinkPolicy(applicationContext, permalinkPatternProvider,
externalUrlSupplier);
new CategoryPermalinkPolicy(externalUrlSupplier, environmentFetcher);
}
@Test
void permalink() {
when(permalinkPatternProvider.getPattern(eq(DefaultTemplateEnum.CATEGORY)))
.thenReturn("categories");
Category category = new Category();
Metadata metadata = new Metadata();
metadata.setName("category-test");
@ -70,18 +61,4 @@ class CategoryPermalinkPolicyTest {
permalink = categoryPermalinkPolicy.permalink(category);
assertThat(permalink).isEqualTo("http://exmaple.com/categories/%E4%B8%AD%E6%96%87%20slug");
}
@Test
void templateName() {
String s = categoryPermalinkPolicy.templateName();
assertThat(s).isEqualTo(DefaultTemplateEnum.CATEGORY.getValue());
}
@Test
void pattern() {
when(permalinkPatternProvider.getPattern(eq(DefaultTemplateEnum.CATEGORY)))
.thenReturn("categories");
String pattern = categoryPermalinkPolicy.pattern();
assertThat(pattern).isEqualTo("categories");
}
}

View File

@ -10,6 +10,7 @@ import java.text.NumberFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -17,11 +18,12 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Constant;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkPatternProvider;
/**
* Tests for {@link PostPermalinkPolicy}.
@ -33,27 +35,28 @@ import run.halo.app.theme.router.PermalinkPatternProvider;
class PostPermalinkPolicyTest {
private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00");
@Mock
private PermalinkPatternProvider permalinkPatternProvider;
@Mock
private ApplicationContext applicationContext;
@Mock
private ExternalUrlSupplier externalUrlSupplier;
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
private PostPermalinkPolicy postPermalinkPolicy;
@BeforeEach
void setUp() {
lenient().when(externalUrlSupplier.get()).thenReturn(URI.create(""));
postPermalinkPolicy = new PostPermalinkPolicy(permalinkPatternProvider, applicationContext,
externalUrlSupplier);
postPermalinkPolicy = new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier);
}
@Test
void permalink() {
Post post = TestPost.postV1();
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(post);
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{year}/{month}/{day}/{slug}");
post.getMetadata().setName("test-post");
post.getSpec().setSlug("test-post-slug");
Instant now = Instant.now();
@ -64,35 +67,28 @@ class PostPermalinkPolicyTest {
String month = NUMBER_FORMAT.format(zonedDateTime.getMonthValue());
String day = NUMBER_FORMAT.format(zonedDateTime.getDayOfMonth());
// pattern /{year}/{month}/{day}/{slug}
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/{year}/{month}/{day}/{slug}");
String permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink)
.isEqualTo(PathUtils.combinePath(year, month, day, post.getSpec().getSlug()));
// pattern {month}/{day}/{slug}
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/{month}/{day}/{slug}");
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{month}/{day}/{slug}");
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink)
.isEqualTo(PathUtils.combinePath(month, day, post.getSpec().getSlug()));
// pattern /?p={name}
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/?p={name}");
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/?p={name}");
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("/?p=test-post");
// pattern /posts/{slug}
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/posts/{slug}");
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/posts/{slug}");
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("/posts/test-post-slug");
// pattern /posts/{name}
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/posts/{name}");
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/posts/{name}");
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("/posts/test-post");
}
@ -100,6 +96,8 @@ class PostPermalinkPolicyTest {
@Test
void permalinkWithExternalUrl() {
Post post = TestPost.postV1();
Map<String, String> annotations = ExtensionUtil.nullSafeAnnotations(post);
annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{year}/{month}/{day}/{slug}");
post.getMetadata().setName("test-post");
post.getSpec().setSlug("test-post-slug");
Instant now = Instant.parse("2022-11-01T02:40:06.806310Z");
@ -107,8 +105,6 @@ class PostPermalinkPolicyTest {
when(externalUrlSupplier.get()).thenReturn(URI.create("http://example.com"));
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/{year}/{month}/{day}/{slug}");
String permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("http://example.com/2022/11/01/test-post-slug");
@ -116,17 +112,4 @@ class PostPermalinkPolicyTest {
permalink = postPermalinkPolicy.permalink(post);
assertThat(permalink).isEqualTo("http://example.com/2022/11/01/%E4%B8%AD%E6%96%87%20slug");
}
@Test
void templateName() {
String s = postPermalinkPolicy.templateName();
assertThat(s).isEqualTo(DefaultTemplateEnum.POST.getValue());
}
@Test
void pattern() {
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST))
.thenReturn("/{year}/{month}/{day}/{slug}");
assertThat(postPermalinkPolicy.pattern()).isEqualTo("/{year}/{month}/{day}/{slug}");
}
}

View File

@ -1,7 +1,6 @@
package run.halo.app.content.permalinks;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.net.URI;
@ -14,8 +13,7 @@ import org.springframework.context.ApplicationContext;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkPatternProvider;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
/**
* Tests for {@link TagPermalinkPolicy}.
@ -26,28 +24,24 @@ import run.halo.app.theme.router.PermalinkPatternProvider;
@ExtendWith(MockitoExtension.class)
class TagPermalinkPolicyTest {
@Mock
private PermalinkPatternProvider permalinkPatternProvider;
@Mock
private ApplicationContext applicationContext;
@Mock
private ExternalUrlSupplier externalUrlSupplier;
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
private TagPermalinkPolicy tagPermalinkPolicy;
@BeforeEach
void setUp() {
tagPermalinkPolicy = new TagPermalinkPolicy(permalinkPatternProvider, applicationContext,
externalUrlSupplier);
tagPermalinkPolicy = new TagPermalinkPolicy(externalUrlSupplier, environmentFetcher);
}
@Test
void permalink() {
when(permalinkPatternProvider.getPattern(eq(DefaultTemplateEnum.TAG)))
.thenReturn("tags");
Tag tag = new Tag();
Metadata metadata = new Metadata();
metadata.setName("test-tag");
@ -70,17 +64,4 @@ class TagPermalinkPolicyTest {
permalink = tagPermalinkPolicy.permalink(tag);
assertThat(permalink).isEqualTo("http://example.com/tags/%E4%B8%AD%E6%96%87slug");
}
@Test
void templateName() {
String s = tagPermalinkPolicy.templateName();
assertThat(s).isEqualTo(DefaultTemplateEnum.TAG.getValue());
}
@Test
void pattern() {
when(permalinkPatternProvider.getPattern(eq(DefaultTemplateEnum.TAG)))
.thenReturn("tags");
assertThat(tagPermalinkPolicy.pattern()).isEqualTo("tags");
}
}

View File

@ -47,7 +47,7 @@ class CategoryReconcilerTest {
reconcileStatusPostPilling("category-A");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(2)).update(captor.capture());
verify(client, times(3)).update(captor.capture());
assertThat(captor.getAllValues().get(1).getStatusOrDefault().getPostCount()).isEqualTo(4);
assertThat(
captor.getAllValues().get(1).getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
@ -57,7 +57,7 @@ class CategoryReconcilerTest {
void reconcileStatusPostForCategoryB() throws JSONException {
reconcileStatusPostPilling("category-B");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(2)).update(captor.capture());
verify(client, times(3)).update(captor.capture());
Category category = captor.getAllValues().get(1);
assertThat(category.getStatusOrDefault().getPostCount()).isEqualTo(3);
assertThat(category.getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
@ -67,7 +67,7 @@ class CategoryReconcilerTest {
void reconcileStatusPostForCategoryC() throws JSONException {
reconcileStatusPostPilling("category-C");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(2)).update(captor.capture());
verify(client, times(3)).update(captor.capture());
assertThat(captor.getAllValues().get(1).getStatusOrDefault().getPostCount()).isEqualTo(2);
assertThat(
captor.getAllValues().get(1).getStatusOrDefault().getVisiblePostCount()).isEqualTo(0);
@ -77,7 +77,7 @@ class CategoryReconcilerTest {
void reconcileStatusPostForCategoryD() throws JSONException {
reconcileStatusPostPilling("category-D");
ArgumentCaptor<Category> captor = ArgumentCaptor.forClass(Category.class);
verify(client, times(2)).update(captor.capture());
verify(client, times(3)).update(captor.capture());
assertThat(captor.getAllValues().get(1).getStatusOrDefault().postCount).isEqualTo(1);
assertThat(captor.getAllValues().get(1).getStatusOrDefault().visiblePostCount).isEqualTo(0);
}

View File

@ -80,9 +80,6 @@ class PostReconcilerTest {
verify(client, times(3)).update(captor.capture());
verify(postPermalinkPolicy, times(1)).permalink(any());
verify(postPermalinkPolicy, times(1)).onPermalinkAdd(any());
verify(postPermalinkPolicy, times(1)).onPermalinkDelete(any());
verify(postPermalinkPolicy, times(0)).onPermalinkUpdate(any());
Post value = captor.getValue();
assertThat(value.getStatus().getExcerpt()).isNull();

View File

@ -3,7 +3,6 @@ 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;
@ -34,9 +33,6 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.metrics.CounterService;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
/**
* Tests for {@link SinglePageReconciler}.
@ -97,10 +93,6 @@ class SinglePageReconcilerTest {
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));
}
@Test

View File

@ -40,6 +40,28 @@ class TagReconcilerTest {
@InjectMocks
private TagReconciler tagReconciler;
@Test
void reconcile() {
Tag tag = tag();
when(client.fetch(eq(Tag.class), eq("fake-tag")))
.thenReturn(Optional.of(tag));
when(tagPermalinkPolicy.permalink(any()))
.thenAnswer(arg -> "/tags/" + tag.getSpec().getSlug());
ArgumentCaptor<Tag> captor = ArgumentCaptor.forClass(Tag.class);
tagReconciler.reconcile(new TagReconciler.Request("fake-tag"));
verify(client, times(3)).update(captor.capture());
Tag capture = captor.getValue();
assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/fake-slug");
// change slug
tag.getSpec().setSlug("new-slug");
tagReconciler.reconcile(new TagReconciler.Request("fake-tag"));
verify(client, times(5)).update(captor.capture());
assertThat(capture.getStatus().getPermalink()).isEqualTo("/tags/new-slug");
}
@Test
void reconcileDelete() {
Tag tag = tag();
@ -50,8 +72,6 @@ class TagReconcilerTest {
tagReconciler.reconcile(new TagReconciler.Request("fake-tag"));
verify(client, times(1)).update(captor.capture());
verify(tagPermalinkPolicy, times(0)).onPermalinkAdd(any());
verify(tagPermalinkPolicy, times(1)).onPermalinkDelete(any());
verify(tagPermalinkPolicy, times(0)).permalink(any());
}

View File

@ -24,7 +24,6 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
/**
* Tests for {@link UserReconciler}.
@ -54,7 +53,6 @@ class UserReconcilerTest {
.thenReturn(Optional.of(user("fake-user")));
userReconciler.reconcile(new Reconciler.Request("fake-user"));
verify(client, times(1)).update(any(User.class));
verify(eventPublisher, times(1)).publishEvent(any(PermalinkIndexUpdateCommand.class));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(1)).update(captor.capture());
@ -68,7 +66,6 @@ class UserReconcilerTest {
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
verify(client, times(0)).update(any(User.class));
verify(eventPublisher, times(0)).publishEvent(any(PermalinkIndexUpdateCommand.class));
}
User user(String name) {

View File

@ -38,7 +38,7 @@ import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.strategy.ModelConst;
import run.halo.app.theme.router.factories.ModelConst;
/**
* Tests for {@link HaloProcessorDialect}.

View File

@ -7,6 +7,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -15,6 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@ -52,9 +54,13 @@ class SinglePageFinderImplTest {
SinglePage singlePage = new SinglePage();
singlePage.setMetadata(new Metadata());
singlePage.getMetadata().setName(fakePageName);
singlePage.getMetadata().setLabels(Map.of(SinglePage.PUBLISHED_LABEL, "true"));
singlePage.setSpec(new SinglePage.SinglePageSpec());
singlePage.getSpec().setOwner("fake-owner");
singlePage.getSpec().setReleaseSnapshot("fake-release");
singlePage.getSpec().setPublish(true);
singlePage.getSpec().setDeleted(false);
singlePage.getSpec().setVisible(Post.VisibleEnum.PUBLIC);
singlePage.setStatus(new SinglePage.SinglePageStatus());
when(client.fetch(eq(SinglePage.class), eq(fakePageName)))
.thenReturn(Mono.just(singlePage));

View File

@ -1,4 +1,4 @@
package run.halo.app.theme.router.strategy;
package run.halo.app.theme.router;
import java.util.Map;
import org.springframework.http.MediaType;

View File

@ -1,136 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.util.List;
import java.util.NoSuchElementException;
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.context.ApplicationContext;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.GroupVersionKind;
/**
* Tests for {@link PermalinkIndexer}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class PermalinkIndexerTest {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(FakeExtension.class);
private PermalinkIndexer permalinkIndexer;
@Mock
private ApplicationContext applicationContext;
@BeforeEach
void setUp() {
permalinkIndexer = new PermalinkIndexer(applicationContext);
ExtensionLocator locator = new ExtensionLocator(gvk, "fake-name", "fake-slug");
permalinkIndexer.register(locator, "/fake-permalink");
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(1);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(1);
}
@Test
void register() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
verify(applicationContext, times(2)).publishEvent(any(PermalinkIndexChangedEvent.class));
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(2);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(2);
}
@Test
void remove() {
ExtensionLocator locator = new ExtensionLocator(gvk, "fake-name", "fake-slug");
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(1);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(1);
permalinkIndexer.remove(locator);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(0);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(0);
verify(applicationContext, times(2)).publishEvent(any(PermalinkIndexChangedEvent.class));
}
@Test
void lookup() {
ExtensionLocator lookup = permalinkIndexer.lookup("/fake-permalink");
assertThat(lookup).isEqualTo(new ExtensionLocator(gvk, "fake-name", "fake-slug"));
lookup = permalinkIndexer.lookup("/nothing");
assertThat(lookup).isNull();
}
@Test
void getPermalinks() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
List<String> permalinks = permalinkIndexer.getPermalinks(gvk);
assertThat(permalinks).isEqualTo(List.of("/fake-permalink", "/test-permalink"));
}
@Test
void getNames() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
assertThat(permalinkIndexer.containsName(gvk, "test-name")).isTrue();
assertThat(permalinkIndexer.containsName(gvk, "nothing")).isFalse();
}
@Test
void getSlugs() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
assertThat(permalinkIndexer.containsSlug(gvk, "fake-slug")).isTrue();
assertThat(permalinkIndexer.containsSlug(gvk, "test-slug")).isTrue();
assertThat(permalinkIndexer.containsSlug(gvk, "nothing")).isFalse();
}
@Test
void getNameBySlug() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
String nameBySlug = permalinkIndexer.getNameBySlug(gvk, "test-slug");
assertThat(nameBySlug).isEqualTo("test-name");
nameBySlug = permalinkIndexer.getNameBySlug(gvk, "fake-slug");
assertThat(nameBySlug).isEqualTo("fake-name");
assertThatThrownBy(() -> {
permalinkIndexer.getNameBySlug(gvk, "nothing");
}).isInstanceOf(NoSuchElementException.class);
}
@Test
void getNameByPermalink() {
ExtensionLocator locator = new ExtensionLocator(gvk, "test-name", "test-slug");
permalinkIndexer.register(locator, "/test-permalink");
var name = permalinkIndexer.getNameByPermalink(gvk, "/test-permalink");
assertEquals("test-name", name);
name = permalinkIndexer.getNameByPermalink(gvk, "/invalid-permalink");
assertNull(name);
}
}

View File

@ -1,101 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
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 run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* Tests for {@link PermalinkPatternProvider}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class PermalinkPatternProviderTest {
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
@InjectMocks
private PermalinkPatternProvider permalinkPatternProvider;
@Test
void getPatternThenDefault() {
when(environmentFetcher.getConfigMapBlocking())
.thenReturn(Optional.empty());
String pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST);
assertThat(pattern).isEqualTo("/archives/{slug}");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAG);
assertThat(pattern).isEqualTo("tags");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAGS);
assertThat(pattern).isEqualTo("tags");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.CATEGORY);
assertThat(pattern).isEqualTo("categories");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.CATEGORIES);
assertThat(pattern).isEqualTo("categories");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.ARCHIVES);
assertThat(pattern).isEqualTo("archives");
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.INDEX);
assertThat(pattern).isNull();
}
@Test
void getPattern() {
ConfigMap configMap = new ConfigMap();
Metadata metadata = new Metadata();
metadata.setName("system");
configMap.setMetadata(metadata);
SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
themeRouteRules.setPost("/posts/{slug}");
themeRouteRules.setCategories("c");
themeRouteRules.setTags("t");
themeRouteRules.setArchives("a");
configMap.setData(Map.of("routeRules", JsonUtils.objectToJson(themeRouteRules)));
when(environmentFetcher.getConfigMapBlocking())
.thenReturn(Optional.of(configMap));
String pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.POST);
assertThat(pattern).isEqualTo(themeRouteRules.getPost());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAG);
assertThat(pattern).isEqualTo(themeRouteRules.getTags());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAGS);
assertThat(pattern).isEqualTo(themeRouteRules.getTags());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.CATEGORY);
assertThat(pattern).isEqualTo(themeRouteRules.getCategories());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.CATEGORIES);
assertThat(pattern).isEqualTo(themeRouteRules.getCategories());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.ARCHIVES);
assertThat(pattern).isEqualTo(themeRouteRules.getArchives());
pattern = permalinkPatternProvider.getPattern(DefaultTemplateEnum.INDEX);
assertThat(pattern).isNull();
}
}

View File

@ -1,51 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.URISyntaxException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Tests for {@link RadixRouterTree}.
*
* @author guqing
* @since 2.0.0
*/
class RadixRouterTreeTest {
@Test
void pathToFind() throws URISyntaxException {
MockServerRequest request =
MockServerRequest.builder().uri(new URI("/archives"))
.method(HttpMethod.GET).build();
String path = RadixRouterTree.pathToFind(request);
assertThat(path).isEqualTo("/archives");
request = MockServerRequest.builder().uri(new URI("/archives/"))
.method(HttpMethod.GET).build();
assertThat(RadixRouterTree.pathToFind(request)).isEqualTo("/archives");
request = MockServerRequest.builder().uri(new URI("/archives/page/1"))
.method(HttpMethod.GET).build();
assertThat(RadixRouterTree.pathToFind(request)).isEqualTo("/archives");
request = MockServerRequest.builder().uri(new URI("/"))
.method(HttpMethod.GET).build();
assertThat(RadixRouterTree.pathToFind(request)).isEqualTo("/");
request = MockServerRequest.builder().uri(new URI("/"))
.queryParam("p", "fake-post")
.method(HttpMethod.GET).build();
assertThat(RadixRouterTree.pathToFind(request)).isEqualTo("/?p=fake-post");
}
@Test
void shouldInsertKeyWithPercentSign() {
var tree = new RadixRouterTree();
tree.insert("/1%1", request -> ServerResponse.ok().build());
}
}

View File

@ -1,267 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link RadixTree}.
*
* @author guqing
* @since 2.0.0
*/
class RadixTreeTest {
@Test
void insert() {
RadixTree<String> radixTree = new RadixTree<>();
radixTree.insert("/users", "users");
radixTree.insert("/users/a", "users-a");
radixTree.insert("/users/a/b/c", "users-a-b-c");
radixTree.insert("/users/a/b/c/d", "users-a-b-c-d");
radixTree.insert("/users/a/b/e/f", "users-a-b-c-d");
radixTree.insert("/users/b/d", "users-b-d");
radixTree.insert("/users/b/d/e/f", "users-b-d-e-f");
radixTree.insert("/users/b/d/g/h", "users-b-d-g-h");
radixTree.insert("/users/b/f/g/h", "users-b-f-g-h");
radixTree.insert("/users/c/d/g/h", "users-c-d-g-h");
radixTree.insert("/users/c/f/g/h", "users-c-f-g-h");
radixTree.insert("/test/hello", "test-hello");
radixTree.insert("/test/中文/abc", "test-中文-abc");
radixTree.insert("/test/中/test", "test-中-test");
radixTree.checkIndices();
String display = radixTree.display();
assertThat(display).isEqualTo("""
/ [indices=ut]
users [value=users]*
/ [indices=abc]
a [value=users-a]*
/b/ [indices=ce]
c [value=users-a-b-c]*
/d [value=users-a-b-c-d]*
e/f [value=users-a-b-c-d]*
b/ [indices=df]
d [value=users-b-d]*
/ [indices=eg]
e/f [value=users-b-d-e-f]*
g/h [value=users-b-d-g-h]*
f/g/h [value=users-b-f-g-h]*
c/ [indices=df]
d/g/h [value=users-c-d-g-h]*
f/g/h [value=users-c-f-g-h]*
test/ [indices=h]
hello [value=test-hello]*
[indices=/]
/abc [value=test--abc]*
/test [value=test--test]*
""");
}
@Test
void delete() {
RadixTree<String> radixTree = new RadixTree<>();
radixTree.insert("/", "index");
radixTree.insert("/categories/default", "categories-default");
radixTree.insert("/tags/halo", "tags-halo");
radixTree.insert("/archives/hello-halo", "archives-hello-halo");
radixTree.insert("/about", "about");
radixTree.delete("/tags/halo");
radixTree.delete("/archives/hello-halo");
radixTree.insert("/tags/halo", "tags-halo");
radixTree.delete("/");
String display = radixTree.display();
assertThat(display).isEqualTo("""
/ [indices=cat]
categories/default [value=categories-default]*
about [value=about]*
tags/halo [value=tags-halo]*
""");
radixTree.checkIndices();
}
@Test
void getSize() {
RadixTree<String> radixTree = new RadixTree<>();
radixTree.insert("/", "index");
radixTree.insert("/categories/default", "categories-default");
radixTree.insert("/tags/halo", "tags-halo");
radixTree.insert("/archives/hello-halo", "archives-hello-halo");
assertThat(radixTree.getSize()).isEqualTo(4);
radixTree.insert("/about", "about");
radixTree.delete("/tags/halo");
assertThat(radixTree.getSize()).isEqualTo(4);
radixTree.delete("/archives/hello-halo");
radixTree.insert("/tags/halo", "tags-halo");
radixTree.delete("/");
assertThat(radixTree.getSize()).isEqualTo(3);
}
@Test
void contains() {
RadixTree<String> radixTree = new RadixTree<>();
radixTree.insert("/", "index");
radixTree.insert("/categories/default", "categories-default");
radixTree.insert("/tags/halo", "tags-halo");
radixTree.insert("/archives/hello-halo", "archives-hello-halo");
assertThat(radixTree.contains("/tags/halo")).isTrue();
assertThat(radixTree.contains("/archives/hello-halo")).isTrue();
assertThat(radixTree.contains("/categories/default")).isTrue();
assertThat(radixTree.contains("/tags/test")).isFalse();
assertThat(radixTree.contains("/tags/abc")).isFalse();
assertThat(radixTree.contains("/archives/abc")).isFalse();
assertThat(radixTree.contains("/archives")).isFalse();
}
@Test
void replace() {
RadixTree<String> radixTree = new RadixTree<>();
radixTree.insert("/", "index");
radixTree.insert("/categories/default", "categories-default");
boolean replaced = radixTree.replace("/categories/default", "categories-new");
assertThat(replaced).isTrue();
assertThat(radixTree.find("/categories/default")).isEqualTo("categories-new");
}
@Test
void find() {
RadixTree<String> radixTree = new RadixTree<>();
for (String testCase : testCases()) {
radixTree.insert(testCase, testCase);
}
for (String testCase : testCases()) {
String s = radixTree.find(testCase);
assertThat(s).isEqualTo(testCase);
}
}
@Test
void visitTimes() {
AtomicInteger visitCount = new AtomicInteger(0);
RadixTree<String> radixTree = new RadixTree<>() {
@Override
protected <R> void visit(String prefix, Visitor<String, R> visitor,
RadixTreeNode<String> parent, RadixTreeNode<String> node) {
visitCount.getAndIncrement();
super.visit(prefix, visitor, parent, node);
}
};
for (String testCase : testCases()) {
radixTree.insert(testCase, testCase);
}
/*
* / [indices=hbAscxy01adnΠuvw] 1
* s [indices=er] 2
earch/query [value=/search/query]* 3
rc/*filepath [value=/src/*filepath]* 3
* u [indices=/s] 2
* sers/a/b/c [indices=/c] 3
* /d [value=/users/a/b/c/d]* 4
* c/d [value=/users/a/b/cc/d]* 4
* //...
*/
String key = "/users/a/b/c/d";
AtomicInteger resultVisitorCount = new AtomicInteger(0);
RadixTree.Visitor<String, String> visitor = new RadixTree.Visitor<>() {
public void visit(String key, RadixTreeNode<String> parent,
RadixTreeNode<String> node) {
resultVisitorCount.getAndIncrement();
if (node.isReal()) {
result = node.getValue();
}
}
};
RadixTreeNode<String> root = radixTree.getRoot();
radixTree.visit(key, visitor, null, root);
assertThat(resultVisitorCount.get()).isEqualTo(1);
assertThat(visitor.result).isEqualTo(key);
assertThat(visitCount.get()).isEqualTo(4);
// clear counter
visitCount.set(0);
resultVisitorCount.set(0);
visitor.result = null;
key = "/search/query";
radixTree.visit(key, visitor, null, root);
assertThat(resultVisitorCount.get()).isEqualTo(1);
assertThat(visitCount.get()).isEqualTo(3);
assertThat(visitor.getResult()).isEqualTo(key);
// clear counter
visitCount.set(0);
resultVisitorCount.set(0);
visitor.result = null;
// not exists key
key = "/search";
radixTree.visit(key, visitor, null, root);
assertThat(resultVisitorCount.get()).isEqualTo(0);
assertThat(visitCount.get()).isEqualTo(3);
assertThat(visitor.getResult()).isEqualTo(null);
// clear counter
visitCount.set(0);
resultVisitorCount.set(0);
visitor.result = null;
// not exists key
key = "/s";
radixTree.visit(key, visitor, null, root);
assertThat(resultVisitorCount.get()).isEqualTo(1);
assertThat(visitCount.get()).isEqualTo(2);
assertThat(visitor.getResult()).isEqualTo(null);
}
private List<String> testCases() {
return Arrays.asList(
"/hi",
"/b/",
"/ABC/",
"/search/query",
"/cmd/tool/",
"/src/*filepath",
"/x",
"/x/y",
"/y/",
"/y/z",
"/0/id",
"/0/id/1",
"/1/id/",
"/1/id/2",
"/aa",
"/a/",
"/doc",
"/doc/go_faq.html",
"/doc/go1.html",
"/doc/go/away",
"/no/a",
"/no/b",
"/Π",
"/u/apfêl/",
"/u/äpfêl/",
"/u/öpfêl",
"/v/Äpfêl/",
"/v/Öpfêl",
"/w/♬",
"/w/♭/",
"/w/𠜎",
"/w/𠜏/",
"/users/a/b/c/d",
"/users/a/b/cc/d"
);
}
}

View File

@ -22,7 +22,6 @@ import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.theme.HaloViewResolver;
import run.halo.app.theme.router.strategy.EmptyView;
/**
* Tests for {@link ViewNameResolver}.

View File

@ -0,0 +1,66 @@
package run.halo.app.theme.router.factories;
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.HttpStatus;
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 run.halo.app.theme.finders.PostFinder;
/**
* Tests for {@link ArchiveRouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class ArchiveRouteFactoryTest extends RouteFactoryTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private ArchiveRouteFactory archiveRouteFactory;
@Test
void create() {
String prefix = "/new-archives";
RouterFunction<ServerResponse> routerFunction = archiveRouteFactory.create(prefix);
WebTestClient client = getWebTestClient(routerFunction);
client.get()
.uri(prefix)
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/page/1")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/09")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/08/page/1")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/8/page/1")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -0,0 +1,43 @@
package run.halo.app.theme.router.factories;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
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.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.User;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Tests for {@link AuthorPostsRouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class AuthorPostsRouteFactoryTest extends RouteFactoryTestSuite {
@Mock
ReactiveExtensionClient client;
@InjectMocks
AuthorPostsRouteFactory authorPostsRouteFactory;
@Test
void create() {
RouterFunction<ServerResponse> routerFunction = authorPostsRouteFactory.create(null);
WebTestClient webClient = getWebTestClient(routerFunction);
when(client.get(eq(User.class), eq("fake-user")))
.thenReturn(Mono.just(new User()));
webClient.get()
.uri("/authors/fake-user")
.exchange()
.expectStatus().isOk();
}
}

View File

@ -0,0 +1,41 @@
package run.halo.app.theme.router.factories;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
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.Flux;
import run.halo.app.theme.finders.CategoryFinder;
/**
* Tests for {@link CategoriesRouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
class CategoriesRouteFactoryTest extends RouteFactoryTestSuite {
@Mock
private CategoryFinder categoryFinder;
@InjectMocks
private CategoriesRouteFactory categoriesRouteFactory;
@Test
void create() {
String prefix = "/topics";
RouterFunction<ServerResponse> routerFunction = categoriesRouteFactory.create(prefix);
WebTestClient webClient = getWebTestClient(routerFunction);
when(categoryFinder.listAsTree())
.thenReturn(Flux.empty());
webClient.get()
.uri(prefix)
.exchange()
.expectStatus().isOk();
}
}

View File

@ -0,0 +1,42 @@
package run.halo.app.theme.router.factories;
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.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.finders.PostFinder;
/**
* Tests for {@link IndexRouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class IndexRouteFactoryTest extends RouteFactoryTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private IndexRouteFactory indexRouteFactory;
@Test
void create() {
RouterFunction<ServerResponse> routerFunction = indexRouteFactory.create("/");
WebTestClient webTestClient = getWebTestClient(routerFunction);
webTestClient.get()
.uri("/")
.exchange()
.expectStatus().isOk();
webTestClient.get()
.uri("/page/1")
.exchange()
.expectStatus().isOk();
}
}

View File

@ -1,17 +1,14 @@
package run.halo.app.theme.router.strategy;
package run.halo.app.theme.router.factories;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import java.net.URI;
import java.net.URISyntaxException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
@ -20,38 +17,27 @@ import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.theme.router.PermalinkHttpGetRouter;
import run.halo.app.theme.router.EmptyView;
/**
* Abstract test for {@link DetailsPageRouteHandlerStrategy} and
* {@link ListPageRouteHandlerStrategy}.
* Abstract test for {@link RouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
abstract class RouterStrategyTestSuite {
abstract class RouteFactoryTestSuite {
@Mock
protected SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
protected ApplicationContext applicationContext;
@Mock
protected HaloProperties haloProperties;
@Mock
protected ViewResolver viewResolver;
@InjectMocks
protected PermalinkHttpGetRouter permalinkHttpGetRouter;
@BeforeEach
final void setUpParent() throws URISyntaxException {
lenient().when(environmentFetcher.fetchPost())
.thenReturn(Mono.just(new SystemSetting.Post()));
lenient().when(environmentFetcher.fetch(eq(SystemSetting.ThemeRouteRules.GROUP),
eq(SystemSetting.ThemeRouteRules.class))).thenReturn(Mono.just(getThemeRouteRules()));
lenient().when(haloProperties.getExternalUrl()).thenReturn(new URI("http://example.com"));
lenient().when(viewResolver.resolveViewName(any(), any()))
.thenReturn(Mono.just(new EmptyView()));
setUp();
@ -77,8 +63,4 @@ abstract class RouterStrategyTestSuite {
.build())
.build();
}
public RouterFunction<ServerResponse> getRouterFunction() {
return request -> Mono.justOrEmpty(permalinkHttpGetRouter.route(request));
}
}

View File

@ -0,0 +1,68 @@
package run.halo.app.theme.router.factories;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
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.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.TagVo;
/**
* Tests for @link TagPostRouteFactory}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TagPostRouteFactoryTest extends RouteFactoryTestSuite {
@Mock
private ReactiveExtensionClient client;
@Mock
private TagFinder tagFinder;
@Mock
private PostFinder postFinder;
@InjectMocks
TagPostRouteFactory tagPostRouteFactory;
@Test
void create() {
when(client.list(eq(Tag.class), any(), eq(null))).thenReturn(Flux.empty());
WebTestClient webTestClient = getWebTestClient(tagPostRouteFactory.create("/new-tags"));
webTestClient.get()
.uri("/new-tags/tag-slug-1")
.exchange()
.expectStatus().isNotFound();
Tag tag = new Tag();
tag.setMetadata(new Metadata());
tag.getMetadata().setName("fake-tag-name");
tag.setSpec(new Tag.TagSpec());
tag.getSpec().setSlug("tag-slug-2");
when(client.list(eq(Tag.class), any(), eq(null))).thenReturn(Flux.just(tag));
when(tagFinder.getByName(eq(tag.getMetadata().getName())))
.thenReturn(Mono.just(TagVo.from(tag)));
webTestClient.get()
.uri("/new-tags/tag-slug-2")
.exchange()
.expectStatus().isOk();
webTestClient.get()
.uri("/new-tags/tag-slug-2/page/1")
.exchange()
.expectStatus().isOk();
}
}

View File

@ -1,99 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.List;
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.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.finders.PostFinder;
/**
* Tests for {@link ArchivesRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class ArchivesRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private ArchivesRouteStrategy archivesRouteStrategy;
@Test
void getRouteFunctionWhenDefaultPattern() {
HandlerFunction<ServerResponse> handler = archivesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
List<String> routerPaths = archivesRouteStrategy.getRouterPaths("/archives");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
fixedAssertion(client, "/archives");
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
private static void fixedAssertion(WebTestClient client, String prefix) {
client.get()
.uri(prefix)
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/page/1")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/09")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/08/page/1")
.exchange()
.expectStatus().isOk();
client.get()
.uri(prefix + "/2022/8/page/1")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getRouteFunctionWhenOtherPattern() {
HandlerFunction<ServerResponse> handler = archivesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
final WebTestClient client = getWebTestClient(routeFunction);
List<String> routerPaths = archivesRouteStrategy.getRouterPaths("/archives-test");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
fixedAssertion(client, "/archives-test");
client.get()
.uri("/archives")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,68 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* Tests for {@link AuthorRouteStrategy}.
*
* @author guqing
* @since 2.0.1
*/
class AuthorRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private AuthorRouteStrategy strategy;
@Test
void handlerTest() {
User user = new User();
Metadata metadata = new Metadata();
metadata.setName("fake-user");
user.setMetadata(metadata);
user.setSpec(new User.UserSpec());
when(client.fetch(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user));
permalinkHttpGetRouter.insert("/authors/fake-user",
strategy.getHandler(getThemeRouteRules(), "fake-user"));
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.AUTHOR.getValue()), any()))
.thenReturn(Mono.just(new EmptyView() {
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
assertThat(model.get("name")).isEqualTo("fake-user");
assertThat(model.get("_templateId"))
.isEqualTo(DefaultTemplateEnum.AUTHOR.getValue());
assertThat(model.get("author")).isNotNull();
assertThat(model.get("posts")).isNotNull();
return Mono.empty();
}
}));
WebTestClient webTestClient = getWebTestClient(getRouterFunction());
webTestClient.get()
.uri("/authors/fake-user")
.exchange()
.expectStatus()
.isOk();
}
}

View File

@ -1,58 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.Mockito.when;
import java.util.List;
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.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import run.halo.app.theme.finders.CategoryFinder;
/**
* Tests for {@link CategoriesRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class CategoriesRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private CategoryFinder categoryFinder;
@InjectMocks
private CategoriesRouteStrategy categoriesRouteStrategy;
@Test
void getRouteFunction() {
HandlerFunction<ServerResponse> handler = categoriesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
List<String> routerPaths = categoriesRouteStrategy.getRouterPaths("/categories-test");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
when(categoryFinder.listAsTree()).thenReturn(Flux.empty());
client.get()
.uri("/categories-test")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,82 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.HttpStatus;
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.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.PostFinder;
/**
* Tests for {@link CategoryRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class CategoryRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@Mock
private CategoryFinder categoryFinder;
@InjectMocks
private CategoryRouteStrategy categoryRouteStrategy;
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
final WebTestClient client = getWebTestClient(routeFunction);
permalinkHttpGetRouter.insert("/categories-test/category-slug-1",
categoryRouteStrategy.getHandler(null, "category-slug-1"));
permalinkHttpGetRouter.insert("/categories-test/category-slug-2",
categoryRouteStrategy.getHandler(null, "category-slug-2"));
when(categoryFinder.getByName(any())).thenReturn(Mono.empty());
// /{prefix}/{slug}
client.get()
.uri("/categories-test/category-slug-1")
.exchange()
.expectStatus()
.isOk();
// /{prefix}/{slug}
client.get()
.uri("/categories-test/category-slug-2")
.exchange()
.expectStatus()
.isOk();
// /{prefix}/{slug}/page/{page}
client.get()
.uri("/categories-test/category-slug-2/page/1")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/categories-test/not-exist-slug")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,60 +0,0 @@
package run.halo.app.theme.router.strategy;
import java.util.List;
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.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.finders.PostFinder;
/**
* Tests for {@link IndexRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class IndexRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private IndexRouteStrategy indexRouteStrategy;
@Test
void getRouteFunction() {
HandlerFunction<ServerResponse> handler = indexRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
List<String> routerPaths = indexRouteStrategy.getRouterPaths("/");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
final WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri("/")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/page/1")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,132 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
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.HttpStatus;
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.content.TestPost;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.router.ViewNameResolver;
/**
* Tests for {@link PostRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class PostRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@Mock
private ViewNameResolver viewNameResolver;
@InjectMocks
private PostRouteStrategy postRouteStrategy;
@Override
public void setUp() {
lenient().when(viewNameResolver.resolveViewNameOrDefault(any(), any(), any()))
.thenReturn(Mono.just(DefaultTemplateEnum.POST.getValue()));
lenient().when(postFinder.getByName(any()))
.thenReturn(Mono.just(PostVo.from(TestPost.postV1())));
}
@Test
void getRouteFunctionWhenSlugPathVariable() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
SystemSetting.ThemeRouteRules themeRouteRules = getThemeRouteRules();
themeRouteRules.setPost("/posts-test/{slug}");
permalinkHttpGetRouter.insert("/posts-test/fake-slug",
postRouteStrategy.getHandler(themeRouteRules, "fake-slug"));
WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri("/posts-test/fake-slug")
.exchange()
.expectStatus()
.isOk();
}
@Test
void getRouteFunctionWhenNamePathVariable() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
SystemSetting.ThemeRouteRules themeRouteRules = getThemeRouteRules();
themeRouteRules.setPost("/posts-test/{slug}");
permalinkHttpGetRouter.insert("/posts-test/fake-name",
postRouteStrategy.getHandler(themeRouteRules, "fake-name"));
WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri("/posts-test/fake-name")
.exchange()
.expectStatus()
.isOk();
}
@Test
void getRouteFunctionWhenYearMonthSlugPathVariable() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
SystemSetting.ThemeRouteRules themeRouteRules = getThemeRouteRules();
themeRouteRules.setPost("/{year}/{month}/{slug}");
permalinkHttpGetRouter.insert("/{year}/{month}/{slug}",
postRouteStrategy.getHandler(themeRouteRules, "fake-name"));
WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri("/2022/08/fake-slug")
.exchange()
.expectStatus()
.isOk();
}
@Test
void getRouteFunctionWhenQueryParam() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
SystemSetting.ThemeRouteRules themeRouteRules = getThemeRouteRules();
themeRouteRules.setPost("/?p={slug}");
permalinkHttpGetRouter.insert("/?p=fake-name",
postRouteStrategy.getHandler(themeRouteRules, "fake-name"));
WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri(uriBuilder -> uriBuilder.path("/")
.queryParam("p", "fake-name")
.build()
)
.exchange()
.expectStatus()
.isOk();
client.get()
.uri(uriBuilder -> uriBuilder.path("/")
.queryParam("p", "nothing")
.build()
)
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,139 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import static run.halo.app.theme.DefaultTemplateEnum.SINGLE_PAGE;
import java.util.Map;
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 org.springframework.web.reactive.function.server.RouterFunction;
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.extension.GroupVersionKind;
import run.halo.app.extension.Metadata;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.SinglePageVo;
import run.halo.app.theme.router.ViewNameResolver;
/**
* Tests for {@link SinglePageRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private SinglePageFinder singlePageFinder;
@Mock
private ViewNameResolver viewNameResolver;
@InjectMocks
private SinglePageRouteStrategy strategy;
@Override
public void setUp() {
lenient().when(viewResolver.resolveViewName(eq(SINGLE_PAGE.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
}
@Test
void shouldResponse404IfNoPermalinkFound() {
createClient().get()
.uri("/nothing")
.exchange()
.expectStatus().isNotFound();
}
@Test
void shouldResponse200IfPermalinkFound() {
permalinkHttpGetRouter.insert("/fake-slug",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
when(singlePageFinder.getByName(any())).thenReturn(Mono.empty());
createClient().get()
.uri("/fake-slug")
.exchange()
.expectStatus()
.isOk();
}
@Test
void shouldResponse200IfSlugNameContainsSpecialChars() {
permalinkHttpGetRouter.insert("/fake / slug",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
when(singlePageFinder.getByName(any())).thenReturn(Mono.empty());
createClient().get()
.uri("/fake / slug")
.exchange()
.expectStatus().isOk();
}
@Test
void shouldResponse200IfSlugNameContainsChineseChars() {
permalinkHttpGetRouter.insert("/中文",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
when(singlePageFinder.getByName(any())).thenReturn(Mono.empty());
createClient().get()
.uri("/中文")
.exchange()
.expectStatus().isOk();
}
@Test
void ensureModel() {
// fix gh-2912
Metadata metadata = new Metadata();
metadata.setName("fake-name");
SinglePageVo singlePageVo = SinglePageVo.builder()
.metadata(metadata)
.spec(new SinglePage.SinglePageSpec())
.build();
when(singlePageFinder.getByName(eq("fake-name"))).thenReturn(Mono.just(singlePageVo));
permalinkHttpGetRouter.insert("/fake-slug",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
when(viewNameResolver.resolveViewNameOrDefault(any(), any(), any()))
.thenReturn(Mono.just("page"));
when(viewResolver.resolveViewName(eq(SINGLE_PAGE.getValue()), any()))
.thenReturn(Mono.just(new EmptyView() {
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
assertThat(model.get("name")).isEqualTo("fake-name");
assertThat(model.get("plural")).isEqualTo("singlepages");
assertThat(model.get("_templateId")).isEqualTo("page");
assertThat(model.get("singlePage")).isEqualTo(singlePageVo);
assertThat(model.get("groupVersionKind")).isEqualTo(
GroupVersionKind.fromExtension(SinglePage.class));
return Mono.empty();
}
}));
createClient().get()
.uri("/fake-slug")
.exchange()
.expectStatus().isOk()
.expectBody();
}
WebTestClient createClient() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
return getWebTestClient(routeFunction);
}
}

View File

@ -1,63 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.HttpStatus;
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.theme.finders.PostFinder;
import run.halo.app.theme.finders.TagFinder;
/**
* Tests for {@link TagRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TagRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@Mock
private TagFinder tagFinder;
@InjectMocks
private TagRouteStrategy tagRouteStrategy;
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
permalinkHttpGetRouter.insert("/tags-test/fake-slug",
tagRouteStrategy.getHandler(getThemeRouteRules(), "fake-name"));
when(tagFinder.getByName(any())).thenReturn(Mono.empty());
client.get()
.uri("/tags-test/fake-slug")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/tags-test/fake-slug/page/1")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@ -1,58 +0,0 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.Mockito.when;
import java.util.List;
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.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import run.halo.app.theme.finders.TagFinder;
/**
* Tests for {@link TagsRouteStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TagsRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private TagFinder tagFinder;
@InjectMocks
private TagsRouteStrategy tagsRouteStrategy;
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
List<String> routerPaths = tagsRouteStrategy.getRouterPaths("/tags-test");
HandlerFunction<ServerResponse> handler = tagsRouteStrategy.getHandler();
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
when(tagFinder.listAll()).thenReturn(Flux.empty());
client.get()
.uri("/tags-test")
.exchange()
.expectStatus()
.isOk();
client.get()
.uri("/nothing")
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
}