mirror of https://github.com/halo-dev/halo
feat: add plugin setting support (#2238)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /area core /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind improvement 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 新增插件设置,允许通过 Setting 自定义模型生成表单并收集其值为 ConfigMap 插件可以通过注入 SettingFetcher 来获取值并使用它,例如: ```java Sns sns = settingFetcher.getGroupForObject("sns", Sns.class); ``` #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> Fixes # #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2241/head
parent
abe29c12bf
commit
de493ccb2c
|
@ -64,6 +64,10 @@ public class Plugin extends AbstractExtension {
|
|||
private Boolean enabled = false;
|
||||
|
||||
private List<String> extensionLocations;
|
||||
|
||||
private String settingName;
|
||||
|
||||
private String configMapName;
|
||||
}
|
||||
|
||||
@Getter
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* {@link Setting} is a custom extension to generate forms based on configuration.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = "", version = "v1alpha1", kind = "Setting",
|
||||
plural = "settings", singular = "setting")
|
||||
public class Setting extends AbstractExtension {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private List<SettingSpec> spec;
|
||||
|
||||
@Data
|
||||
public static class SettingSpec {
|
||||
|
||||
@Schema(required = true)
|
||||
private String group;
|
||||
|
||||
private String label;
|
||||
|
||||
private List<Object> formSchema;
|
||||
}
|
||||
}
|
|
@ -8,7 +8,9 @@ import run.halo.app.core.extension.Plugin;
|
|||
import run.halo.app.core.extension.ReverseProxy;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.security.authentication.pat.PersonalAccessToken;
|
||||
|
||||
|
@ -29,5 +31,7 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
|||
schemeManager.register(RoleBinding.class);
|
||||
schemeManager.register(User.class);
|
||||
schemeManager.register(ReverseProxy.class);
|
||||
schemeManager.register(Setting.class);
|
||||
schemeManager.register(ConfigMap.class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import org.springframework.core.io.DefaultResourceLoader;
|
|||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StopWatch;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
|
||||
/**
|
||||
* Plugin application initializer will create plugin application context by plugin id and
|
||||
|
@ -65,6 +66,8 @@ public class PluginApplicationInitializer {
|
|||
AnnotationConfigUtils.registerAnnotationConfigProcessors(beanFactory);
|
||||
stopWatch.stop();
|
||||
|
||||
populateSettingFetcher(pluginId, beanFactory);
|
||||
|
||||
log.debug("Total millis: {} ms -> {}", stopWatch.getTotalTimeMillis(),
|
||||
stopWatch.prettyPrint());
|
||||
|
||||
|
@ -103,6 +106,14 @@ public class PluginApplicationInitializer {
|
|||
stopWatch.getTotalTimeMillis(), stopWatch.prettyPrint());
|
||||
}
|
||||
|
||||
private void populateSettingFetcher(String pluginName,
|
||||
DefaultListableBeanFactory listableBeanFactory) {
|
||||
ExtensionClient extensionClient =
|
||||
getRootApplicationContext().getBean(ExtensionClient.class);
|
||||
SettingFetcher settingFetcher = new SettingFetcher(pluginName, extensionClient);
|
||||
listableBeanFactory.registerSingleton("settingFetcher", settingFetcher);
|
||||
}
|
||||
|
||||
public void onStartUp(String pluginId) {
|
||||
initApplicationContext(pluginId);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonParseException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* <p>A value fetcher for pPlugin form configuration.</p>
|
||||
* <p>The method of obtaining values is lazy,only when it is used for the first time can the real
|
||||
* query value be obtained from {@link ConfigMap}.</p>
|
||||
* <p>It is thread safe.</p>
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class SettingFetcher {
|
||||
|
||||
private static final String PLUGIN_SETTING_VALUE = "setting";
|
||||
|
||||
private final AtomicReference<JsonNode> valueRef = new AtomicReference<>(null);
|
||||
|
||||
private final ExtensionClient extensionClient;
|
||||
|
||||
private final String pluginName;
|
||||
|
||||
public SettingFetcher(String pluginName,
|
||||
ExtensionClient extensionClient) {
|
||||
this.extensionClient = extensionClient;
|
||||
this.pluginName = pluginName;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public <T> T getGroupForObject(String group, Class<T> clazz) {
|
||||
return convertValue(getInternal(group), clazz);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public JsonNode getGroup(String group) {
|
||||
return getInternal(group);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public JsonNode getValues() {
|
||||
return valueRef.updateAndGet(m -> m != null ? m : getValuesInternal());
|
||||
}
|
||||
|
||||
private JsonNode getInternal(String group) {
|
||||
return Optional.ofNullable(getValues().get(group))
|
||||
.orElse(JsonNodeFactory.instance.missingNode());
|
||||
}
|
||||
|
||||
private JsonNode getValuesInternal() {
|
||||
return configMap(pluginName)
|
||||
.filter(configMap -> configMap.getData() != null
|
||||
&& configMap.getData().containsKey(PLUGIN_SETTING_VALUE))
|
||||
.map(configMap -> configMap.getData().get(PLUGIN_SETTING_VALUE))
|
||||
.map(this::readTree)
|
||||
.orElse(JsonNodeFactory.instance.missingNode());
|
||||
}
|
||||
|
||||
private Optional<ConfigMap> configMap(String pluginName) {
|
||||
return extensionClient.fetch(Plugin.class, pluginName)
|
||||
.flatMap(plugin -> {
|
||||
String configMapName = plugin.getSpec().getConfigMapName();
|
||||
if (StringUtils.isBlank(configMapName)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return extensionClient.fetch(ConfigMap.class, plugin.getSpec().getConfigMapName());
|
||||
});
|
||||
}
|
||||
|
||||
private JsonNode readTree(String json) {
|
||||
try {
|
||||
return JsonUtils.DEFAULT_JSON_MAPPER.readTree(json);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T convertValue(JsonNode jsonNode, Class<T> clazz) {
|
||||
return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.json.JSONException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.skyscreamer.jsonassert.JSONAssert;
|
||||
import org.springframework.security.util.InMemoryResource;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
|
||||
/**
|
||||
* Tests for {@link Setting}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class SettingTest {
|
||||
|
||||
@Test
|
||||
void setting() throws JSONException {
|
||||
String settingYaml = """
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: setting-name
|
||||
spec:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $el: h1
|
||||
children: Register
|
||||
- $formkit: text
|
||||
help: This will be used for your account.
|
||||
label: Email
|
||||
name: email
|
||||
validation: required|email
|
||||
- group: sns
|
||||
label: 社交资料
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
help: This will be used for your theme.
|
||||
label: color
|
||||
name: color
|
||||
validation: required
|
||||
""";
|
||||
List<Unstructured> unstructureds =
|
||||
new YamlUnstructuredLoader(new InMemoryResource(settingYaml)).load();
|
||||
assertThat(unstructureds).hasSize(1);
|
||||
Unstructured unstructured = unstructureds.get(0);
|
||||
|
||||
Setting setting = Unstructured.OBJECT_MAPPER.convertValue(unstructured, Setting.class);
|
||||
assertThat(setting).isNotNull();
|
||||
JSONAssert.assertEquals("""
|
||||
{
|
||||
"spec": [
|
||||
{
|
||||
"group": "basic",
|
||||
"label": "基本设置",
|
||||
"formSchema": [
|
||||
{
|
||||
"$el": "h1",
|
||||
"children": "Register"
|
||||
},
|
||||
{
|
||||
"$formkit": "text",
|
||||
"help": "This will be used for your account.",
|
||||
"label": "Email",
|
||||
"name": "email",
|
||||
"validation": "required|email"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "sns",
|
||||
"label": "社交资料",
|
||||
"formSchema": [
|
||||
{
|
||||
"$formkit": "text",
|
||||
"help": "This will be used for your theme.",
|
||||
"label": "color",
|
||||
"name": "color",
|
||||
"validation": "required"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"apiVersion": "v1alpha1",
|
||||
"kind": "Setting",
|
||||
"metadata": {
|
||||
"name": "setting-name",
|
||||
"labels": null,
|
||||
"annotations": null,
|
||||
"version": null,
|
||||
"creationTimestamp": null,
|
||||
"deletionTimestamp": null
|
||||
}
|
||||
}
|
||||
""",
|
||||
JsonUtils.objectToJson(setting), false);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
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.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.json.JSONException;
|
||||
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.skyscreamer.jsonassert.JSONAssert;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link SettingFetcher}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SettingFetcherTest {
|
||||
|
||||
@Mock
|
||||
private ExtensionClient extensionClient;
|
||||
|
||||
private SettingFetcher settingFetcher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
settingFetcher = new SettingFetcher("fake", extensionClient);
|
||||
// do not call extensionClient when the settingFetcher first time created
|
||||
verify(extensionClient, times(0)).fetch(eq(ConfigMap.class), any());
|
||||
verify(extensionClient, times(0)).fetch(eq(Plugin.class), any());
|
||||
|
||||
Plugin plugin = buildPlugin();
|
||||
when(extensionClient.fetch(eq(Plugin.class), any())).thenReturn(Optional.of(plugin));
|
||||
|
||||
ConfigMap configMap = buildConfigMap();
|
||||
when(extensionClient.fetch(eq(ConfigMap.class), any())).thenReturn(Optional.of(configMap));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getValues() throws JSONException {
|
||||
JsonNode values = settingFetcher.getValues();
|
||||
|
||||
verify(extensionClient, times(1)).fetch(eq(ConfigMap.class), any());
|
||||
|
||||
assertThat(values).hasSize(2);
|
||||
JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(values.get("sns")), true);
|
||||
|
||||
// The extensionClient will only be called once
|
||||
JsonNode callAgain = settingFetcher.getValues();
|
||||
assertThat(callAgain).isNotNull();
|
||||
verify(extensionClient, times(1)).fetch(eq(ConfigMap.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroupForObject() throws JSONException {
|
||||
Sns sns = settingFetcher.getGroupForObject("sns", Sns.class);
|
||||
assertThat(sns).isNotNull();
|
||||
JSONAssert.assertEquals(getSns(), JsonUtils.objectToJson(sns), true);
|
||||
|
||||
Sns missing = settingFetcher.getGroupForObject("sns1", Sns.class);
|
||||
assertThat(missing).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroup() {
|
||||
JsonNode jsonNode = settingFetcher.getGroup("basic");
|
||||
assertThat(jsonNode).isNotNull();
|
||||
assertThat(jsonNode.isObject()).isTrue();
|
||||
assertThat(jsonNode.get("color").asText()).isEqualTo("red");
|
||||
assertThat(jsonNode.get("width").asInt()).isEqualTo(100);
|
||||
|
||||
// missing key will return empty json node
|
||||
JsonNode emptyNode = settingFetcher.getGroup("basic1");
|
||||
assertThat(emptyNode.isEmpty()).isTrue();
|
||||
}
|
||||
|
||||
private ConfigMap buildConfigMap() {
|
||||
ConfigMap configMap = new ConfigMap();
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("fake");
|
||||
metadata.setLabels(Map.of("plugin.halo.run/plugin-name", "fake"));
|
||||
configMap.setMetadata(metadata);
|
||||
configMap.setKind("ConfigMap");
|
||||
configMap.setApiVersion("v1alpha1");
|
||||
configMap.setData(Map.of("setting", String.format("""
|
||||
{
|
||||
"sns": %s,
|
||||
"basic": {
|
||||
"color": "red",
|
||||
"width": "100"
|
||||
}
|
||||
}
|
||||
""", getSns())));
|
||||
return configMap;
|
||||
}
|
||||
|
||||
private Plugin buildPlugin() {
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setKind("Plugin");
|
||||
plugin.setApiVersion("plugin.halo.run/v1alpha1");
|
||||
|
||||
Metadata pluginMetadata = new Metadata();
|
||||
pluginMetadata.setName("fakePlugin");
|
||||
plugin.setMetadata(pluginMetadata);
|
||||
|
||||
Plugin.PluginSpec pluginSpec = new Plugin.PluginSpec();
|
||||
pluginSpec.setConfigMapName("fakeConfigMap");
|
||||
pluginSpec.setSettingName("fakeSetting");
|
||||
plugin.setSpec(pluginSpec);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
String getSns() {
|
||||
return """
|
||||
{
|
||||
"email": "example@example.com",
|
||||
"github": "example",
|
||||
"instagram": "123",
|
||||
"twitter": "halo-dev",
|
||||
"user": {
|
||||
"name": "guqing",
|
||||
"age": "18"
|
||||
},
|
||||
"nums": [1, 2, 3]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
record Sns(String email, String github, String instagram, String twitter,
|
||||
Map<String, Object> user, List<Integer> nums) {
|
||||
}
|
||||
}
|
|
@ -94,7 +94,9 @@ class YamlPluginFinderTest {
|
|||
"requires": ">=2.0.0",
|
||||
"pluginClass": "run.halo.app.plugin.BasePlugin",
|
||||
"enabled": false,
|
||||
"extensionLocations": null
|
||||
"extensionLocations": null,
|
||||
settingName: null,
|
||||
configMapName: null
|
||||
},
|
||||
"status": null,
|
||||
"apiVersion": "plugin.halo.run/v1alpha1",
|
||||
|
|
Loading…
Reference in New Issue