mirror of https://github.com/halo-dev/halo
feat: add reverse proxy router registry for plugin by reverse proxy rules (#2151)
* feat: add reverse proxy router registry for plugin by reverse proxy rules * Update src/main/java/run/halo/app/plugin/resources/ReverseProxyRouterFunctionFactory.java Co-authored-by: John Niang <johnniang@fastmail.com> * refactor: merge stream operation * refactor: plugin composite router function * feat: add unit test case Co-authored-by: John Niang <johnniang@fastmail.com>pull/2158/head
parent
a2f49c60bc
commit
6bbaa9aeba
|
@ -0,0 +1,47 @@
|
||||||
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path manipulation tool class.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public class PathUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine paths based on the passed in path segments parameters.
|
||||||
|
*
|
||||||
|
* @param pathSegments Path segments to be combined
|
||||||
|
* @return the combined path
|
||||||
|
*/
|
||||||
|
public static String combinePath(String... pathSegments) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String path : pathSegments) {
|
||||||
|
String s = path.startsWith("/") ? path : "/" + path;
|
||||||
|
String segment = s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||||
|
sb.append(segment);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Append a {@code '/'} if the path does not end with a {@code '/'}.</p>
|
||||||
|
* Examples are as follows:
|
||||||
|
* <pre>
|
||||||
|
* PathUtils.appendPathSeparatorIfMissing("hello") -> hello/
|
||||||
|
* PathUtils.appendPathSeparatorIfMissing("some-path/") -> some-path/
|
||||||
|
* PathUtils.appendPathSeparatorIfMissing(null) -> null
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @param path a path
|
||||||
|
* @return A new String if suffix was appended, the same string otherwise.
|
||||||
|
*/
|
||||||
|
public static String appendPathSeparatorIfMissing(String path) {
|
||||||
|
return StringUtils.appendIfMissing(path, "/", "/");
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import org.springframework.beans.factory.InitializingBean;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.ApplicationContextAware;
|
import org.springframework.context.ApplicationContextAware;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginStateChangedEvent;
|
import run.halo.app.plugin.event.HaloPluginStateChangedEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginStoppedEvent;
|
import run.halo.app.plugin.event.HaloPluginStoppedEvent;
|
||||||
|
@ -367,5 +368,12 @@ public class HaloPluginManager extends DefaultPluginManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected PluginWrapper loadPluginFromPath(Path pluginPath) {
|
||||||
|
PluginWrapper pluginWrapper = super.loadPluginFromPath(pluginPath);
|
||||||
|
rootApplicationContext.publishEvent(new HaloPluginLoadedEvent(this, pluginWrapper));
|
||||||
|
return pluginWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
// end-region
|
// end-region
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.pf4j.PluginWrapper;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.web.reactive.function.server.HandlerFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginStoppedEvent;
|
||||||
|
import run.halo.app.plugin.resources.ReverseProxyRouterFunctionFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composite {@link RouterFunction} implementation for plugin.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PluginCompositeRouterFunction implements RouterFunction<ServerResponse> {
|
||||||
|
|
||||||
|
private final Map<String, RouterFunction<ServerResponse>> routerFunctionRegistry =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private final ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
|
||||||
|
|
||||||
|
public PluginCompositeRouterFunction(
|
||||||
|
ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory) {
|
||||||
|
this.reverseProxyRouterFunctionFactory = reverseProxyRouterFunctionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RouterFunction<ServerResponse> getRouterFunction(String pluginId) {
|
||||||
|
return routerFunctionRegistry.get(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
|
||||||
|
return Flux.fromIterable(routerFunctionRegistry.values())
|
||||||
|
.concatMap(routerFunction -> routerFunction.route(request))
|
||||||
|
.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(@NonNull RouterFunctions.Visitor visitor) {
|
||||||
|
routerFunctionRegistry.values().forEach(routerFunction -> routerFunction.accept(visitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains the user-defined {@link RouterFunction} from the plugin
|
||||||
|
* {@link PluginApplicationContext} and create {@link RouterFunction} according to the
|
||||||
|
* reverse proxy configuration file then register them to {@link #routerFunctionRegistry}.
|
||||||
|
*
|
||||||
|
* @param haloPluginStartedEvent event for plugin started
|
||||||
|
*/
|
||||||
|
@EventListener(HaloPluginStartedEvent.class)
|
||||||
|
public void onPluginStarted(HaloPluginStartedEvent haloPluginStartedEvent) {
|
||||||
|
PluginWrapper plugin = haloPluginStartedEvent.getPlugin();
|
||||||
|
// Obtain plugin application context
|
||||||
|
PluginApplicationContext pluginApplicationContext =
|
||||||
|
ExtensionContextRegistry.getInstance().getByPluginId(plugin.getPluginId());
|
||||||
|
|
||||||
|
// create reverse proxy router function for plugin
|
||||||
|
RouterFunction<ServerResponse> reverseProxyRouterFunction =
|
||||||
|
reverseProxyRouterFunctionFactory.create(pluginApplicationContext);
|
||||||
|
|
||||||
|
routerFunctions(pluginApplicationContext)
|
||||||
|
.stream()
|
||||||
|
.reduce(RouterFunction::and)
|
||||||
|
.map(compositeRouterFunction -> {
|
||||||
|
if (reverseProxyRouterFunction != null) {
|
||||||
|
compositeRouterFunction.andOther(reverseProxyRouterFunction);
|
||||||
|
}
|
||||||
|
return compositeRouterFunction;
|
||||||
|
})
|
||||||
|
.ifPresent(routerFunction -> {
|
||||||
|
routerFunctionRegistry.put(plugin.getPluginId(), routerFunction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(HaloPluginStoppedEvent.class)
|
||||||
|
public void onPluginStopped(HaloPluginStoppedEvent haloPluginStoppedEvent) {
|
||||||
|
PluginWrapper plugin = haloPluginStoppedEvent.getPlugin();
|
||||||
|
routerFunctionRegistry.remove(plugin.getPluginId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<RouterFunction<ServerResponse>> routerFunctions(
|
||||||
|
PluginApplicationContext applicationContext) {
|
||||||
|
List<RouterFunction<ServerResponse>> functions =
|
||||||
|
applicationContext.getBeanProvider(RouterFunction.class)
|
||||||
|
.orderedStream()
|
||||||
|
.map(router -> (RouterFunction<ServerResponse>) router)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return (!CollectionUtils.isEmpty(functions) ? functions : Collections.emptyList());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
|
import org.pf4j.PluginWrapper;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.Schemes;
|
||||||
|
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||||
|
import run.halo.app.plugin.resources.ReverseProxy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PluginLoadedListener implements ApplicationListener<HaloPluginLoadedEvent> {
|
||||||
|
private static final String REVERSE_PROXY_NAME = "extensions/reverseProxy.yaml";
|
||||||
|
private final ExtensionClient extensionClient;
|
||||||
|
|
||||||
|
public PluginLoadedListener(ExtensionClient extensionClient) {
|
||||||
|
this.extensionClient = extensionClient;
|
||||||
|
|
||||||
|
// TODO Optimize schemes register
|
||||||
|
Schemes.INSTANCE.register(Plugin.class);
|
||||||
|
Schemes.INSTANCE.register(ReverseProxy.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onApplicationEvent(HaloPluginLoadedEvent event) {
|
||||||
|
PluginWrapper pluginWrapper = event.getPluginWrapper();
|
||||||
|
// TODO: Optimize plugin custom resource loading method
|
||||||
|
// load plugin.yaml
|
||||||
|
YamlPluginFinder yamlPluginFinder = new YamlPluginFinder();
|
||||||
|
Plugin plugin = yamlPluginFinder.find(pluginWrapper.getPluginPath());
|
||||||
|
DefaultResourceLoader defaultResourceLoader =
|
||||||
|
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
|
||||||
|
extensionClient.create(plugin);
|
||||||
|
// load reverse proxy
|
||||||
|
Resource resource = defaultResourceLoader.getResource(REVERSE_PROXY_NAME);
|
||||||
|
if (resource.exists()) {
|
||||||
|
YamlUnstructuredLoader unstructuredLoader = new YamlUnstructuredLoader(resource);
|
||||||
|
unstructuredLoader.load().forEach(extensionClient::create);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package run.halo.app.plugin.event;
|
||||||
|
|
||||||
|
import org.pf4j.PluginWrapper;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class HaloPluginLoadedEvent extends ApplicationEvent {
|
||||||
|
private final PluginWrapper pluginWrapper;
|
||||||
|
|
||||||
|
|
||||||
|
public HaloPluginLoadedEvent(Object source, PluginWrapper pluginWrapper) {
|
||||||
|
super(source);
|
||||||
|
this.pluginWrapper = pluginWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginWrapper getPluginWrapper() {
|
||||||
|
return pluginWrapper;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
package run.halo.app.plugin.resources;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import run.halo.app.extension.AbstractExtension;
|
||||||
|
import run.halo.app.extension.GVK;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>The reverse proxy custom resource is used to configure a path to proxy it to a directory or
|
||||||
|
* file.</p>
|
||||||
|
* <p>HTTP proxy may be added in the future.</p>
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@GVK(group = "plugin.halo.run", kind = "ReverseProxy", version = "v1alpha1",
|
||||||
|
plural = "reverseproxies", singular = "reverseproxy")
|
||||||
|
public class ReverseProxy extends AbstractExtension {
|
||||||
|
private List<ReverseProxyRule> rules;
|
||||||
|
|
||||||
|
record ReverseProxyRule(String path, FileReverseProxyProvider file) {
|
||||||
|
}
|
||||||
|
|
||||||
|
record FileReverseProxyProvider(String directory, String filename) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,149 @@
|
||||||
|
package run.halo.app.plugin.resources;
|
||||||
|
|
||||||
|
import static org.springframework.http.MediaType.ALL;
|
||||||
|
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.stream.Collectors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.AntPathMatcher;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
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 run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.infra.utils.PathUtils;
|
||||||
|
import run.halo.app.plugin.PluginApplicationContext;
|
||||||
|
import run.halo.app.plugin.resources.ReverseProxy.FileReverseProxyProvider;
|
||||||
|
import run.halo.app.plugin.resources.ReverseProxy.ReverseProxyRule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Plugin's reverse proxy router factory.</p>
|
||||||
|
* <p>It creates a {@link RouterFunction} based on the ReverseProxy rule configured by
|
||||||
|
* the plugin.</p>
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ReverseProxyRouterFunctionFactory {
|
||||||
|
private static final String REVERSE_PROXY_API_PREFIX = "/assets";
|
||||||
|
|
||||||
|
public static final String REVERSE_PROXY_PLUGIN_LABEL_NAME = "plugin.halo.run/plugin-name";
|
||||||
|
|
||||||
|
private final ExtensionClient extensionClient;
|
||||||
|
|
||||||
|
public ReverseProxyRouterFunctionFactory(ExtensionClient extensionClient) {
|
||||||
|
this.extensionClient = extensionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Create {@link RouterFunction} according to the {@link ReverseProxy} custom resource
|
||||||
|
* configuration of the plugin.</p>
|
||||||
|
* <p>Note that: returns {@code Null} if the plugin does not have a {@link ReverseProxy} custom
|
||||||
|
* resource.</p>
|
||||||
|
*
|
||||||
|
* @param pluginApplicationContext plugin application context
|
||||||
|
* @return A reverse proxy RouterFunction handle(nullable)
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public RouterFunction<ServerResponse> create(
|
||||||
|
PluginApplicationContext pluginApplicationContext) {
|
||||||
|
String pluginId = pluginApplicationContext.getPluginId();
|
||||||
|
List<ReverseProxyRule> reverseProxyRules = getReverseProxyRules(pluginId);
|
||||||
|
return createReverseProxyRouterFunction(reverseProxyRules, pluginApplicationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouterFunction<ServerResponse> createReverseProxyRouterFunction(
|
||||||
|
List<ReverseProxyRule> rules, PluginApplicationContext pluginApplicationContext) {
|
||||||
|
Assert.notNull(rules, "The reverseProxyRules must not be null.");
|
||||||
|
Assert.notNull(pluginApplicationContext, "The pluginApplicationContext must not be null.");
|
||||||
|
|
||||||
|
String pluginId = pluginApplicationContext.getPluginId();
|
||||||
|
return rules.stream()
|
||||||
|
.map(rule -> {
|
||||||
|
String routePath = buildRoutePath(pluginId, rule);
|
||||||
|
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginId,
|
||||||
|
routePath);
|
||||||
|
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
|
||||||
|
request -> {
|
||||||
|
Resource resource =
|
||||||
|
loadResourceByFileRule(pluginApplicationContext, rule, request);
|
||||||
|
if (!resource.exists()) {
|
||||||
|
return ServerResponse.notFound().build();
|
||||||
|
}
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.bodyValue(resource);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.reduce(RouterFunction::and)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReverseProxyRule> getReverseProxyRules(String pluginId) {
|
||||||
|
return extensionClient.list(ReverseProxy.class,
|
||||||
|
reverseProxy -> {
|
||||||
|
String pluginName = reverseProxy.getMetadata()
|
||||||
|
.getLabels()
|
||||||
|
.get(REVERSE_PROXY_PLUGIN_LABEL_NAME);
|
||||||
|
return pluginId.equals(pluginName);
|
||||||
|
},
|
||||||
|
null)
|
||||||
|
.stream()
|
||||||
|
.map(ReverseProxy::getRules)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRoutePath(String pluginId, ReverseProxyRule reverseProxyRule) {
|
||||||
|
return PathUtils.combinePath(REVERSE_PROXY_API_PREFIX, pluginId, reverseProxyRule.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>File load rule: if the directory is configured but the file name is not configured, it
|
||||||
|
* means access through wildcards. Otherwise, if only the file name is configured, this
|
||||||
|
* method only returns the file pointed to by the rule.</p>
|
||||||
|
* <p>You should only use {@link Resource#getInputStream()} to get resource content instead of
|
||||||
|
* {@link Resource#getFile()},the resource is loaded from the plugin jar file using a
|
||||||
|
* specific plugin class loader; if you use {@link Resource#getFile()}, you cannot get the
|
||||||
|
* file.</p>
|
||||||
|
* <p>Note that a returned Resource handle does not imply an existing resource; you need to
|
||||||
|
* invoke {@link Resource#exists()} to check for existence</p>
|
||||||
|
*
|
||||||
|
* @param pluginApplicationContext load file from plugin
|
||||||
|
* @param rule reverse proxy rule
|
||||||
|
* @param request client request
|
||||||
|
* @return a Resource handle for the specified resource location by the plugin(never null);
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private Resource loadResourceByFileRule(PluginApplicationContext pluginApplicationContext,
|
||||||
|
ReverseProxyRule rule, ServerRequest request) {
|
||||||
|
Assert.notNull(rule.file(), "File rule must not be null.");
|
||||||
|
FileReverseProxyProvider file = rule.file();
|
||||||
|
String directory = file.directory();
|
||||||
|
|
||||||
|
// Decision file name
|
||||||
|
String filename;
|
||||||
|
String configuredFilename = file.filename();
|
||||||
|
if (StringUtils.isNotBlank(configuredFilename)) {
|
||||||
|
filename = configuredFilename;
|
||||||
|
} else {
|
||||||
|
AntPathMatcher antPathMatcher = new AntPathMatcher();
|
||||||
|
String routePath = buildRoutePath(pluginApplicationContext.getPluginId(), rule);
|
||||||
|
filename =
|
||||||
|
antPathMatcher.extractPathWithinPattern(routePath, request.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
String filePath = PathUtils.appendPathSeparatorIfMissing(directory) + filename;
|
||||||
|
return pluginApplicationContext.getResource(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link PathUtils}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
class PathUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinePath() {
|
||||||
|
Map<String, String> combinePathCases = getCombinePathCases();
|
||||||
|
combinePathCases.forEach((segments, expected) -> {
|
||||||
|
String s = PathUtils.combinePath(segments.split(","));
|
||||||
|
assertThat(s).isEqualTo(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> getCombinePathCases() {
|
||||||
|
Map<String, String> combinePathCases = new HashMap<>();
|
||||||
|
combinePathCases.put("a,b,c", "/a/b/c");
|
||||||
|
combinePathCases.put("/a,b,c", "/a/b/c");
|
||||||
|
combinePathCases.put("/a,b/,c", "/a/b/c");
|
||||||
|
combinePathCases.put("/a,/b/,c", "/a/b/c");
|
||||||
|
return combinePathCases;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appendPathSeparatorIfMissing() {
|
||||||
|
String s = PathUtils.appendPathSeparatorIfMissing("a");
|
||||||
|
assertThat(s).isEqualTo("a/");
|
||||||
|
|
||||||
|
s = PathUtils.appendPathSeparatorIfMissing("a/");
|
||||||
|
assertThat(s).isEqualTo("a/");
|
||||||
|
|
||||||
|
s = PathUtils.appendPathSeparatorIfMissing(null);
|
||||||
|
assertThat(s).isEqualTo(null);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
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.pf4j.PluginWrapper;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.http.codec.ServerCodecConfigurer;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
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.function.server.support.RouterFunctionMapping;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||||
|
import run.halo.app.plugin.event.HaloPluginStoppedEvent;
|
||||||
|
import run.halo.app.plugin.resources.ReverseProxyRouterFunctionFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link PluginCompositeRouterFunction}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PluginCompositeRouterFunctionTest {
|
||||||
|
private final ServerCodecConfigurer codecConfigurer = ServerCodecConfigurer.create();
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PluginApplicationContext pluginApplicationContext;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PluginWrapper pluginWrapper;
|
||||||
|
|
||||||
|
private PluginCompositeRouterFunction compositeRouterFunction;
|
||||||
|
|
||||||
|
private HandlerFunction<ServerResponse> handlerFunction;
|
||||||
|
private RouterFunction<ServerResponse> routerFunction;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void setUp() {
|
||||||
|
compositeRouterFunction =
|
||||||
|
new PluginCompositeRouterFunction(reverseProxyRouterFunctionFactory);
|
||||||
|
|
||||||
|
ExtensionContextRegistry.getInstance().register("fakeA", pluginApplicationContext);
|
||||||
|
when(pluginWrapper.getPluginId()).thenReturn("fakeA");
|
||||||
|
|
||||||
|
handlerFunction = request -> ServerResponse.ok().build();
|
||||||
|
routerFunction = request -> Mono.just(handlerFunction);
|
||||||
|
|
||||||
|
ObjectProvider objectProvider = mock(ObjectProvider.class);
|
||||||
|
when(objectProvider.orderedStream()).thenReturn(Stream.of(routerFunction));
|
||||||
|
|
||||||
|
when(pluginApplicationContext.getBeanProvider(RouterFunction.class))
|
||||||
|
.thenReturn(objectProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void route() {
|
||||||
|
// trigger haloPluginStartedEvent
|
||||||
|
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
|
||||||
|
|
||||||
|
RouterFunctionMapping mapping = new RouterFunctionMapping(compositeRouterFunction);
|
||||||
|
mapping.setMessageReaders(this.codecConfigurer.getReaders());
|
||||||
|
|
||||||
|
Mono<Object> result = mapping.getHandler(createExchange("https://example.com/match"));
|
||||||
|
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(handlerFunction)
|
||||||
|
.expectComplete()
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onPluginStarted() {
|
||||||
|
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isNull();
|
||||||
|
|
||||||
|
// trigger haloPluginStartedEvent
|
||||||
|
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
|
||||||
|
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isEqualTo(routerFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onPluginStopped() {
|
||||||
|
// trigger haloPluginStartedEvent
|
||||||
|
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
|
||||||
|
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isEqualTo(routerFunction);
|
||||||
|
|
||||||
|
// trigger HaloPluginStoppedEvent
|
||||||
|
compositeRouterFunction.onPluginStopped(new HaloPluginStoppedEvent(this, pluginWrapper));
|
||||||
|
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerWebExchange createExchange(String urlTemplate) {
|
||||||
|
return MockServerWebExchange.from(MockServerHttpRequest.get(urlTemplate));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
package run.halo.app.plugin.resources;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
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.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.plugin.PluginApplicationContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ReverseProxyRouterFunctionFactory}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ReverseProxyRouterFunctionFactoryTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ExtensionClient extensionClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PluginApplicationContext pluginApplicationContext;
|
||||||
|
|
||||||
|
private ReverseProxyRouterFunctionFactory reverseProxyRouterFunctionFactory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
reverseProxyRouterFunctionFactory = new ReverseProxyRouterFunctionFactory(extensionClient);
|
||||||
|
|
||||||
|
ReverseProxy reverseProxy = mockReverseProxy();
|
||||||
|
|
||||||
|
when(pluginApplicationContext.getPluginId()).thenReturn("fakeA");
|
||||||
|
when(extensionClient.list(eq(ReverseProxy.class), any(), any())).thenReturn(
|
||||||
|
List.of(reverseProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create() {
|
||||||
|
RouterFunction<ServerResponse> routerFunction =
|
||||||
|
reverseProxyRouterFunctionFactory.create(pluginApplicationContext);
|
||||||
|
assertThat(routerFunction).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private ReverseProxy mockReverseProxy() {
|
||||||
|
ReverseProxy.ReverseProxyRule reverseProxyRule =
|
||||||
|
new ReverseProxy.ReverseProxyRule("/static/**",
|
||||||
|
new ReverseProxy.FileReverseProxyProvider("static", ""));
|
||||||
|
ReverseProxy reverseProxy = new ReverseProxy();
|
||||||
|
Metadata metadata = new Metadata();
|
||||||
|
metadata.setLabels(
|
||||||
|
Map.of(ReverseProxyRouterFunctionFactory.REVERSE_PROXY_PLUGIN_LABEL_NAME, "fakeA"));
|
||||||
|
reverseProxy.setMetadata(metadata);
|
||||||
|
reverseProxy.setRules(List.of(reverseProxyRule));
|
||||||
|
return reverseProxy;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue