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
guqing 2022-06-14 18:58:21 +08:00 committed by GitHub
parent a2f49c60bc
commit 6bbaa9aeba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 638 additions and 0 deletions

View File

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

View File

@ -24,6 +24,7 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
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.HaloPluginStateChangedEvent;
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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