refactor: content permalink routing using radix tree (#2547)

#### What type of PR is this?
/area core
/milestone 2.0
/kind improvement

#### What this PR does / why we need it:
由于之前的实现方式在某些场景下遇到瓶颈,例如 permalink 含有中文或者 /{year}/{month}/{slug} 这样的 pattern 容易出现路由冲突,所以提出此 PR 以改进之前的路由方式:

[Radix tree wiki](https://en.wikipedia.org/wiki/Radix_tree)

本 PR 通过变种的 RadixTree 来作为存储 permalink 的数据结构有以下原因:
1. 内容模块的 permalink 都具有大部分相同的前缀,例如分类是 以 /categories 前缀开头,文章的 /{year}/{month}/ 前缀等,使用 RadixTree 前缀树来存储将更节约内存
2. 具有分页规则例如根据分类查询文章列表的路由,/categories/fake-slug/page/{page},需要 Router 在匹配时能支持动态参数
3. 最坏时间复杂度为 O(n)

当插入以下几个路由
```
/categories/default
/categories/hello
/archives/test
/about
/tags/halo
```
存储结构将如下(带 * 的表示它为实际 path 节点)
```
/ [indices=act, priority=6]
├── a [indices=rb, priority=3]
│   ├── rchives/test [value=archives-test, priority=1]*
│   └── bout [value=about, priority=1]*
├── categories/ [indices=dh, priority=2]
│   ├── default [value=categories-default, priority=1]*
│   └── hello [value=categories-hello, priority=1]*
└── tags/halo [value=tags-halo, priority=1]*
```
通过在 Node 添加 indices 字段来对应 children 的首字符,当查询时便可直接判断 key 的首字符选择对应下标的 children 进行深度查找(这得益于每个 node 都是与插入 key part 取最大公共字串,所以每个 node 的 indices 都不存在重复的字符。)
例如当查询 /categories/default 时
1. 首先将 `/categories/default` 与 root node 的 key `/` 取最长公共字串为 `/`,`/categories/default` 剩下的子串为 `categories/default` 首字符为 `c`, 通过获取 root node 的 `indices` 在 `indexOf('c')` 得到该进入哪个子分支
2. 如果 index 为 -1 则直接返回 null 表示不存在
3. 得到 index 为 1,则 node.getChildren(1), 得到 key 为 `categories` 的 node
4. 重复步骤1,取公共最长字串的剩余部分得到 `default`,获取该 node 的 indices 检查`default` 的首字符 是否存在:`indexOf('d')`,得到 index=0,取 node 的 children.get(0) 得到 key 为`default` 的 node,取最长公共字串没有剩余部分且当前node的 isReal(是否是一个 path) 为 true 查询结束得到结果

使用 indices 的改进 radix tree 的思路来自于 [julienschmidt/httprouter](https://github.com/julienschmidt/httprouter/blob/master/tree.go)

- [ ] 带参数的 node 节点和带统配符的 node 节点有待优化查询效率,尽量做到一次查询得到结果
#### Which issue(s) this PR fixes:

Fixes #2539 #2473

#### Special notes for your reviewer:
how to test it?
1. 创建文章、分类、标签、自定义页面等多条数据
2. 通过反复删除和创建来测试这些资源的 permalink 是否还能被访问到
3.  该 PR 已经支持 permalink 中带有中文和特殊字符

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

```release-note
使用 RadixTree 的变种数据结构改进 permalink Router
```
pull/2598/head
guqing 2022-10-20 16:12:15 +08:00 committed by GitHub
parent ec7dc8445f
commit 6b2aea9301
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1804 additions and 1075 deletions

View File

@ -58,7 +58,6 @@ import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.plugin.ExtensionComponentsFinder;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.resources.ReverseProxyRouterFunctionRegistry;
import run.halo.app.theme.router.TemplateRouteManager;
@Configuration(proxyBeanMethods = false)
public class ExtensionConfiguration {
@ -196,10 +195,10 @@ public class ExtensionConfiguration {
@Bean
Controller singlePageController(ExtensionClient client, ContentService contentService,
ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) {
ApplicationContext applicationContext) {
return new ControllerBuilder("single-page-controller", client)
.reconciler(new SinglePageReconciler(client, contentService,
applicationContext, templateRouteManager)
applicationContext)
)
.extension(new SinglePage())
.build();

View File

@ -26,10 +26,8 @@ import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.TemplateRouteManager;
/**
* <p>Reconciler for {@link SinglePage}.</p>
@ -49,14 +47,12 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
private final ContentService contentService;
private final ApplicationContext applicationContext;
private final TemplateRouteManager templateRouteManager;
public SinglePageReconciler(ExtensionClient client, ContentService contentService,
ApplicationContext applicationContext, TemplateRouteManager templateRouteManager) {
ApplicationContext applicationContext) {
this.client = client;
this.contentService = contentService;
this.applicationContext = applicationContext;
this.templateRouteManager = templateRouteManager;
}
@Override
@ -137,7 +133,6 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
ExtensionLocator locator = new ExtensionLocator(GVK, singlePage.getMetadata().getName(),
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexDeleteCommand(this, locator));
templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue());
}
private void permalinkOnAdd(SinglePage singlePage) {
@ -145,7 +140,6 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
singlePage.getSpec().getSlug());
applicationContext.publishEvent(new PermalinkIndexAddCommand(this, locator,
singlePage.getStatusOrDefault().getPermalink()));
templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.SINGLE_PAGE.getValue());
}
private void reconcileStatus(String name) {

View File

@ -144,7 +144,7 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
newRules.getArchives());
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.ARCHIVES,
newRules.getArchives()));
oldArchivesPrefix, newRules.getArchives()));
}
if (postPatternChanged) {
@ -152,7 +152,7 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
newRules.getPost());
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST, newRules.getPost()));
DefaultTemplateEnum.POST, oldPostPattern, newRules.getPost()));
}
});
}
@ -181,7 +181,7 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
// then publish event
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.TAGS,
newRules.getTags()));
oldTagsPrefix, newRules.getTags()));
}
});
}
@ -200,7 +200,7 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
// categories rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.CATEGORIES,
newRules.getCategories()));
oldCategoriesPrefix, newRules.getCategories()));
}
});
}
@ -215,12 +215,12 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
oldRules.setPost(newRules.getPost());
updateNewRuleToConfigMap(configMap, oldRules, newRules);
log.debug("Categories prefix changed from [{}] to [{}].", oldPostPattern,
log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern,
newRules.getPost());
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST,
newRules.getPost()));
oldPostPattern, newRules.getPost()));
}
});
}

View File

@ -68,8 +68,12 @@ public class SystemConfigurableEnvironmentFetcher {
* @return a new {@link ConfigMap} named <code>system</code> by json merge patch.
*/
public Mono<ConfigMap> getConfigMap() {
return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT)
.flatMap(systemDefault ->
Mono<ConfigMap> mapMono =
extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG_DEFAULT);
if (mapMono == null) {
return Mono.empty();
}
return mapMono.flatMap(systemDefault ->
extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.map(system -> {
Map<String, String> defaultData = systemDefault.getData();

View File

@ -0,0 +1,12 @@
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

@ -0,0 +1,108 @@
package run.halo.app.theme.router;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import run.halo.app.extension.ListResult;
import run.halo.app.infra.utils.PathUtils;
/**
* A utility class for template page url.
*
* @author guqing
* @since 2.0.0
*/
public class PageUrlUtils {
public static final String PAGE_PART = "page";
public static int pageNum(ServerRequest request) {
if (isPageUrl(request.path())) {
String pageNum = StringUtils.substringAfterLast(request.path(), "/page/");
return NumberUtils.toInt(pageNum, 1);
}
return 1;
}
public static boolean isPageUrl(String path) {
String[] split = StringUtils.split(path, "/");
if (split.length > 1) {
return PAGE_PART.equals(split[split.length - 2])
&& NumberUtils.isDigits(split[split.length - 1]);
}
return false;
}
public static long totalPage(ListResult<?> list) {
return (list.getTotal() - 1) / list.getSize() + 1;
}
/**
* Gets next page url with path.
*
* @param path request path
* @return request path with next page part
*/
public static String nextPageUrl(String path, long total) {
String[] segments = StringUtils.split(path, "/");
long defaultPage = Math.min(2, Math.max(total, 1));
if (segments.length > 1) {
String pagePart = segments[segments.length - 2];
if (PAGE_PART.equals(pagePart)) {
int pageNumIndex = segments.length - 1;
String pageNum = segments[pageNumIndex];
segments[pageNumIndex] = toNextPage(pageNum, total);
return PathUtils.combinePath(segments);
}
return appendPagePart(PathUtils.combinePath(segments), defaultPage);
}
return appendPagePart(PathUtils.combinePath(segments), defaultPage);
}
/**
* Gets previous page url with path.
*
* @param path request path
* @return request path with previous page part
*/
public static String prevPageUrl(String path) {
String[] segments = StringUtils.split(path, "/");
if (segments.length > 1) {
String pagePart = segments[segments.length - 2];
if (PAGE_PART.equals(pagePart)) {
int pageNumIndex = segments.length - 1;
String pageNum = segments[pageNumIndex];
int prevPage = toPrevPage(pageNum);
segments[pageNumIndex] = String.valueOf(prevPage);
if (prevPage == 1) {
segments = ArrayUtils.subarray(segments, 0, pageNumIndex - 1);
}
if (segments.length == 0) {
return "/";
}
return PathUtils.combinePath(segments);
}
}
return StringUtils.defaultString(path, "/");
}
private static String appendPagePart(String path, long page) {
return PathUtils.combinePath(path, PAGE_PART, String.valueOf(page));
}
private static String toNextPage(String pageStr, long total) {
long page = Math.min(parseInt(pageStr) + 1, Math.max(total, 1));
return String.valueOf(page);
}
private static int toPrevPage(String pageStr) {
return Math.max(parseInt(pageStr) - 1, 1);
}
private static int parseInt(String pageStr) {
if (!NumberUtils.isParsable(pageStr)) {
throw new IllegalArgumentException("Page number must be a number");
}
return NumberUtils.toInt(pageStr, 1);
}
}

View File

@ -0,0 +1,228 @@
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.util.MultiValueMap;
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) {
MultiValueMap<String, String> queryParams = request.queryParams();
String requestPath = request.path();
// 文章的 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);
}
}
return routeTree.match(requestPath);
}
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

@ -0,0 +1,28 @@
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

@ -5,6 +5,7 @@ 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;
@ -26,7 +27,10 @@ public class PermalinkIndexer {
private final Map<GvkName, String> gvkNamePermalinkLookup = new HashMap<>();
private final Map<String, ExtensionLocator> permalinkLocatorLookup = new HashMap<>();
record GvkName(GroupVersionKind gvk, String name) {
private final ApplicationContext applicationContext;
public PermalinkIndexer(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
@ -41,11 +45,17 @@ public class PermalinkIndexer {
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.
*
@ -54,10 +64,11 @@ public class PermalinkIndexer {
public void remove(ExtensionLocator locator) {
readWriteLock.writeLock().lock();
try {
String permalink =
gvkNamePermalinkLookup.remove(new GvkName(locator.gvk(), locator.name()));
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();
@ -76,7 +87,7 @@ public class PermalinkIndexer {
try {
return gvkNamePermalinkLookup.entrySet()
.stream()
.filter(entry -> entry.getKey().gvk.equals(gvk))
.filter(entry -> entry.getKey().gvk().equals(gvk))
.map(Map.Entry::getValue)
.toList();
} finally {

View File

@ -24,18 +24,15 @@ import run.halo.app.theme.DefaultTemplateEnum;
@Component
public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRuleChangedEvent> {
private final ExtensionClient client;
private final TemplateRouteManager templateRouterManager;
private final PostPermalinkPolicy postPermalinkPolicy;
private final TagPermalinkPolicy tagPermalinkPolicy;
private final CategoryPermalinkPolicy categoryPermalinkPolicy;
public PermalinkRefreshHandler(ExtensionClient client,
TemplateRouteManager templateRouterManager,
PostPermalinkPolicy postPermalinkPolicy,
TagPermalinkPolicy tagPermalinkPolicy,
CategoryPermalinkPolicy categoryPermalinkPolicy) {
this.client = client;
this.templateRouterManager = templateRouterManager;
this.postPermalinkPolicy = postPermalinkPolicy;
this.tagPermalinkPolicy = tagPermalinkPolicy;
this.categoryPermalinkPolicy = categoryPermalinkPolicy;
@ -47,7 +44,6 @@ public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRul
log.debug("Refresh permalink for template [{}]", template.getValue());
switch (template) {
case POST -> updatePostPermalink();
case ARCHIVES -> templateRouterManager.changeTemplatePattern(template.getValue());
case CATEGORIES, CATEGORY -> updateCategoryPermalink();
case TAGS, TAG -> updateTagPermalink();
default -> {
@ -70,14 +66,12 @@ public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRul
client.update(post);
postPermalinkPolicy.onPermalinkUpdate(post);
templateRouterManager.changeTemplatePattern(postPermalinkPolicy.templateName());
});
}
private void updateCategoryPermalink() {
String pattern = categoryPermalinkPolicy.pattern();
log.debug("Update category and categories permalink by new policy [{}]", pattern);
templateRouterManager.changeTemplatePattern(DefaultTemplateEnum.CATEGORIES.getValue());
client.list(Category.class, null, null)
.forEach(category -> {
String oldPermalink = category.getStatusOrDefault().getPermalink();
@ -90,15 +84,12 @@ public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRul
client.update(category);
categoryPermalinkPolicy.onPermalinkUpdate(category);
templateRouterManager.changeTemplatePattern(
categoryPermalinkPolicy.templateName());
});
}
private void updateTagPermalink() {
String pattern = tagPermalinkPolicy.pattern();
log.debug("Update tag and tags permalink by new policy [{}]", pattern);
templateRouterManager.changeTemplatePattern(DefaultTemplateEnum.TAGS.getValue());
client.list(Tag.class, null, null)
.forEach(tag -> {
String oldPermalink = tag.getStatusOrDefault().getPermalink();
@ -111,7 +102,6 @@ public class PermalinkRefreshHandler implements ApplicationListener<PermalinkRul
client.update(tag);
tagPermalinkPolicy.onPermalinkUpdate(tag);
templateRouterManager.changeTemplatePattern(tagPermalinkPolicy.templateName());
});
}
}

View File

@ -5,12 +5,14 @@ import run.halo.app.theme.DefaultTemplateEnum;
public class PermalinkRuleChangedEvent extends ApplicationEvent {
private final DefaultTemplateEnum template;
private final String oldRule;
private final String rule;
public PermalinkRuleChangedEvent(Object source, DefaultTemplateEnum template,
String rule) {
String oldRule, String rule) {
super(source);
this.template = template;
this.oldRule = oldRule;
this.rule = rule;
}
@ -18,6 +20,10 @@ public class PermalinkRuleChangedEvent extends ApplicationEvent {
return template;
}
public String getOldRule() {
return oldRule;
}
public String getRule() {
return rule;
}

View File

@ -0,0 +1,158 @@
package run.halo.app.theme.router;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.PathContainer;
import org.springframework.web.reactive.function.server.HandlerFunction;
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 {
// uri decode key to insert
String decodedKey = UriUtils.decode(key, StandardCharsets.UTF_8);
super.insert(decodedKey, 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 requestPath request path
* @return a handler function if matched, otherwise null
*/
public HandlerFunction<ServerResponse> match(String requestPath) {
String path = processRequestPath(requestPath);
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 + "}");
}
}
return find(bestMatch.getPatternString());
}
private 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

@ -0,0 +1,360 @@
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

@ -0,0 +1,85 @@
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

@ -1,130 +0,0 @@
package run.halo.app.theme.router;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.strategy.ArchivesRouteStrategy;
import run.halo.app.theme.router.strategy.CategoriesRouteStrategy;
import run.halo.app.theme.router.strategy.CategoryRouteStrategy;
import run.halo.app.theme.router.strategy.IndexRouteStrategy;
import run.halo.app.theme.router.strategy.PostRouteStrategy;
import run.halo.app.theme.router.strategy.SinglePageRouteStrategy;
import run.halo.app.theme.router.strategy.TagRouteStrategy;
import run.halo.app.theme.router.strategy.TagsRouteStrategy;
/**
* Theme template router mapping manager.
*
* @author guqing
* @see PermalinkIndexer
* @see PermalinkPatternProvider
* @see TemplateRouterStrategy
* @since 2.0.0
*/
@Component
public class TemplateRouteManager implements ApplicationListener<ApplicationReadyEvent> {
private final ReentrantReadWriteLock.WriteLock writeLock =
new ReentrantReadWriteLock().writeLock();
private final Map<String, RouterFunction<ServerResponse>> routerFunctionMap = new HashMap<>();
private final PermalinkPatternProvider permalinkPatternProvider;
private final ApplicationContext applicationContext;
public TemplateRouteManager(PermalinkPatternProvider permalinkPatternProvider,
ApplicationContext applicationContext) {
this.permalinkPatternProvider = permalinkPatternProvider;
this.applicationContext = applicationContext;
}
public Map<String, RouterFunction<ServerResponse>> getRouterFunctionMap() {
return Map.copyOf(this.routerFunctionMap);
}
/**
* Change the template router function.
*
* @param templateName template name
* @return a new {@link RouterFunction} generated by the template router strategy
*/
public RouterFunction<ServerResponse> changeTemplatePattern(String templateName) {
String pattern = getPatternByTemplateName(templateName);
RouterFunction<ServerResponse> routeFunction = templateRouterStrategy(templateName)
.getRouteFunction(templateName, pattern);
writeLock.lock();
try {
routerFunctionMap.remove(templateName);
routerFunctionMap.put(templateName, routeFunction);
} finally {
writeLock.unlock();
}
return routeFunction;
}
private String getPatternByTemplateName(String templateName) {
DefaultTemplateEnum templateEnum = DefaultTemplateEnum.convertFrom(templateName);
if (templateEnum == null) {
throw new IllegalArgumentException("Template name is not valid");
}
return permalinkPatternProvider.getPattern(templateEnum);
}
/**
* Register router mapping by template name.
*
* @param templateName template name
*/
public void register(String templateName) {
String pattern = getPatternByTemplateName(templateName);
RouterFunction<ServerResponse> routeFunction = templateRouterStrategy(templateName)
.getRouteFunction(templateName, pattern);
if (routeFunction == null) {
throw new IllegalStateException("Router function must not be null");
}
writeLock.lock();
try {
routerFunctionMap.put(templateName, routeFunction);
} finally {
writeLock.unlock();
}
}
void initRouterMapping() {
for (DefaultTemplateEnum templateEnum : DefaultTemplateEnum.values()) {
String templateName = templateEnum.getValue();
register(templateName);
}
}
@NonNull
private TemplateRouterStrategy templateRouterStrategy(String template) {
DefaultTemplateEnum value = DefaultTemplateEnum.convertFrom(template);
if (value == null) {
throw new NotFoundException("Unknown template: " + template);
}
return switch (value) {
case INDEX -> applicationContext.getBean(IndexRouteStrategy.class);
case POST -> applicationContext.getBean(PostRouteStrategy.class);
case ARCHIVES -> applicationContext.getBean(ArchivesRouteStrategy.class);
case TAGS -> applicationContext.getBean(TagsRouteStrategy.class);
case TAG -> applicationContext.getBean(TagRouteStrategy.class);
case CATEGORIES -> applicationContext.getBean(CategoriesRouteStrategy.class);
case CATEGORY -> applicationContext.getBean(CategoryRouteStrategy.class);
case SINGLE_PAGE -> applicationContext.getBean(SinglePageRouteStrategy.class);
};
}
@Override
public void onApplicationEvent(@NonNull ApplicationReadyEvent event) {
initRouterMapping();
}
}

View File

@ -1,104 +0,0 @@
package run.halo.app.theme.router;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
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 run.halo.app.extension.ListResult;
import run.halo.app.infra.utils.PathUtils;
/**
* The {@link TemplateRouterStrategy} for generate {@link RouterFunction} specific to the template.
*
* @author guqing
* @since 2.0.0
*/
@FunctionalInterface
public interface TemplateRouterStrategy {
RouterFunction<ServerResponse> getRouteFunction(String template, String pattern);
class PageUrlUtils {
public static final String PAGE_PART = "page";
public static int pageNum(ServerRequest request) {
String pageNum = request.pathVariables().get(PageUrlUtils.PAGE_PART);
return NumberUtils.toInt(pageNum, 1);
}
public static long totalPage(ListResult<?> list) {
return (list.getTotal() - 1) / list.getSize() + 1;
}
/**
* Gets next page url with path.
*
* @param path request path
* @return request path with next page part
*/
public static String nextPageUrl(String path, long total) {
String[] segments = StringUtils.split(path, "/");
long defaultPage = Math.min(2, Math.max(total, 1));
if (segments.length > 1) {
String pagePart = segments[segments.length - 2];
if (PAGE_PART.equals(pagePart)) {
int pageNumIndex = segments.length - 1;
String pageNum = segments[pageNumIndex];
segments[pageNumIndex] = toNextPage(pageNum, total);
return PathUtils.combinePath(segments);
}
return appendPagePart(PathUtils.combinePath(segments), defaultPage);
}
return appendPagePart(PathUtils.combinePath(segments), defaultPage);
}
/**
* Gets previous page url with path.
*
* @param path request path
* @return request path with previous page part
*/
public static String prevPageUrl(String path) {
String[] segments = StringUtils.split(path, "/");
if (segments.length > 1) {
String pagePart = segments[segments.length - 2];
if (PAGE_PART.equals(pagePart)) {
int pageNumIndex = segments.length - 1;
String pageNum = segments[pageNumIndex];
int prevPage = toPrevPage(pageNum);
segments[pageNumIndex] = String.valueOf(prevPage);
if (prevPage == 1) {
segments = ArrayUtils.subarray(segments, 0, pageNumIndex - 1);
}
if (segments.length == 0) {
return "/";
}
return PathUtils.combinePath(segments);
}
}
return StringUtils.defaultString(path, "/");
}
private static String appendPagePart(String path, long page) {
return PathUtils.combinePath(path, PAGE_PART, String.valueOf(page));
}
private static String toNextPage(String pageStr, long total) {
long page = Math.min(parseInt(pageStr) + 1, Math.max(total, 1));
return String.valueOf(page);
}
private static int toPrevPage(String pageStr) {
return Math.max(parseInt(pageStr) - 1, 1);
}
private static int parseInt(String pageStr) {
if (!NumberUtils.isParsable(pageStr)) {
throw new IllegalArgumentException("Page number must be a number");
}
return NumberUtils.toInt(pageStr, 1);
}
}
}

View File

@ -1,49 +1,40 @@
package run.halo.app.theme.router;
import java.util.List;
import org.springframework.http.HttpMethod;
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;
/**
* <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 TemplateRouteManager}.
* {@link PermalinkHttpGetRouter}.
*
* @author guqing
* @see TemplateRouteManager
* @see PermalinkHttpGetRouter
* @since 2.0.0
*/
@Component
public class ThemeCompositeRouterFunction implements
RouterFunction<ServerResponse> {
private final TemplateRouteManager templateRouterManager;
private final PermalinkHttpGetRouter permalinkHttpGetRouter;
public ThemeCompositeRouterFunction(TemplateRouteManager templateRouterManager) {
this.templateRouterManager = templateRouterManager;
public ThemeCompositeRouterFunction(PermalinkHttpGetRouter permalinkHttpGetRouter) {
this.permalinkHttpGetRouter = permalinkHttpGetRouter;
}
@Override
@NonNull
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
return Flux.fromIterable(getRouterFunctions())
.concatMap(routerFunction -> routerFunction.route(request))
.next();
// this router function only supports GET method
if (!request.method().equals(HttpMethod.GET)) {
return Mono.empty();
}
@Override
public void accept(@NonNull RouterFunctions.Visitor visitor) {
getRouterFunctions().forEach(routerFunction -> routerFunction.accept(visitor));
}
private List<RouterFunction<ServerResponse>> getRouterFunctions() {
return List.copyOf(templateRouterManager.getRouterFunctionMap().values());
return Mono.justOrEmpty(permalinkHttpGetRouter.route(request));
}
}

View File

@ -1,15 +1,12 @@
package run.halo.app.theme.router.strategy;
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.TemplateRouterStrategy.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.TemplateRouterStrategy.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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;
@ -18,38 +15,24 @@ 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.PostVo;
import run.halo.app.theme.router.TemplateRouterStrategy;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link ArchivesRouteStrategy} for generate {@link RouterFunction} specific to the template
* 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 TemplateRouterStrategy {
public class ArchivesRouteStrategy implements ListPageRouteHandlerStrategy {
private final PostFinder postFinder;
public ArchivesRouteStrategy(PostFinder postFinder) {
this.postFinder = postFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String prefix) {
return RouterFunctions
.route(GET(prefix)
.or(GET(PathUtils.combinePath(prefix, "/page/{page:\\d+}")))
.or(GET(PathUtils.combinePath(prefix, "/{year:\\d{4}}/{month:\\d{2}}")))
.or(GET(PathUtils.combinePath(prefix,
"/{year:\\d{4}}/{month:\\d{2}}/page/{page:\\d+}")))
.and(accept(MediaType.TEXT_HTML)),
request -> ServerResponse.ok()
.render(DefaultTemplateEnum.ARCHIVES.getValue(),
Map.of("posts", postList(request))));
}
private Mono<UrlContextListResult<PostVo>> postList(ServerRequest request) {
String path = request.path();
return Mono.defer(() -> Mono.just(postFinder.list(pageNum(request), 10)))
@ -60,4 +43,27 @@ public class ArchivesRouteStrategy implements TemplateRouterStrategy {
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.ARCHIVES.getValue(),
Map.of("posts", postList(request)));
}
@Override
public List<String> getRouterPaths(String prefix) {
return List.of(
prefix,
PathUtils.combinePath(prefix, "/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,50 +1,51 @@
package run.halo.app.theme.router.strategy;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.vo.CategoryTreeVo;
import run.halo.app.theme.router.TemplateRouterStrategy;
/**
* Categories router strategy for generate {@link RouterFunction} specific to the template
* Categories router strategy for generate {@link HandlerFunction} specific to the template
* <code>categories.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class CategoriesRouteStrategy implements TemplateRouterStrategy {
public class CategoriesRouteStrategy implements ListPageRouteHandlerStrategy {
private final CategoryFinder categoryFinder;
public CategoriesRouteStrategy(CategoryFinder categoryFinder) {
this.categoryFinder = categoryFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String prefix) {
return RouterFunctions
.route(GET(PathUtils.combinePath(prefix))
.and(accept(MediaType.TEXT_HTML)),
request -> ServerResponse.ok()
.render(DefaultTemplateEnum.CATEGORIES.getValue(),
Map.of("categories", categories())));
}
private Mono<List<CategoryTreeVo>> categories() {
return Mono.defer(() -> Mono.just(categoryFinder.listAsTree()))
.publishOn(Schedulers.boundedElastic());
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.CATEGORIES.getValue(),
Map.of("categories", categories()));
}
@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

@ -1,74 +1,46 @@
package run.halo.app.theme.router.strategy;
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.TemplateRouterStrategy.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.TemplateRouterStrategy.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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 reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.Category;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.utils.PathUtils;
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.CategoryVo;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.router.PermalinkIndexer;
import run.halo.app.theme.router.TemplateRouterStrategy;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link CategoryRouteStrategy} for generate {@link RouterFunction} specific to the template
* The {@link CategoryRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>category.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class CategoryRouteStrategy implements TemplateRouterStrategy {
private final PermalinkIndexer permalinkIndexer;
public class CategoryRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Category.class);
private final PostFinder postFinder;
private final CategoryFinder categoryFinder;
public CategoryRouteStrategy(PermalinkIndexer permalinkIndexer, PostFinder postFinder,
public CategoryRouteStrategy(PostFinder postFinder,
CategoryFinder categoryFinder) {
this.permalinkIndexer = permalinkIndexer;
this.postFinder = postFinder;
this.categoryFinder = categoryFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String prefix) {
return RouterFunctions
.route(GET(PathUtils.combinePath(prefix, "/{slug}"))
.or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}")))
.and(accept(MediaType.TEXT_HTML)),
request -> {
String slug = request.pathVariable("slug");
GroupVersionKind gvk = GroupVersionKind.fromExtension(Category.class);
if (!permalinkIndexer.containsSlug(gvk, slug)) {
return ServerResponse.notFound().build();
}
String categoryName = permalinkIndexer.getNameBySlug(gvk, slug);
return ServerResponse.ok()
.render(DefaultTemplateEnum.CATEGORY.getValue(),
Map.of("name", categoryName,
"posts", postListByCategoryName(categoryName, request),
"category", categoryByName(categoryName)));
});
}
private Mono<UrlContextListResult<PostVo>> postListByCategoryName(String name,
ServerRequest request) {
String path = request.path();
@ -85,4 +57,19 @@ public class CategoryRouteStrategy implements TemplateRouterStrategy {
return Mono.defer(() -> Mono.just(categoryFinder.getByName(name)))
.publishOn(Schedulers.boundedElastic());
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.CATEGORY.getValue(),
Map.of("name", name,
"posts", postListByCategoryName(name, request),
"category", categoryByName(name)));
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -0,0 +1,21 @@
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,15 +1,12 @@
package run.halo.app.theme.router.strategy;
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.TemplateRouterStrategy.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.TemplateRouterStrategy.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
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;
@ -17,18 +14,18 @@ import reactor.core.scheduler.Schedulers;
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.TemplateRouterStrategy;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
* The {@link IndexRouteStrategy} for generate {@link RouterFunction} specific to the template
* The {@link IndexRouteStrategy} for generate {@link HandlerFunction} specific to the template
* <code>index.html</code>.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class IndexRouteStrategy implements TemplateRouterStrategy {
public class IndexRouteStrategy implements ListPageRouteHandlerStrategy {
private final PostFinder postFinder;
@ -36,16 +33,6 @@ public class IndexRouteStrategy implements TemplateRouterStrategy {
this.postFinder = postFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) {
return RouterFunctions
.route(GET("/").or(GET("/page/{page:\\d+}"))
.and(accept(MediaType.TEXT_HTML)),
request -> ServerResponse.ok()
.render(DefaultTemplateEnum.INDEX.getValue(),
Map.of("posts", postList(request))));
}
private Mono<UrlContextListResult<PostVo>> postList(ServerRequest request) {
String path = request.path();
return Mono.defer(() -> Mono.just(postFinder.list(pageNum(request), 10)))
@ -56,4 +43,21 @@ public class IndexRouteStrategy implements TemplateRouterStrategy {
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build());
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.INDEX.getValue(),
Map.of("posts", postList(request)));
}
@Override
public List<String> getRouterPaths(String pattern) {
return List.of("/", "/index");
}
@Override
public boolean supports(DefaultTemplateEnum template) {
return DefaultTemplateEnum.INDEX.equals(template);
}
}

View File

@ -0,0 +1,22 @@
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,177 +1,69 @@
package run.halo.app.theme.router.strategy;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.MediaType;
import org.springframework.http.server.PathContainer;
import org.springframework.stereotype.Component;
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.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.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.finders.vo.PostVo;
import run.halo.app.theme.router.PermalinkIndexer;
import run.halo.app.theme.router.TemplateRouterStrategy;
/**
* The {@link PostRouteStrategy} for generate {@link RouterFunction} specific to the template
* 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 TemplateRouterStrategy {
private final PermalinkIndexer permalinkIndexer;
public class PostRouteStrategy implements DetailsPageRouteHandlerStrategy {
static final String NAME_PARAM = "name";
private final GroupVersionKind groupVersionKind = GroupVersionKind.fromExtension(Post.class);
private final PostFinder postFinder;
public PostRouteStrategy(PermalinkIndexer permalinkIndexer, PostFinder postFinder) {
this.permalinkIndexer = permalinkIndexer;
public PostRouteStrategy(PostFinder postFinder) {
this.postFinder = postFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) {
PostRequestParamPredicate postParamPredicate =
new PostRequestParamPredicate(pattern);
GVK gvk = Post.class.getAnnotation(GVK.class);
if (postParamPredicate.isQueryParamPattern()) {
String paramName = postParamPredicate.getParamName();
String placeholderName = postParamPredicate.getPlaceholderName();
RequestPredicate requestPredicate = postParamPredicate.requestPredicate();
return RouterFunctions.route(requestPredicate,
request -> {
String name = null;
if (PostRequestParamPredicate.NAME_PARAM.equals(
placeholderName)) {
name = request.queryParam(paramName).orElseThrow();
}
if (PostRequestParamPredicate.SLUG_PARAM.equals(
placeholderName)) {
name = permalinkIndexer.getNameBySlug(
PostRequestParamPredicate.GVK,
placeholderName);
}
if (name == null) {
return ServerResponse.notFound().build();
}
GroupVersionKind groupVersionKind =
new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind());
return ServerResponse.ok()
.render(DefaultTemplateEnum.POST.getValue(),
Map.of(PostRequestParamPredicate.NAME_PARAM, name,
"post", postByName(name),
"groupVersionKind", groupVersionKind,
"plural", gvk.plural())
);
});
}
return RouterFunctions
.route(GET(pattern).and(accept(MediaType.TEXT_HTML)),
request -> {
ExtensionLocator locator = permalinkIndexer.lookup(request.path());
if (locator == null) {
return ServerResponse.notFound().build();
}
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(PostRequestParamPredicate.NAME_PARAM, locator.name());
model.put(NAME_PARAM, name);
if (pathMatchInfo != null) {
model.putAll(pathMatchInfo.getUriVariables());
}
model.put("post", postByName(locator.name()));
model.put("groupVersionKind", locator.gvk());
model.put("post", postByName(name));
model.put("groupVersionKind", groupVersionKind);
model.put("plural", gvk.plural());
return ServerResponse.ok()
.render(DefaultTemplateEnum.POST.getValue(), model);
});
};
}
@Override
public boolean supports(GroupVersionKind gvk) {
return groupVersionKind.equals(gvk);
}
private Mono<PostVo> postByName(String name) {
return Mono.defer(() -> Mono.just(postFinder.getByName(name)))
.publishOn(Schedulers.boundedElastic());
}
class PostRequestParamPredicate {
static final String NAME_PARAM = "name";
static final String SLUG_PARAM = "slug";
static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Post.class);
private final String pattern;
private String paramName;
private String placeholderName;
private final boolean isQueryParamPattern;
PostRequestParamPredicate(String pattern) {
this.pattern = pattern;
Matcher matcher = matchUrlParam(pattern);
if (matcher != null) {
this.paramName = matcher.group(1);
this.placeholderName = matcher.group(2);
this.isQueryParamPattern = true;
} else {
this.isQueryParamPattern = false;
}
}
RequestPredicate requestPredicate() {
if (!this.isQueryParamPattern) {
throw new IllegalStateException("Not a query param pattern: " + pattern);
}
if (NAME_PARAM.equals(placeholderName)) {
return RequestPredicates.queryParam(paramName,
name -> permalinkIndexer.containsName(GVK, name));
}
if (SLUG_PARAM.equals(placeholderName)) {
return RequestPredicates.queryParam(paramName,
slug -> permalinkIndexer.containsSlug(GVK, slug));
}
throw new IllegalArgumentException(
String.format("Unknown param value placeholder [%s] in pattern [%s]",
placeholderName, pattern));
}
Matcher matchUrlParam(String patternSequence) {
Pattern compile = Pattern.compile("([^&?]*)=\\{(.*?)\\}(&|$)");
Matcher matcher = compile.matcher(patternSequence);
if (matcher.find()) {
return matcher;
}
return null;
}
public String getParamName() {
return this.paramName;
}
public String getPlaceholderName() {
return placeholderName;
}
public boolean isQueryParamPattern() {
return isQueryParamPattern;
}
}
}

View File

@ -1,72 +1,35 @@
package run.halo.app.theme.router.strategy;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static run.halo.app.theme.router.strategy.PermalinkPredicates.get;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.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.finders.vo.SinglePageVo;
import run.halo.app.theme.router.PermalinkIndexer;
import run.halo.app.theme.router.TemplateRouterStrategy;
/**
* The {@link SinglePageRouteStrategy} for generate {@link RouterFunction} specific to the template
* 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 TemplateRouterStrategy {
private final PermalinkIndexer permalinkIndexer;
public class SinglePageRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
private final SinglePageFinder singlePageFinder;
public SinglePageRouteStrategy(PermalinkIndexer permalinkIndexer,
SinglePageFinder singlePageFinder) {
this.permalinkIndexer = permalinkIndexer;
public SinglePageRouteStrategy(SinglePageFinder singlePageFinder) {
this.singlePageFinder = singlePageFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) {
GroupVersionKind gvk = GroupVersionKind.fromExtension(SinglePage.class);
RequestPredicate requestPredicate = request -> false;
List<String> permalinks = permalinkIndexer.getPermalinks(gvk);
for (String permalink : permalinks) {
requestPredicate = requestPredicate.or(get(permalink));
}
return RouterFunctions
.route(requestPredicate.and(accept(MediaType.TEXT_HTML)), request -> {
var name = permalinkIndexer.getNameByPermalink(gvk, request.path());
if (name == null) {
return ServerResponse.notFound().build();
}
return ServerResponse.ok()
.render(DefaultTemplateEnum.SINGLE_PAGE.getValue(),
Map.of("name", name,
"groupVersionKind", gvk,
"plural", getPlural(),
"singlePage", singlePageByName(name)));
});
}
private String getPlural() {
GVK annotation = SinglePage.class.getAnnotation(GVK.class);
return annotation.plural();
@ -76,4 +39,20 @@ public class SinglePageRouteStrategy implements TemplateRouterStrategy {
return Mono.defer(() -> Mono.just(singlePageFinder.getByName(name)))
.publishOn(Schedulers.boundedElastic());
}
@Override
public HandlerFunction<ServerResponse> getHandler(SystemSetting.ThemeRouteRules routeRules,
String name) {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.SINGLE_PAGE.getValue(),
Map.of("name", name,
"groupVersionKind", gvk,
"plural", getPlural(),
"singlePage", singlePageByName(name)));
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -1,29 +1,25 @@
package run.halo.app.theme.router.strategy;
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.TemplateRouterStrategy.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.TemplateRouterStrategy.PageUrlUtils.totalPage;
import static run.halo.app.theme.router.PageUrlUtils.pageNum;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
import java.util.Map;
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 reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.Tag;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.infra.utils.PathUtils;
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.PostVo;
import run.halo.app.theme.finders.vo.TagVo;
import run.halo.app.theme.router.PermalinkIndexer;
import run.halo.app.theme.router.TemplateRouterStrategy;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
/**
@ -34,43 +30,18 @@ import run.halo.app.theme.router.UrlContextListResult;
* @since 2.0.0
*/
@Component
public class TagRouteStrategy implements TemplateRouterStrategy {
private final PermalinkIndexer permalinkIndexer;
public class TagRouteStrategy implements DetailsPageRouteHandlerStrategy {
private final GroupVersionKind gvk = GroupVersionKind.fromExtension(Tag.class);
private final PostFinder postFinder;
private final TagFinder tagFinder;
public TagRouteStrategy(PermalinkIndexer permalinkIndexer, PostFinder postFinder,
public TagRouteStrategy(PostFinder postFinder,
TagFinder tagFinder) {
this.permalinkIndexer = permalinkIndexer;
this.postFinder = postFinder;
this.tagFinder = tagFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String prefix) {
return RouterFunctions
.route(GET(PathUtils.combinePath(prefix, "/{slug}"))
.or(GET(PathUtils.combinePath(prefix, "/{slug}/page/{page:\\d+}")))
.and(accept(MediaType.TEXT_HTML)),
request -> {
GroupVersionKind gvk = GroupVersionKind.fromExtension(Tag.class);
String slug = request.pathVariable("slug");
if (!permalinkIndexer.containsSlug(gvk, slug)) {
return ServerResponse.notFound().build();
}
String name = permalinkIndexer.getNameBySlug(gvk, slug);
return ServerResponse.ok()
.render(DefaultTemplateEnum.TAG.getValue(),
Map.of("name", name,
"posts", postList(request, name),
"tag", tagByName(name))
);
});
}
private Mono<UrlContextListResult<PostVo>> postList(ServerRequest request, String name) {
String path = request.path();
return Mono.defer(() -> Mono.just(postFinder.listByTag(pageNum(request), 10, name)))
@ -86,4 +57,20 @@ public class TagRouteStrategy implements TemplateRouterStrategy {
return Mono.defer(() -> Mono.just(tagFinder.getByName(name)))
.publishOn(Schedulers.boundedElastic());
}
@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", tagByName(name))
);
}
@Override
public boolean supports(GroupVersionKind gvk) {
return this.gvk.equals(gvk);
}
}

View File

@ -1,32 +1,26 @@
package run.halo.app.theme.router.strategy;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.TagVo;
import run.halo.app.theme.router.TemplateRouterStrategy;
/**
* The {@link TagsRouteStrategy} for generate {@link RouterFunction} specific to the template
* 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 TemplateRouterStrategy {
public class TagsRouteStrategy implements ListPageRouteHandlerStrategy {
private final TagFinder tagFinder;
@ -34,20 +28,25 @@ public class TagsRouteStrategy implements TemplateRouterStrategy {
this.tagFinder = tagFinder;
}
@Override
public RouterFunction<ServerResponse> getRouteFunction(String template, String prefix) {
String pattern = PathUtils.combinePath(prefix);
return RouterFunctions
.route(GET(pattern)
.and(accept(MediaType.TEXT_HTML)),
request -> ServerResponse.ok()
.render(DefaultTemplateEnum.TAGS.getValue(),
Map.of("tags", tags()))
);
}
private Mono<List<TagVo>> tags() {
return Mono.defer(() -> Mono.just(tagFinder.listAll()))
.publishOn(Schedulers.boundedElastic());
}
@Override
public HandlerFunction<ServerResponse> getHandler() {
return request -> ServerResponse.ok()
.render(DefaultTemplateEnum.TAGS.getValue(),
Map.of("tags", tags()));
}
@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

@ -32,7 +32,6 @@ import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.CounterMeterHandler;
@ -56,9 +55,6 @@ class UserEndpointTest {
@MockBean
CounterMeterHandler counterMeterHandler;
@MockBean
SystemConfigurableEnvironmentFetcher environmentFetcher;
@BeforeEach
void setUp() {
// disable authorization

View File

@ -30,11 +30,9 @@ import run.halo.app.core.extension.Snapshot;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.router.PermalinkIndexAddCommand;
import run.halo.app.theme.router.PermalinkIndexDeleteCommand;
import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
import run.halo.app.theme.router.TemplateRouteManager;
/**
* Tests for {@link SinglePageReconciler}.
@ -52,15 +50,11 @@ class SinglePageReconcilerTest {
@Mock
private ApplicationContext applicationContext;
@Mock
private TemplateRouteManager templateRouteManager;
private SinglePageReconciler singlePageReconciler;
@BeforeEach
void setUp() {
singlePageReconciler = new SinglePageReconciler(client, contentService, applicationContext,
templateRouteManager);
singlePageReconciler = new SinglePageReconciler(client, contentService, applicationContext);
}
@Test
@ -94,8 +88,6 @@ class SinglePageReconcilerTest {
verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexAddCommand.class));
verify(applicationContext, times(1)).publishEvent(isA(PermalinkIndexDeleteCommand.class));
verify(applicationContext, times(0)).publishEvent(isA(PermalinkIndexUpdateCommand.class));
verify(templateRouteManager, times(1))
.changeTemplatePattern(eq(DefaultTemplateEnum.SINGLE_PAGE.getValue()));
}
public static SinglePage pageV1() {

View File

@ -0,0 +1,63 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link PageUrlUtils}.
*
* @author guqing
* @since 2.0.0
*/
class PageUrlUtilsTest {
static String s = "/tags";
static String s1 = "/tags/page/1";
static String s2 = "/tags/page/2";
static String s3 = "/tags/y/m/page/2";
static String s4 = "/tags/y/m";
static String s5 = "/tags/y/m/page/3";
@Test
void nextPageUrl() {
long totalPage = 10;
assertThat(PageUrlUtils.nextPageUrl(s, totalPage))
.isEqualTo("/tags/page/2");
assertThat(PageUrlUtils.nextPageUrl(s2, totalPage))
.isEqualTo("/tags/page/3");
assertThat(PageUrlUtils.nextPageUrl(s3, totalPage))
.isEqualTo("/tags/y/m/page/3");
assertThat(PageUrlUtils.nextPageUrl(s4, totalPage))
.isEqualTo("/tags/y/m/page/2");
assertThat(PageUrlUtils.nextPageUrl(s5, totalPage))
.isEqualTo("/tags/y/m/page/4");
// The number of pages does not exceed the total number of pages
totalPage = 1;
assertThat(PageUrlUtils.nextPageUrl("/tags/page/1", totalPage))
.isEqualTo("/tags/page/1");
totalPage = 0;
assertThat(PageUrlUtils.nextPageUrl("/tags", totalPage))
.isEqualTo("/tags/page/1");
}
@Test
void prevPageUrl() {
assertThat(PageUrlUtils.prevPageUrl(s))
.isEqualTo("/tags");
assertThat(PageUrlUtils.prevPageUrl(s1))
.isEqualTo("/tags");
assertThat(PageUrlUtils.prevPageUrl(s2))
.isEqualTo("/tags");
assertThat(PageUrlUtils.prevPageUrl(s3))
.isEqualTo("/tags/y/m");
assertThat(PageUrlUtils.prevPageUrl(s4))
.isEqualTo("/tags/y/m");
assertThat(PageUrlUtils.prevPageUrl(s5))
.isEqualTo("/tags/y/m/page/2");
assertThat(PageUrlUtils.prevPageUrl("/page/2"))
.isEqualTo("/");
}
}

View File

@ -4,11 +4,18 @@ 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;
@ -19,15 +26,18 @@ import run.halo.app.extension.GroupVersionKind;
* @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();
permalinkIndexer = new PermalinkIndexer(applicationContext);
ExtensionLocator locator = new ExtensionLocator(gvk, "fake-name", "fake-slug");
permalinkIndexer.register(locator, "/fake-permalink");
@ -39,6 +49,7 @@ class PermalinkIndexerTest {
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);
@ -54,6 +65,8 @@ class PermalinkIndexerTest {
permalinkIndexer.remove(locator);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(0);
assertThat(permalinkIndexer.permalinkLocatorMapSize()).isEqualTo(0);
verify(applicationContext, times(2)).publishEvent(any(PermalinkIndexChangedEvent.class));
}
@Test

View File

@ -0,0 +1,267 @@
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

@ -1,76 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
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.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.router.strategy.TagsRouteStrategy;
/**
* Tests for {@link TemplateRouteManager}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TemplateRouteManagerTest {
@Mock
private PermalinkPatternProvider permalinkPatternProvider;
@Mock
private ApplicationContext applicationContext;
private TemplateRouteManager templateRouteManager;
@BeforeEach
void setUp() {
templateRouteManager = new TemplateRouteManager(permalinkPatternProvider,
applicationContext);
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAGS))
.thenReturn("/tags");
TagFinder tagFinder = Mockito.mock(TagFinder.class);
when(applicationContext.getBean(eq(TagsRouteStrategy.class)))
.thenReturn(new TagsRouteStrategy(tagFinder));
templateRouteManager.register(DefaultTemplateEnum.TAGS.getValue());
}
@Test
void getRouterFunctionMap() {
Map<String, RouterFunction<ServerResponse>> routerFunctionMap =
templateRouteManager.getRouterFunctionMap();
assertThat(routerFunctionMap.size()).isEqualTo(1);
assertThat(routerFunctionMap).containsKey(DefaultTemplateEnum.TAGS.getValue());
}
@Test
void changeTemplatePattern() {
Map<String, RouterFunction<ServerResponse>> routerFunctionMap =
templateRouteManager.getRouterFunctionMap();
RouterFunction<ServerResponse> routerFunction =
routerFunctionMap.get(DefaultTemplateEnum.TAGS.getValue());
when(permalinkPatternProvider.getPattern(DefaultTemplateEnum.TAGS))
.thenReturn("/t");
RouterFunction<ServerResponse> newRouterFunction =
templateRouteManager.changeTemplatePattern(DefaultTemplateEnum.TAGS.getValue());
assertThat(newRouterFunction).isNotEqualTo(routerFunction);
}
}

View File

@ -1,68 +0,0 @@
package run.halo.app.theme.router;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link TemplateRouterStrategy}.
*
* @author guqing
* @since 2.0.0
*/
class TemplateRouterStrategyTest {
@Nested
class PageUrlUtils {
static String s = "/tags";
static String s1 = "/tags/page/1";
static String s2 = "/tags/page/2";
static String s3 = "/tags/y/m/page/2";
static String s4 = "/tags/y/m";
static String s5 = "/tags/y/m/page/3";
@Test
void nextPageUrl() {
long totalPage = 10;
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl(s, totalPage))
.isEqualTo("/tags/page/2");
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl(s2, totalPage))
.isEqualTo("/tags/page/3");
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl(s3, totalPage))
.isEqualTo("/tags/y/m/page/3");
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl(s4, totalPage))
.isEqualTo("/tags/y/m/page/2");
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl(s5, totalPage))
.isEqualTo("/tags/y/m/page/4");
// The number of pages does not exceed the total number of pages
totalPage = 1;
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl("/tags/page/1", totalPage))
.isEqualTo("/tags/page/1");
totalPage = 0;
assertThat(TemplateRouterStrategy.PageUrlUtils.nextPageUrl("/tags", totalPage))
.isEqualTo("/tags/page/1");
}
@Test
void prevPageUrl() {
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s))
.isEqualTo("/tags");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s1))
.isEqualTo("/tags");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s2))
.isEqualTo("/tags");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s3))
.isEqualTo("/tags/y/m");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s4))
.isEqualTo("/tags/y/m");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl(s5))
.isEqualTo("/tags/y/m/page/2");
assertThat(TemplateRouterStrategy.PageUrlUtils.prevPageUrl("/page/2"))
.isEqualTo("/");
}
}
}

View File

@ -1,12 +1,9 @@
package run.halo.app.theme.router.strategy;
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 java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -14,12 +11,9 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.router.UrlContextListResult;
@ -30,36 +24,30 @@ import run.halo.app.theme.router.UrlContextListResult;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class ArchivesRouteStrategyTest {
@Mock
private ViewResolver viewResolver;
class ArchivesRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private ArchivesRouteStrategy archivesRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(postFinder.list(any(), any())).thenReturn(
new UrlContextListResult<>(1, 10, 1, List.of(), null, null));
}
@Test
void getRouteFunctionWhenDefaultPattern() {
RouterFunction<ServerResponse> routeFunction =
archivesRouteStrategy.getRouteFunction(DefaultTemplateEnum.ARCHIVES.getValue(),
"/archives");
HandlerFunction<ServerResponse> handler = archivesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
WebTestClient client = getWebTestClient(routeFunction);
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.ARCHIVES.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
List<String> routerPaths = archivesRouteStrategy.getRouterPaths("/archives");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
fixedAssertion(client, "/archives");
@ -100,18 +88,15 @@ class ArchivesRouteStrategyTest {
@Test
void getRouteFunctionWhenOtherPattern() {
RouterFunction<ServerResponse> routeFunction =
archivesRouteStrategy.getRouteFunction(DefaultTemplateEnum.ARCHIVES.getValue(),
"/archives-test");
HandlerFunction<ServerResponse> handler = archivesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.ARCHIVES.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
final WebTestClient client = getWebTestClient(routeFunction);
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
List<String> routerPaths = archivesRouteStrategy.getRouterPaths("/archives-test");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
fixedAssertion(client, "/archives-test");

View File

@ -1,12 +1,8 @@
package run.halo.app.theme.router.strategy;
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 java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -14,12 +10,9 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.CategoryFinder;
/**
@ -29,36 +22,30 @@ import run.halo.app.theme.finders.CategoryFinder;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class CategoriesRouteStrategyTest {
@Mock
private ViewResolver viewResolver;
class CategoriesRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private CategoryFinder categoryFinder;
@InjectMocks
private CategoriesRouteStrategy categoriesRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(categoryFinder.listAsTree())
.thenReturn(List.of());
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
categoriesRouteStrategy.getRouteFunction(DefaultTemplateEnum.ARCHIVES.getValue(),
"/categories-test");
HandlerFunction<ServerResponse> handler = categoriesRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
WebTestClient client = getWebTestClient(routeFunction);
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.CATEGORIES.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
List<String> routerPaths = categoriesRouteStrategy.getRouterPaths("/categories-test");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
client.get()
.uri("/categories-test")

View File

@ -2,12 +2,9 @@ package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -15,17 +12,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Category;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ListResult;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.router.PermalinkIndexer;
/**
* Tests for {@link CategoryRouteStrategy}.
@ -34,12 +24,7 @@ import run.halo.app.theme.router.PermalinkIndexer;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class CategoryRouteStrategyTest {
@Mock
private PermalinkIndexer permalinkIndexer;
@Mock
private ViewResolver viewResolver;
class CategoryRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@ -47,36 +32,21 @@ class CategoryRouteStrategyTest {
@InjectMocks
private CategoryRouteStrategy categoryRouteStrategy;
@BeforeEach
void setUp() {
GroupVersionKind gvk = GroupVersionKind.fromExtension(Category.class);
when(permalinkIndexer.containsSlug(eq(gvk), eq("category-slug-1")))
.thenReturn(true);
when(permalinkIndexer.containsSlug(eq(gvk), eq("category-slug-2")))
.thenReturn(true);
when(permalinkIndexer.getNameBySlug(any(), eq("category-slug-1")))
.thenReturn("category-name-1");
when(permalinkIndexer.getNameBySlug(any(), eq("category-slug-2")))
.thenReturn("category-name-2");
@Override
public void setUp() {
lenient().when(postFinder.listByCategory(anyInt(), anyInt(), any()))
.thenReturn(new ListResult<>(1, 10, 0, List.of()));
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
categoryRouteStrategy.getRouteFunction(DefaultTemplateEnum.CATEGORY.getValue(),
"/categories-test");
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
final WebTestClient client = getWebTestClient(routeFunction);
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.CATEGORY.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
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"));
// /{prefix}/{slug}
client.get()

View File

@ -1,13 +1,9 @@
package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -15,13 +11,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ListResult;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
/**
@ -31,36 +24,30 @@ import run.halo.app.theme.finders.PostFinder;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class IndexRouteStrategyTest {
@Mock
private ViewResolver viewResolver;
class IndexRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@InjectMocks
private IndexRouteStrategy indexRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(postFinder.list(anyInt(), anyInt()))
.thenReturn(new ListResult<>(1, 10, 0, List.of()));
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
indexRouteStrategy.getRouteFunction(DefaultTemplateEnum.INDEX.getValue(),
null);
HandlerFunction<ServerResponse> handler = indexRouteStrategy.getHandler();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
List<String> routerPaths = indexRouteStrategy.getRouterPaths("/");
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.INDEX.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
final WebTestClient client = getWebTestClient(routeFunction);
client.get()
.uri("/")

View File

@ -1,11 +1,8 @@
package run.halo.app.theme.router.strategy;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -13,19 +10,12 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.content.TestPost;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.Post;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.router.PermalinkIndexer;
/**
* Tests for {@link PostRouteStrategy}.
@ -34,13 +24,7 @@ import run.halo.app.theme.router.PermalinkIndexer;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class PostRouteStrategyTest {
@Mock
private ViewResolver viewResolver;
@Mock
private PermalinkIndexer permalinkIndexer;
class PostRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PostFinder postFinder;
@ -48,21 +32,22 @@ class PostRouteStrategyTest {
@InjectMocks
private PostRouteStrategy postRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(postFinder.getByName(any())).thenReturn(PostVo.from(TestPost.postV1()));
}
@Test
void getRouteFunctionWhenSlugPathVariable() {
RouterFunction<ServerResponse> routeFunction =
postRouteStrategy.getRouteFunction(DefaultTemplateEnum.POST.getValue(),
"/posts-test/{slug}");
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);
piling();
client.get()
.uri("/posts-test/fake-slug")
.exchange()
@ -72,14 +57,15 @@ class PostRouteStrategyTest {
@Test
void getRouteFunctionWhenNamePathVariable() {
RouterFunction<ServerResponse> routeFunction =
postRouteStrategy.getRouteFunction(DefaultTemplateEnum.POST.getValue(),
"/posts-test/{name}");
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);
piling();
client.get()
.uri("/posts-test/fake-name")
.exchange()
@ -89,14 +75,15 @@ class PostRouteStrategyTest {
@Test
void getRouteFunctionWhenYearMonthSlugPathVariable() {
RouterFunction<ServerResponse> routeFunction =
postRouteStrategy.getRouteFunction(DefaultTemplateEnum.POST.getValue(),
"/{year}/{month}/{slug}");
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);
piling();
client.get()
.uri("/2022/08/fake-slug")
.exchange()
@ -106,17 +93,18 @@ class PostRouteStrategyTest {
@Test
void getRouteFunctionWhenQueryParam() {
RouterFunction<ServerResponse> routeFunction =
postRouteStrategy.getRouteFunction(DefaultTemplateEnum.POST.getValue(),
"/?pp={name}");
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);
piling();
client.get()
.uri(uriBuilder -> uriBuilder.path("/")
.queryParam("pp", "fake-name")
.queryParam("p", "fake-name")
.build()
)
.exchange()
@ -125,41 +113,11 @@ class PostRouteStrategyTest {
client.get()
.uri(uriBuilder -> uriBuilder.path("/")
.queryParam("pp", "nothing")
.queryParam("p", "nothing")
.build()
)
.exchange()
.expectStatus()
.isEqualTo(HttpStatus.NOT_FOUND);
}
private void piling() {
GroupVersionKind postGvk = GroupVersionKind.fromExtension(Post.class);
lenient().when(permalinkIndexer.containsName(eq(postGvk), eq("fake-name")))
.thenReturn(true);
lenient().when(permalinkIndexer.containsSlug(eq(postGvk), eq("fake-slug")))
.thenReturn(true);
lenient().when(permalinkIndexer.getNameBySlug(any(), eq("fake-slug")))
.thenReturn("fake-name");
ExtensionLocator extensionLocator =
new ExtensionLocator(GroupVersionKind.fromExtension(Post.class), "fake-name",
"fake-slug");
lenient().when(permalinkIndexer.lookup(any()))
.thenReturn(extensionLocator);
}
private WebTestClient getWebTestClient(RouterFunction<ServerResponse> routeFunction) {
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.POST.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
return client;
}
}

View File

@ -0,0 +1,83 @@
package run.halo.app.theme.router.strategy;
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 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;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.theme.router.PermalinkHttpGetRouter;
/**
* Abstract test for {@link DetailsPageRouteHandlerStrategy} and
* {@link ListPageRouteHandlerStrategy}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
abstract class RouterStrategyTestSuite {
@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.fetch(eq(SystemSetting.ThemeRouteRules.GROUP),
eq(SystemSetting.ThemeRouteRules.class))).thenReturn(Mono.just(getThemeRouteRules()));
lenient().when(haloProperties.getExternalUrl()).thenReturn(new URI("http://example.com"));
when(viewResolver.resolveViewName(any(), any()))
.thenReturn(Mono.just(new EmptyView()));
setUp();
}
public void setUp() {
}
public SystemSetting.ThemeRouteRules getThemeRouteRules() {
SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
themeRouteRules.setArchives("archives");
themeRouteRules.setPost("/archives/{slug}");
themeRouteRules.setTags("tags");
themeRouteRules.setCategories("categories");
return themeRouteRules;
}
public WebTestClient getWebTestClient(RouterFunction<ServerResponse> routeFunction) {
return WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
}
public RouterFunction<ServerResponse> getRouterFunction() {
return request -> Mono.justOrEmpty(permalinkHttpGetRouter.route(request));
}
}

View File

@ -4,24 +4,20 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.theme.DefaultTemplateEnum.SINGLE_PAGE;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.result.view.ViewResolver;
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.extension.ListResult;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.router.PermalinkIndexer;
/**
* Tests for {@link SinglePageRouteStrategy}.
@ -30,22 +26,15 @@ import run.halo.app.theme.router.PermalinkIndexer;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageRouteStrategyTest {
@Mock
private PermalinkIndexer permalinkIndexer;
@Mock
private ViewResolver viewResolver;
class SinglePageRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private SinglePageFinder singlePageFinder;
@InjectMocks
private SinglePageRouteStrategy strategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(singlePageFinder.list(anyInt(), anyInt()))
.thenReturn(new ListResult<>(1, 10, 0, List.of()));
lenient().when(viewResolver.resolveViewName(eq(SINGLE_PAGE.getValue()), any()))
@ -62,52 +51,37 @@ class SinglePageRouteStrategyTest {
@Test
void shouldResponse200IfPermalinkFound() {
when(permalinkIndexer.getPermalinks(any()))
.thenReturn(List.of("/fake-slug"));
when(permalinkIndexer.getNameByPermalink(any(), eq("/fake-slug")))
.thenReturn("fake-name");
permalinkHttpGetRouter.insert("/fake-slug",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
createClient().get()
.uri("/fake-slug")
.exchange()
.expectStatus()
.isOk();
verify(permalinkIndexer).getNameByPermalink(any(), eq("/fake-slug"));
}
@Test
void shouldResponse200IfSlugNameContainsSpecialChars() {
when(permalinkIndexer.getPermalinks(any()))
.thenReturn(List.of("/fake%20/%20slug"));
when(permalinkIndexer.getNameByPermalink(any(), eq("/fake%20/%20slug")))
.thenReturn("fake-name");
permalinkHttpGetRouter.insert("/fake / slug",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
createClient().get()
.uri("/fake / slug")
.exchange()
.expectStatus().isOk();
verify(permalinkIndexer).getNameByPermalink(any(), eq("/fake%20/%20slug"));
}
@Test
void shouldResponse200IfSlugNameContainsChineseChars() {
when(permalinkIndexer.getPermalinks(any()))
.thenReturn(List.of("/%E4%B8%AD%E6%96%87"));
when(permalinkIndexer.getNameByPermalink(any(), eq("/%E4%B8%AD%E6%96%87")))
.thenReturn("fake-name");
permalinkHttpGetRouter.insert("/中文",
strategy.getHandler(getThemeRouteRules(), "fake-name"));
createClient().get()
.uri("/中文")
.exchange()
.expectStatus().isOk();
verify(permalinkIndexer).getNameByPermalink(any(), eq("/%E4%B8%AD%E6%96%87"));
}
WebTestClient createClient() {
var routeFunction = strategy.getRouteFunction(SINGLE_PAGE.getValue(), null);
return WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
return getWebTestClient(routeFunction);
}
}

View File

@ -2,12 +2,9 @@ package run.halo.app.theme.router.strategy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -15,17 +12,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Tag;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ListResult;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.router.PermalinkIndexer;
/**
* Tests for {@link TagRouteStrategy}.
@ -34,43 +24,27 @@ import run.halo.app.theme.router.PermalinkIndexer;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TagRouteStrategyTest {
class TagRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private PermalinkIndexer permalinkIndexer;
@Mock
private PostFinder postFinder;
@Mock
private ViewResolver viewResolver;
@InjectMocks
private TagRouteStrategy tagRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(postFinder.listByTag(anyInt(), anyInt(), any()))
.thenReturn(new ListResult<>(1, 10, 0, List.of()));
GroupVersionKind gvk = GroupVersionKind.fromExtension(Tag.class);
when(permalinkIndexer.containsSlug(eq(gvk), eq("fake-slug")))
.thenReturn(true);
when(permalinkIndexer.getNameBySlug(any(), eq("fake-slug")))
.thenReturn("fake-name");
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
tagRouteStrategy.getRouteFunction(DefaultTemplateEnum.TAG.getValue(),
"/tags-test");
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.TAG.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
permalinkHttpGetRouter.insert("/tags-test/fake-slug",
tagRouteStrategy.getHandler(getThemeRouteRules(), "fake-name"));
client.get()
.uri("/tags-test/fake-slug")

View File

@ -1,12 +1,8 @@
package run.halo.app.theme.router.strategy;
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 java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -14,12 +10,9 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.TagFinder;
/**
@ -29,10 +22,7 @@ import run.halo.app.theme.finders.TagFinder;
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class TagsRouteStrategyTest {
@Mock
private ViewResolver viewResolver;
class TagsRouteStrategyTest extends RouterStrategyTestSuite {
@Mock
private TagFinder tagFinder;
@ -40,25 +30,21 @@ class TagsRouteStrategyTest {
@InjectMocks
private TagsRouteStrategy tagsRouteStrategy;
@BeforeEach
void setUp() {
@Override
public void setUp() {
lenient().when(tagFinder.listAll()).thenReturn(List.of());
}
@Test
void getRouteFunction() {
RouterFunction<ServerResponse> routeFunction =
tagsRouteStrategy.getRouteFunction(DefaultTemplateEnum.TAGS.getValue(),
"/tags-test");
RouterFunction<ServerResponse> routeFunction = getRouterFunction();
WebTestClient client = getWebTestClient(routeFunction);
WebTestClient client = WebTestClient.bindToRouterFunction(routeFunction)
.handlerStrategies(HandlerStrategies.builder()
.viewResolver(viewResolver)
.build())
.build();
when(viewResolver.resolveViewName(eq(DefaultTemplateEnum.TAGS.getValue()), any()))
.thenReturn(Mono.just(new EmptyView()));
List<String> routerPaths = tagsRouteStrategy.getRouterPaths("/tags-test");
HandlerFunction<ServerResponse> handler = tagsRouteStrategy.getHandler();
for (String routerPath : routerPaths) {
permalinkHttpGetRouter.insert(routerPath, handler);
}
client.get()
.uri("/tags-test")