feat: add request mapping handler mapping for plugin (#2161)

* feat: add requet mapping handler mapping for plugin

* fix: request info resolve

* fix: test code style

* refactor: plugin api version resolve

* fix: merge conflicts
pull/2175/head
guqing 2022-06-21 11:22:24 +08:00 committed by GitHub
parent 89eeccd99c
commit 7cd1282ad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 692 additions and 150 deletions

View File

@ -4,7 +4,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.apache.commons.lang3.StringUtils;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@ -32,29 +34,28 @@ public class Role extends AbstractExtension {
* @author guqing
* @since 2.0.0
*/
@Data
@Getter
public static class PolicyRule {
/**
* APIGroups is the name of the APIGroup that contains the resources.
* If multiple API groups are specified, any action requested against one of the enumerated
* resources in any API group will be allowed.
*/
String[] apiGroups;
final String[] apiGroups;
/**
* Resources is a list of resources this rule applies to. '*' represents all resources in
* the specified apiGroups.
* '*/foo' represents the subresource 'foo' for all resources in
* the specified apiGroups.
* '*/foo' represents the subresource 'foo' for all resources in the specified
* apiGroups.
*/
String[] resources;
final String[] resources;
/**
* ResourceNames is an optional white list of names that the rule applies to. An empty set
* means that everything is allowed.
*/
String[] resourceNames;
final String[] resourceNames;
/**
* NonResourceURLs is a set of partial urls that a user should have access to.
@ -66,19 +67,26 @@ public class Role extends AbstractExtension {
* Rules can either apply to API resources (such as "pods" or "secrets") or non-resource
* URL paths (such as "/api"), but not both.
*/
String[] nonResourceURLs;
final String[] nonResourceURLs;
/**
* about who the rule applies to or which namespace the rule applies to.
*/
String[] verbs;
final String[] verbs;
/**
* If the plugin name exists, it means that the API is provided by the plugin.
*/
final String pluginName;
public PolicyRule() {
this(null, null, null, null, null);
this(null, null, null, null, null, null);
}
public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames,
public PolicyRule(String pluginName, String[] apiGroups, String[] resources,
String[] resourceNames,
String[] nonResourceURLs, String[] verbs) {
this.pluginName = StringUtils.defaultString(pluginName);
this.apiGroups = nullElseEmpty(apiGroups);
this.resources = nullElseEmpty(resources);
this.resourceNames = nullElseEmpty(resourceNames);
@ -95,11 +103,22 @@ public class Role extends AbstractExtension {
public static class Builder {
String[] apiGroups;
String[] resources;
String[] resourceNames;
String[] nonResourceURLs;
String[] verbs;
String pluginName;
public Builder pluginName(String pluginName) {
this.pluginName = pluginName;
return this;
}
public Builder apiGroups(String... apiGroups) {
this.apiGroups = apiGroups;
return this;
@ -126,7 +145,9 @@ public class Role extends AbstractExtension {
}
public PolicyRule build() {
return new PolicyRule(apiGroups, resources, resourceNames, nonResourceURLs, verbs);
return new PolicyRule(pluginName, apiGroups, resources, resourceNames,
nonResourceURLs,
verbs);
}
}
}

View File

@ -0,0 +1,26 @@
package run.halo.app.plugin;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Api version.
*
* @author guqing
* @since 2.0.0
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiVersion {
/**
* Api version value.
*
* @return api version string
*/
String value();
}

View File

@ -183,7 +183,7 @@ public class HaloPluginManager extends DefaultPluginManager
pluginWrapper.getPlugin().start();
requestMappingManager.registerControllers(pluginWrapper);
requestMappingManager.registerHandlerMappings(pluginWrapper);
pluginWrapper.setPluginState(PluginState.STARTED);
pluginWrapper.setFailedException(null);
@ -259,7 +259,7 @@ public class HaloPluginManager extends DefaultPluginManager
// create plugin instance and start it
pluginWrapper.getPlugin().start();
requestMappingManager.registerControllers(pluginWrapper);
requestMappingManager.registerHandlerMappings(pluginWrapper);
pluginWrapper.setPluginState(PluginState.STARTED);
startedPlugins.add(pluginWrapper);
@ -358,7 +358,7 @@ public class HaloPluginManager extends DefaultPluginManager
*/
public void releaseAdditionalResources(String pluginId) {
// release request mapping
requestMappingManager.removeControllerMapping(pluginId);
requestMappingManager.removeHandlerMappings(pluginId);
try {
pluginApplicationInitializer.contextDestroyed(pluginId);
} catch (Exception e) {

View File

@ -14,11 +14,12 @@ import org.pf4j.PluginLoader;
import org.pf4j.PluginManager;
import org.pf4j.PluginStatusProvider;
import org.pf4j.RuntimeMode;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
/**
* Plugin autoconfiguration for Spring Boot.
@ -32,18 +33,26 @@ import org.springframework.web.reactive.result.method.annotation.RequestMappingH
public class PluginAutoConfiguration {
private final PluginProperties pluginProperties;
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
@Qualifier("webFluxContentTypeResolver")
private final RequestedContentTypeResolver requestedContentTypeResolver;
public PluginAutoConfiguration(PluginProperties pluginProperties,
RequestMappingHandlerMapping requestMappingHandlerMapping) {
RequestedContentTypeResolver requestedContentTypeResolver) {
this.pluginProperties = pluginProperties;
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
this.requestedContentTypeResolver = requestedContentTypeResolver;
}
@Bean
public PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping() {
PluginRequestMappingHandlerMapping mapping = new PluginRequestMappingHandlerMapping();
mapping.setContentTypeResolver(requestedContentTypeResolver);
mapping.setOrder(-1);
return mapping;
}
@Bean
public PluginRequestMappingManager pluginRequestMappingManager() {
return new PluginRequestMappingManager(requestMappingHandlerMapping);
return new PluginRequestMappingManager(pluginRequestMappingHandlerMapping());
}
@Bean

View File

@ -98,6 +98,9 @@ public class PluginCompositeRouterFunction implements RouterFunction<ServerRespo
@SuppressWarnings("unchecked")
private List<RouterFunction<ServerResponse>> routerFunctions(
PluginApplicationContext applicationContext) {
// TODO: Since the parent of the ApplicationContext of the plugin is RootApplicationContext
// obtaining the RouterFunction here will obtain the existing in the parent
// resulting in a loop when there is no matching route
List<RouterFunction<ServerResponse>> functions =
applicationContext.getBeanProvider(RouterFunction.class)
.orderedStream()

View File

@ -0,0 +1,126 @@
package run.halo.app.plugin;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.pf4j.PluginRuntimeException;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.MethodIntrospector;
import org.springframework.stereotype.Controller;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.reactive.result.method.RequestMappingInfo;
import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import run.halo.app.extension.GroupVersion;
/**
* An extension of {@link RequestMappingInfoHandlerMapping} that creates
* {@link RequestMappingInfo} instances from class-level and method-level
* {@link RequestMapping} annotations used by plugin.
*
* @author guqing
* @since 2.0.0
*/
public class PluginRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
private final MultiValueMap<String, RequestMappingInfo> pluginMappingInfo =
new LinkedMultiValueMap<>();
/**
* Register handler methods according to the plugin id and the controller(annotated
* {@link Controller}) bean.
*
* @param pluginId plugin id to be registered
* @param handler controller bean
*/
public void registerHandlerMethods(String pluginId, Object handler) {
Class<?> handlerType = (handler instanceof String beanName
? obtainApplicationContext().getType(beanName) : handler.getClass());
if (handlerType != null) {
final Class<?> userType = ClassUtils.getUserClass(handlerType);
Map<Method, RequestMappingInfo> methods = MethodIntrospector.selectMethods(userType,
(MethodIntrospector.MetadataLookup<RequestMappingInfo>)
method -> getPluginMappingForMethod(pluginId, method, userType));
if (logger.isTraceEnabled()) {
logger.trace(formatMappings(userType, methods));
} else if (mappingsLogger.isDebugEnabled()) {
mappingsLogger.debug(formatMappings(userType, methods));
}
methods.forEach((method, mapping) -> {
Method invocableMethod = AopUtils.selectInvocableMethod(method, userType);
registerHandlerMethod(handler, invocableMethod, mapping);
pluginMappingInfo.add(pluginId, mapping);
});
}
}
private String formatMappings(Class<?> userType, Map<Method, RequestMappingInfo> methods) {
String packageName = ClassUtils.getPackageName(userType);
String formattedType = (StringUtils.hasText(packageName)
? Arrays.stream(packageName.split("\\."))
.map(packageSegment -> packageSegment.substring(0, 1))
.collect(Collectors.joining(".", "", "." + userType.getSimpleName())) :
userType.getSimpleName());
Function<Method, String> methodFormatter =
method -> Arrays.stream(method.getParameterTypes())
.map(Class::getSimpleName)
.collect(Collectors.joining(",", "(", ")"));
return methods.entrySet().stream()
.map(e -> {
Method method = e.getKey();
return e.getValue() + ": " + method.getName() + methodFormatter.apply(method);
})
.collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", ""));
}
/**
* Remove handler methods and mapping based on plugin id.
*
* @param pluginId plugin id
*/
public void unregister(String pluginId) {
Assert.notNull(pluginId, "The pluginId must not be null.");
if (!pluginMappingInfo.containsKey(pluginId)) {
return;
}
pluginMappingInfo.remove(pluginId).forEach(this::unregisterMapping);
}
protected List<RequestMappingInfo> getMappings(String pluginId) {
List<RequestMappingInfo> requestMappingInfos = pluginMappingInfo.get(pluginId);
if (requestMappingInfos == null) {
return Collections.emptyList();
}
return List.copyOf(requestMappingInfos);
}
protected RequestMappingInfo getPluginMappingForMethod(String pluginId,
Method method, Class<?> handlerType) {
RequestMappingInfo info = super.getMappingForMethod(method, handlerType);
if (info != null) {
ApiVersion apiVersion = handlerType.getAnnotation(ApiVersion.class);
if (apiVersion == null) {
throw new PluginRuntimeException(
"The handler [" + handlerType + "] is missing @ApiVersion annotation.");
}
info = RequestMappingInfo.paths(buildPrefix(pluginId, apiVersion.value())).build()
.combine(info);
}
return info;
}
protected String buildPrefix(String pluginId, String version) {
GroupVersion groupVersion = GroupVersion.parseAPIVersion(version);
return String.format("/api/%s/plugins/%s", groupVersion.version(), pluginId);
}
}

View File

@ -1,13 +1,10 @@
package run.halo.app.plugin;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.PluginWrapper;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.stereotype.Controller;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
/**
@ -19,56 +16,25 @@ import org.springframework.web.reactive.result.method.annotation.RequestMappingH
@Slf4j
public class PluginRequestMappingManager {
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
private final PluginRequestMappingHandlerMapping requestMappingHandlerMapping;
public PluginRequestMappingManager(
RequestMappingHandlerMapping requestMappingHandlerMapping) {
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
PluginRequestMappingHandlerMapping pluginRequestMappingHandlerMapping) {
this.requestMappingHandlerMapping = pluginRequestMappingHandlerMapping;
}
public void registerControllers(PluginWrapper pluginWrapper) {
public void registerHandlerMappings(PluginWrapper pluginWrapper) {
String pluginId = pluginWrapper.getPluginId();
getControllerBeans(pluginId)
.forEach(this::registerController);
.forEach(handler ->
requestMappingHandlerMapping.registerHandlerMethods(pluginId, handler));
}
private void registerController(Object controller) {
log.debug("Registering plugin request mapping for bean: [{}]", controller);
Method detectHandlerMethods = ReflectionUtils.findMethod(RequestMappingHandlerMapping.class,
"detectHandlerMethods", Object.class);
if (detectHandlerMethods == null) {
return;
}
try {
detectHandlerMethods.setAccessible(true);
detectHandlerMethods.invoke(requestMappingHandlerMapping, controller);
} catch (IllegalStateException ise) {
// ignore this
log.warn(ise.getMessage());
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
} catch (InvocationTargetException e) {
log.warn("invocation target exception: [{}]", e.getMessage(), e);
}
public void removeHandlerMappings(String pluginId) {
requestMappingHandlerMapping.unregister(pluginId);
}
private void unregisterControllerMappingInternal(Object controller) {
requestMappingHandlerMapping.getHandlerMethods()
.forEach((mapping, handlerMethod) -> {
if (controller == handlerMethod.getBean()) {
log.debug("Removed plugin request mapping [{}] from bean [{}]", mapping,
controller);
requestMappingHandlerMapping.unregisterMapping(mapping);
}
});
}
public void removeControllerMapping(String pluginId) {
getControllerBeans(pluginId)
.forEach(this::unregisterControllerMappingInternal);
}
public Collection<Object> getControllerBeans(String pluginId) {
private Collection<Object> getControllerBeans(String pluginId) {
GenericApplicationContext pluginContext =
ExtensionContextRegistry.getInstance().getByPluginId(pluginId);
return pluginContext.getBeansWithAnnotation(Controller.class).values();

View File

@ -65,4 +65,6 @@ public interface Attributes {
* @return returns the path of the request
*/
String getPath();
String pluginName();
}

View File

@ -67,4 +67,9 @@ public class AttributesRecord implements Attributes {
public String getPath() {
return requestInfo.getPath();
}
@Override
public String pluginName() {
return requestInfo.getPluginName();
}
}

View File

@ -5,6 +5,7 @@ import java.util.Objects;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.Role.PolicyRule;
/**
* @author guqing
@ -38,7 +39,8 @@ public class RbacRequestEvaluation {
return verbMatches(rule, requestAttributes.getVerb())
&& apiGroupMatches(rule, requestAttributes.getApiGroup())
&& resourceMatches(rule, combinedResource, requestAttributes.getSubresource())
&& resourceNameMatches(rule, requestAttributes.getName());
&& resourceNameMatches(rule, requestAttributes.getName())
&& pluginNameMatches(rule, requestAttributes.pluginName());
}
return verbMatches(rule, requestAttributes.getVerb())
&& nonResourceURLMatches(rule, requestAttributes.getPath());
@ -123,4 +125,8 @@ public class RbacRequestEvaluation {
}
return false;
}
protected boolean pluginNameMatches(PolicyRule rule, String pluginName) {
return StringUtils.equals(rule.getPluginName(), pluginName);
}
}

View File

@ -4,8 +4,11 @@ import java.util.Objects;
import lombok.Getter;
import lombok.ToString;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* RequestInfo holds information parsed from the {@link ServerHttpRequest}.
*
* @author guqing
* @since 2.0.0
*/
@ -14,7 +17,7 @@ import org.apache.commons.lang3.StringUtils;
public class RequestInfo {
boolean isResourceRequest;
final String path;
String namespace;
String pluginName;
String verb;
String apiPrefix;
String apiGroup;
@ -28,14 +31,14 @@ public class RequestInfo {
this(isResourceRequest, path, null, verb, null, null, null, null, null, null, null);
}
public RequestInfo(boolean isResourceRequest, String path, String namespace, String verb,
public RequestInfo(boolean isResourceRequest, String path, String pluginName, String verb,
String apiPrefix,
String apiGroup,
String apiVersion, String resource, String subresource, String name,
String[] parts) {
this.isResourceRequest = isResourceRequest;
this.path = StringUtils.defaultString(path, "");
this.namespace = StringUtils.defaultString(namespace, "");
this.pluginName = StringUtils.defaultString(pluginName, "");
this.verb = StringUtils.defaultString(verb, "");
this.apiPrefix = StringUtils.defaultString(apiPrefix, "");
this.apiGroup = StringUtils.defaultString(apiGroup, "");

View File

@ -8,6 +8,8 @@ import org.springframework.http.server.PathContainer;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* Creates {@link RequestInfo} from {@link ServerHttpRequest}.
*
* @author guqing
* @since 2.0.0
*/
@ -25,6 +27,9 @@ public class RequestInfoFactory {
*/
final Set<String> grouplessApiPrefixes;
/**
* special verbs no subresources.
*/
final Set<String> specialVerbs;
public RequestInfoFactory(Set<String> apiPrefixes, Set<String> grouplessApiPrefixes) {
@ -39,39 +44,35 @@ public class RequestInfoFactory {
}
/**
* newRequestInfo returns the information from the http request. If error is not occurred,
* <p>newRequestInfo returns the information from the http request. If error is not occurred,
* RequestInfo holds the information as best it is known before the failure
* It handles both resource and non-resource requests and fills in all the pertinent
* information
* for each.
* <p>
* information.</p>
* <p>for each.</p>
* Valid Inputs:
* <p>
* Resource paths
* <p>Resource paths</p>
* <pre>
* /apis/{api-group}/{version}/namespaces
* /api/{version}/namespaces
* /api/{version}/namespaces/{namespace}
* /api/{version}/namespaces/{namespace}/{resource}
* /api/{version}/namespaces/{namespace}/{resource}/{resourceName}
* /api/{version}/plugins
* /api/{version}/plugins/{pluginName}
* /api/{version}/plugins/{pluginName}/{resource}
* /api/{version}/plugins/{pluginName}/{resource}/{resourceName}
* /api/{version}/{resource}
* /api/{version}/{resource}/{resourceName}
* </pre>
*
* <p>Special verbs without subresources:</p>
* <pre>
* Special verbs without subresources:
* /api/{version}/proxy/{resource}/{resourceName}
* /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName}
* </pre>
*
* <p>Special verbs with subresources:</p>
* <pre>
* Special verbs with subresources:
* /api/{version}/watch/{resource}
* /api/{version}/watch/namespaces/{namespace}/{resource}
* </pre>
*
* <p>NonResource paths:</p>
* <pre>
* NonResource paths
* /apis/{api-group}/{version}
* /apis/{api-group}
* /apis
@ -137,29 +138,29 @@ public class RequestInfoFactory {
default -> "";
};
}
Set<String> namespaceSubresources = Set.of("status", "finalize");
// URL forms: /namespaces/{namespace}/{kind}/*, where parts are adjusted to be relative
// URL forms: /plugins/{plugin-name}/{kind}/*, where parts are adjusted to be relative
// to kind
if (Objects.equals(currentParts[0], "namespaces")) {
if (Objects.equals(currentParts[0], "plugins")
&& StringUtils.isEmpty(requestInfo.getApiGroup())) {
if (currentParts.length > 1) {
requestInfo.namespace = currentParts[1];
requestInfo.pluginName = currentParts[1];
// if there is another step after the namespace name and it is not a known
// namespace subresource
// if there is another step after the plugin name and it is not a known
// plugins subresource
// move currentParts to include it as a resource in its own right
if (currentParts.length > 2 && !namespaceSubresources.contains(currentParts[2])) {
if (currentParts.length > 2) {
currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length);
}
}
} else {
requestInfo.namespace = "";
requestInfo.pluginName = "";
}
// parsing successful, so we now know the proper value for .Parts
requestInfo.parts = currentParts;
Set<String> specialVerbsNoSubresources = Set.of("proxy");
// special verbs no subresources
// parts look like: resource/resourceName/subresource/other/stuff/we/don't/interpret
if (requestInfo.parts.length >= 3 && !specialVerbsNoSubresources.contains(
if (requestInfo.parts.length >= 3 && !specialVerbs.contains(
requestInfo.verb)) {
requestInfo.subresource = requestInfo.parts[2];
}
@ -195,7 +196,7 @@ public class RequestInfoFactory {
return "1".equals(requestParam) || "true".equals(requestParam);
}
public String[] splitPath(String path) {
private String[] splitPath(String path) {
path = StringUtils.strip(path, "/");
if (StringUtils.isEmpty(path)) {
return new String[] {};

View File

@ -42,10 +42,11 @@ class ExtensionConfigurationTest {
@BeforeEach
void setUp() {
// disable authorization
var rule = new Role.PolicyRule();
rule.setApiGroups(new String[] {"*"});
rule.setResources(new String[] {"*"});
rule.setVerbs(new String[] {"*"});
var rule = new Role.PolicyRule.Builder()
.apiGroups("*")
.resources("*")
.verbs("*")
.build();
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole(anyString())).thenReturn(role);

View File

@ -0,0 +1,301 @@
package run.halo.app.plugin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.get;
import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post;
import static org.springframework.web.bind.annotation.RequestMethod.GET;
import static org.springframework.web.bind.annotation.RequestMethod.HEAD;
import java.lang.reflect.Method;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.pf4j.PluginRuntimeException;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.stereotype.Controller;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.StaticWebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.result.method.RequestMappingInfo;
import org.springframework.web.server.MethodNotAllowedException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.pattern.PathPatternParser;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
/**
* Tests for {@link PluginRequestMappingHandlerMapping}.
*
* @author guqing
* @since 2.0.0
*/
class PluginRequestMappingHandlerMappingTest {
private final StaticWebApplicationContext wac = new StaticWebApplicationContext();
private PluginRequestMappingHandlerMapping handlerMapping;
@BeforeEach
public void setup() {
handlerMapping = new PluginRequestMappingHandlerMapping();
this.handlerMapping.setApplicationContext(wac);
}
@Test
public void shouldAddPathPrefixWhenExistingApiVersion() throws Exception {
Method method = UserController.class.getMethod("getUser");
RequestMappingInfo info =
this.handlerMapping.getPluginMappingForMethod("fakePlugin", method,
UserController.class);
assertThat(info).isNotNull();
assertThat(info.getPatternsCondition().getPatterns()).isEqualTo(
Collections.singleton(
new PathPatternParser().parse("/api/v1alpha1/plugins/fakePlugin/user/{id}")));
}
@Test
public void shouldFailWhenMissingApiVersion() throws Exception {
Method method = AppleMissingApiVersionController.class.getMethod("getName");
assertThatThrownBy(() ->
this.handlerMapping.getPluginMappingForMethod("fakePlugin", method,
AppleMissingApiVersionController.class)).isInstanceOf(PluginRuntimeException.class)
.hasMessage(
"The handler [class run.halo.app.plugin"
+ ".PluginRequestMappingHandlerMappingTest$AppleMissingApiVersionController] "
+ "is missing @ApiVersion annotation.");
}
@Test
void registerHandlerMethods() {
assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty();
UserController userController = mock(UserController.class);
handlerMapping.registerHandlerMethods("fakePlugin", userController);
List<RequestMappingInfo> mappings = handlerMapping.getMappings("fakePlugin");
assertThat(mappings).hasSize(1);
assertThat(mappings.get(0).toString()).isEqualTo(
"{GET /api/v1alpha1/plugins/fakePlugin/user/{id}}");
}
@Test
void unregister() {
UserController userController = mock(UserController.class);
// register handler methods first
handlerMapping.registerHandlerMethods("fakePlugin", userController);
assertThat(handlerMapping.getMappings("fakePlugin")).hasSize(1);
// unregister
handlerMapping.unregister("fakePlugin");
assertThat(handlerMapping.getMappings("fakePlugin")).isEmpty();
}
@Test
public void getHandlerDirectMatch() {
// register handler methods first
handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
// resolve an expected method from TestController
Method expected =
ResolvableMethod.on(TestController.class).annot(getMapping("/foo")).build();
// get handler by mock exchange
ServerWebExchange exchange =
MockServerWebExchange.from(get("/api/v1alpha1/plugins/fakePlugin/foo"));
HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
assertThat(hm).isNotNull();
assertThat(hm.getMethod()).isEqualTo(expected);
}
@Test
public void getHandlerBestMatch() {
// register handler methods first
handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
Method expected =
ResolvableMethod.on(TestController.class).annot(getMapping("/foo").params("p")).build();
String requestPath = "/api/v1alpha1/plugins/fakePlugin/foo?p=anything";
ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath));
HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
assertThat(hm).isNotNull();
assertThat(hm.getMethod()).isEqualTo(expected);
}
@Test
public void getHandlerRootPathMatch() {
// register handler methods first
handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
Method expected =
ResolvableMethod.on(TestController.class).annot(getMapping("")).build();
String requestPath = "/api/v1alpha1/plugins/fakePlugin";
ServerWebExchange exchange = MockServerWebExchange.from(get(requestPath));
HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block();
assertThat(hm).isNotNull();
assertThat(hm.getMethod()).isEqualTo(expected);
}
@Test
public void getHandlerRequestMethodNotAllowed() {
// register handler methods first
handlerMapping.registerHandlerMethods("fakePlugin", new TestController());
String requestPath = "/api/v1alpha1/plugins/fakePlugin/bar";
ServerWebExchange exchange = MockServerWebExchange.from(post(requestPath));
Mono<Object> mono = this.handlerMapping.getHandler(exchange);
assertError(mono, MethodNotAllowedException.class,
ex -> assertThat(ex.getSupportedMethods()).isEqualTo(
Set.of(HttpMethod.GET, HttpMethod.HEAD)));
}
@SuppressWarnings("unchecked")
private <T> void assertError(Mono<Object> mono, final Class<T> exceptionClass,
final Consumer<T> consumer) {
StepVerifier.create(mono)
.consumeErrorWith(error -> {
assertThat(error.getClass()).isEqualTo(exceptionClass);
consumer.accept((T) error);
})
.verify();
}
private RequestMappingPredicate getMapping(String... path) {
return new RequestMappingPredicate(path).method(GET).params();
}
public static class ResolvableMethod {
private final Class<?> objectClass;
private final List<Predicate<Method>> filters = new ArrayList<>(4);
public ResolvableMethod(Class<?> objectClass) {
this.objectClass = objectClass;
}
public static ResolvableMethod on(Class<?> objectClass) {
return new ResolvableMethod(objectClass);
}
public ResolvableMethod annot(Predicate<Method> predicate) {
filters.add(predicate);
return this;
}
public Method build() {
Set<Method> methods = MethodIntrospector.selectMethods(this.objectClass, this::isMatch);
Assert.state(!methods.isEmpty(), () -> "No matching method: " + this);
Assert.state(methods.size() == 1,
() -> "Multiple matching methods: " + this + formatMethods(methods));
return methods.iterator().next();
}
private String formatMethods(Set<Method> methods) {
return "\nMatched:\n" + methods.stream()
.map(Method::toGenericString).collect(Collectors.joining(",\n\t", "[\n\t", "\n]"));
}
private boolean isMatch(Method method) {
return this.filters.stream().allMatch(p -> p.test(method));
}
}
public static class RequestMappingPredicate implements Predicate<Method> {
private final String[] path;
private RequestMethod[] method = {};
private String[] params;
private RequestMappingPredicate(String... path) {
this.path = path;
}
public RequestMappingPredicate method(RequestMethod... methods) {
this.method = methods;
return this;
}
public RequestMappingPredicate params(String... params) {
this.params = params;
return this;
}
@Override
public boolean test(Method method) {
RequestMapping annot =
AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
return annot != null
&& Arrays.equals(this.path, annot.path())
&& Arrays.equals(this.method, annot.method())
&& (this.params == null || Arrays.equals(this.params, annot.params()));
}
}
@ApiVersion("v1alpha1")
@RestController
@RequestMapping("/user")
static class UserController {
@GetMapping("/{id}")
public Principal getUser() {
return mock(Principal.class);
}
}
@RestController
@RequestMapping("/apples")
static class AppleMissingApiVersionController {
@GetMapping
public String getName() {
return mock(String.class);
}
}
@ApiVersion("v1alpha1")
@Controller
@RequestMapping
static class TestController {
@GetMapping("/foo")
public void foo() {
}
@GetMapping(path = "/foo", params = "p")
public void fooParam() {
}
@RequestMapping(path = "/ba*", method = {GET, HEAD})
public void bar() {
}
@GetMapping("")
public void empty() {
}
}
}

View File

@ -18,7 +18,6 @@ import run.halo.app.core.extension.service.DefaultRoleBindingService;
* @author guqing
* @since 2.0.0
*/
// @ExtendWith(SpringExtension.class)
public class DefaultRoleBindingServiceTest {
private DefaultRoleBindingService roleBindingLister;

View File

@ -5,8 +5,10 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Role;
import run.halo.app.infra.utils.JsonUtils;
@ -25,21 +27,38 @@ class PolicyRuleTest {
}
@Test
public void constructPolicyRule() throws JsonProcessingException {
Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null);
public void constructPolicyRule() throws JsonProcessingException, JSONException {
Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null, null);
assertThat(policyRule).isNotNull();
JsonNode policyRuleJson = objectMapper.valueToTree(policyRule);
assertThat(policyRuleJson).isEqualTo(objectMapper.readTree("""
{"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]}
"""));
JSONAssert.assertEquals("""
{
"pluginName": "",
"apiGroups": [],
"resources": [],
"resourceNames": [],
"nonResourceURLs": [],
"verbs": []
}
""",
JsonUtils.objectToJson(policyRule),
true);
Role.PolicyRule policyByBuilder = new Role.PolicyRule.Builder().build();
JsonNode policyByBuilderJson = objectMapper.valueToTree(policyByBuilder);
assertThat(policyByBuilderJson).isEqualTo(objectMapper.readTree("""
{"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]}
"""));
JSONAssert.assertEquals("""
{
"pluginName": "",
"apiGroups": [],
"resources": [],
"resourceNames": [],
"nonResourceURLs": [],
"verbs": []
}
""",
JsonUtils.objectToJson(policyByBuilder),
true);
Role.PolicyRule policyNonNull = new Role.PolicyRule.Builder()
.pluginName("fakePluginName")
.apiGroups("group")
.resources("resource-1", "resource-2")
.resourceNames("resourceName")
@ -49,6 +68,7 @@ class PolicyRuleTest {
JsonNode expected = objectMapper.readTree("""
{
"pluginName": "fakePluginName",
"apiGroups": [
"group"
],

View File

@ -85,6 +85,67 @@ public class RequestInfoResolverTest {
});
}
@Test
void pluginsScopedAndPluginManage() {
List<SuccessCase> testCases =
List.of(new SuccessCase("DELETE", "/apis/extensions/v1/plugins/other/posts",
"delete", "apis", "extensions", "v1", "", "plugins", "posts", "other",
new String[] {"plugins", "other", "posts"}),
// api group identification
new SuccessCase("POST", "/apis/extensions/v1/plugins/other/posts", "create", "apis",
"extensions", "v1", "", "plugins", "posts", "other",
new String[] {"plugins", "other", "posts"}),
// api version identification
new SuccessCase("POST", "/apis/extensions/v1beta3/plugins/other/posts", "create",
"apis", "extensions", "v1beta3", "", "plugins", "posts", "other",
new String[] {"plugins", "other", "posts"}));
// 以 /apis 开头的 plugins 资源为 core 中管理插件使用的资源
for (SuccessCase successCase : testCases) {
var request =
method(HttpMethod.valueOf(successCase.method),
successCase.url).build();
RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
assertThat(requestInfo).isNotNull();
assertRequestInfoCase(successCase, requestInfo);
}
List<SuccessCase> pluginScopedCases =
List.of(new SuccessCase("DELETE", "/api/v1/plugins/other/posts",
"deletecollection", "api", "", "v1", "other", "posts", "", "",
new String[] {"posts"}),
// api group identification
new SuccessCase("POST", "/api/v1/plugins/other/posts", "create", "api",
"", "v1", "other", "posts", "", "", new String[] {"posts"}),
// api version identification
new SuccessCase("POST", "/api/v1beta3/plugins/other/posts", "create",
"api", "", "v1beta3", "other", "posts", "", "",
new String[] {"posts"}));
for (SuccessCase pluginScopedCase : pluginScopedCases) {
var request =
method(HttpMethod.valueOf(pluginScopedCase.method),
pluginScopedCase.url).build();
RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
assertThat(requestInfo).isNotNull();
assertRequestInfoCase(pluginScopedCase, requestInfo);
}
}
private void assertRequestInfoCase(SuccessCase pluginScopedCase, RequestInfo requestInfo) {
assertThat(requestInfo.getPluginName()).isEqualTo(pluginScopedCase.expectedPluginName);
assertThat(requestInfo.getVerb()).isEqualTo(pluginScopedCase.expectedVerb);
assertThat(requestInfo.getParts()).isEqualTo(pluginScopedCase.expectedParts);
assertThat(requestInfo.getApiGroup()).isEqualTo(pluginScopedCase.expectedAPIGroup);
assertThat(requestInfo.getResource()).isEqualTo(pluginScopedCase.expectedResource);
assertThat(requestInfo.getSubresource())
.isEqualTo(pluginScopedCase.expectedSubresource());
}
@Test
public void errorCaseTest() {
List<ErrorCases> errorCases = List.of(new ErrorCases("no resource path", "/"),
@ -113,6 +174,10 @@ public class RequestInfoResolverTest {
List<PolicyRule> rules = List.of(
new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get")
.build(),
new PolicyRule.Builder().pluginName("fakePlugin").apiGroups("").resources("posts")
.verbs("list", "get").build(),
new PolicyRule.Builder().pluginName("fakePlugin").apiGroups("")
.resources("posts/tags").verbs("list", "get").build(),
new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*").build(),
new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head")
.build());
@ -165,6 +230,11 @@ public class RequestInfoResolverTest {
new RequestResolveCase("/api/v1/posts", "DELETE", false),
new RequestResolveCase("/api/v1/posts/aName", "UPDATE", false),
// plugin resource
new RequestResolveCase("/api/v1/plugins/fakePlugin/posts", "GET", true),
new RequestResolveCase("/api/v1/plugins/fakePlugin/posts/some-name", "GET", true),
new RequestResolveCase("/api/v1/plugins/fakePlugin/posts/some-name/tags", "GET", true),
// group resource url
new RequestResolveCase("/apis/group/v1/posts", "GET", false),
@ -185,7 +255,7 @@ public class RequestInfoResolverTest {
public record SuccessCase(String method, String url, String expectedVerb,
String expectedAPIPrefix, String expectedAPIGroup,
String expectedAPIVersion, String expectedNamespace,
String expectedAPIVersion, String expectedPluginName,
String expectedResource, String expectedSubresource,
String expectedName, String[] expectedParts) {
}
@ -194,31 +264,31 @@ public class RequestInfoResolverTest {
List<SuccessCase> getTestRequestInfos() {
String namespaceAll = "*";
return List.of(
new SuccessCase("GET", "/api/v1/namespaces", "list", "api", "", "v1", "", "namespaces",
"", "", new String[] {"namespaces"}),
new SuccessCase("GET", "/api/v1/namespaces/other", "get", "api", "", "v1", "other",
"namespaces", "", "other", new String[] {"namespaces", "other"}),
new SuccessCase("GET", "/api/v1/plugins", "list", "api", "", "v1", "", "plugins",
"", "", new String[] {"plugins"}),
new SuccessCase("GET", "/api/v1/plugins/other", "get", "api", "", "v1", "other",
"plugins", "", "other", new String[] {"plugins", "other"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1",
new SuccessCase("GET", "/api/v1/plugins/other/posts", "list", "api", "", "v1",
"other", "posts", "", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
new SuccessCase("GET", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
"other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("HEAD", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
new SuccessCase("HEAD", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
"other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("GET", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts",
"", "", new String[] {"posts"}),
new SuccessCase("HEAD", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts",
"", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1",
new SuccessCase("GET", "/api/v1/plugins/other/posts/foo", "get", "api", "", "v1",
"other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1",
new SuccessCase("GET", "/api/v1/plugins/other/posts", "list", "api", "", "v1",
"other", "posts", "", "", new String[] {"posts"}),
// special verbs
new SuccessCase("GET", "/api/v1/proxy/namespaces/other/posts/foo", "proxy", "api", "",
new SuccessCase("GET", "/api/v1/proxy/plugins/other/posts/foo", "proxy", "api", "",
"v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("GET",
"/api/v1/proxy/namespaces/other/posts/foo/subpath/not/a/subresource", "proxy",
"/api/v1/proxy/plugins/other/posts/foo/subpath/not/a/subresource", "proxy",
"api", "", "v1", "other", "posts", "", "foo",
new String[] {"posts", "foo", "subpath", "not", "a", "subresource"}),
new SuccessCase("GET", "/api/v1/watch/posts", "watch", "api", "", "v1", namespaceAll,
@ -227,52 +297,35 @@ public class RequestInfoResolverTest {
namespaceAll, "posts", "", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/posts?watch=false", "list", "api", "", "v1",
namespaceAll, "posts", "", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/watch/namespaces/other/posts", "watch", "api", "", "v1",
new SuccessCase("GET", "/api/v1/watch/plugins/other/posts", "watch", "api", "", "v1",
"other", "posts", "", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=1", "watch", "api", "",
new SuccessCase("GET", "/api/v1/plugins/other/posts?watch=1", "watch", "api", "",
"v1", "other", "posts", "", "", new String[] {"posts"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=0", "list", "api", "",
new SuccessCase("GET", "/api/v1/plugins/other/posts?watch=0", "list", "api", "",
"v1", "other", "posts", "", "", new String[] {"posts"}),
// subresource identification
new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/status", "get", "api", "",
new SuccessCase("GET", "/api/v1/plugins/other/posts/foo/status", "get", "api", "",
"v1", "other", "posts", "status", "foo", new String[] {"posts", "foo", "status"}),
new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/proxy/subpath", "get", "api",
new SuccessCase("GET", "/api/v1/plugins/other/posts/foo/proxy/subpath", "get", "api",
"", "v1", "other", "posts", "proxy", "foo",
new String[] {"posts", "foo", "proxy", "subpath"}),
new SuccessCase("PUT", "/api/v1/namespaces/other/finalize", "update", "api", "", "v1",
"other", "namespaces", "finalize", "other",
new String[] {"namespaces", "other", "finalize"}),
new SuccessCase("PUT", "/api/v1/namespaces/other/status", "update", "api", "", "v1",
"other", "namespaces", "status", "other",
new String[] {"namespaces", "other", "status"}),
// verb identification
new SuccessCase("PATCH", "/api/v1/namespaces/other/posts/foo", "patch", "api", "", "v1",
new SuccessCase("PATCH", "/api/v1/plugins/other/posts/foo", "patch", "api", "", "v1",
"other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("DELETE", "/api/v1/namespaces/other/posts/foo", "delete", "api", "",
new SuccessCase("DELETE", "/api/v1/plugins/other/posts/foo", "delete", "api", "",
"v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}),
new SuccessCase("POST", "/api/v1/namespaces/other/posts", "create", "api", "", "v1",
new SuccessCase("POST", "/api/v1/plugins/other/posts", "create", "api", "", "v1",
"other", "posts", "", "", new String[] {"posts"}),
// deletecollection verb identification
new SuccessCase("DELETE", "/api/v1/nodes", "deletecollection", "api", "", "v1", "",
"nodes", "", "", new String[] {"nodes"}),
new SuccessCase("DELETE", "/api/v1/namespaces", "deletecollection", "api", "", "v1", "",
"namespaces", "", "", new String[] {"namespaces"}),
new SuccessCase("DELETE", "/api/v1/namespaces/other/posts", "deletecollection", "api",
"", "v1", "other", "posts", "", "", new String[] {"posts"}),
new SuccessCase("DELETE", "/apis/extensions/v1/namespaces/other/posts",
"deletecollection", "apis", "extensions", "v1", "other", "posts", "", "",
new String[] {"posts"}),
// api group identification
new SuccessCase("POST", "/apis/extensions/v1/namespaces/other/posts", "create", "apis",
"extensions", "v1", "other", "posts", "", "", new String[] {"posts"}),
// api version identification
new SuccessCase("POST", "/apis/extensions/v1beta3/namespaces/other/posts", "create",
"apis", "extensions", "v1beta3", "other", "posts", "", "", new String[] {"posts"}));
new SuccessCase("DELETE", "/api/v1/plugins", "deletecollection", "api", "", "v1", "",
"plugins", "", "", new String[] {"plugins"}),
new SuccessCase("DELETE", "/api/v1/plugins/other/posts", "deletecollection", "api",
"", "v1", "other", "posts", "", "", new String[] {"posts"}));
}
}