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
guqing 2022-07-12 22:57:08 +08:00 committed by GitHub
parent abe29c12bf
commit de493ccb2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 402 additions and 1 deletions

View File

@ -64,6 +64,10 @@ public class Plugin extends AbstractExtension {
private Boolean enabled = false;
private List<String> extensionLocations;
private String settingName;
private String configMapName;
}
@Getter

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",