diff --git a/src/main/java/run/halo/app/core/extension/Plugin.java b/src/main/java/run/halo/app/core/extension/Plugin.java index ae9c84234..2ef5daad1 100644 --- a/src/main/java/run/halo/app/core/extension/Plugin.java +++ b/src/main/java/run/halo/app/core/extension/Plugin.java @@ -64,6 +64,10 @@ public class Plugin extends AbstractExtension { private Boolean enabled = false; private List extensionLocations; + + private String settingName; + + private String configMapName; } @Getter diff --git a/src/main/java/run/halo/app/core/extension/Setting.java b/src/main/java/run/halo/app/core/extension/Setting.java new file mode 100644 index 000000000..a7294c4e3 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Setting.java @@ -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 spec; + + @Data + public static class SettingSpec { + + @Schema(required = true) + private String group; + + private String label; + + private List formSchema; + } +} diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index 8fe37db24..db039987e 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -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 {}", 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); } diff --git a/src/main/java/run/halo/app/plugin/SettingFetcher.java b/src/main/java/run/halo/app/plugin/SettingFetcher.java new file mode 100644 index 000000000..219db6b79 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/SettingFetcher.java @@ -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; + +/** + *

A value fetcher for pPlugin form configuration.

+ *

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}.

+ *

It is thread safe.

+ * + * @author guqing + * @since 2.0.0 + */ +public class SettingFetcher { + + private static final String PLUGIN_SETTING_VALUE = "setting"; + + private final AtomicReference 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 getGroupForObject(String group, Class 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(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 convertValue(JsonNode jsonNode, Class clazz) { + return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz); + } +} diff --git a/src/test/java/run/halo/app/core/extension/SettingTest.java b/src/test/java/run/halo/app/core/extension/SettingTest.java new file mode 100644 index 000000000..3544fa9a3 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/SettingTest.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/plugin/SettingFetcherTest.java b/src/test/java/run/halo/app/plugin/SettingFetcherTest.java new file mode 100644 index 000000000..3b904e623 --- /dev/null +++ b/src/test/java/run/halo/app/plugin/SettingFetcherTest.java @@ -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 user, List nums) { + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java index 8f5f5bd1e..33ff0fd1b 100644 --- a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java +++ b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -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",