From 27775c9ac933f01e477343b8c74502c9e33c212d Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Mon, 19 Dec 2022 10:28:10 +0800 Subject: [PATCH] feat: add reset config API for theme and plugin (#2964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /kind api-change /area core #### What this PR does / why we need it: 为主题和插件提供重置设置项 API 此 PR 会重新读取配置对应的 Setting 资源,从其中读取默认值后更新到现有的 ConfigMap 中替换其 data see #2789 for more details #### Which issue(s) this PR fixes: Fixes #2789 #### Special notes for your reviewer: how to test it? 1. 在主题设置或插件设置配置一些设置项后保存 2. 执行重置配置 3. 配置恢复为了 Setting 中指定的默认值 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 为主题和插件提供重置设置项 API ``` --- .../extension/endpoint/PluginEndpoint.java | 44 ++++++++++++ .../extension/reconciler/ThemeReconciler.java | 33 +-------- .../core/extension/theme/SettingUtils.java | 48 +++++++++++++ .../core/extension/theme/ThemeEndpoint.java | 22 ++++++ .../core/extension/theme/ThemeService.java | 3 + .../extension/theme/ThemeServiceImpl.java | 25 +++++++ .../extensions/role-template-plugin.yaml | 3 + .../extensions/role-template-theme.yaml | 2 +- .../reconciler/ThemeReconcilerTest.java | 15 ---- .../extension/theme/SettingUtilsTest.java | 67 ++++++++++++++++++ .../extension/theme/ThemeEndpointTest.java | 9 +++ .../extension/theme/ThemeServiceImplTest.java | 68 +++++++++++++++++++ 12 files changed, 292 insertions(+), 47 deletions(-) create mode 100644 src/main/java/run/halo/app/core/extension/theme/SettingUtils.java create mode 100644 src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java index 96e1f3d51..80f574d4a 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -25,11 +25,13 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import lombok.extern.slf4j.Slf4j; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; @@ -49,7 +51,10 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; +import run.halo.app.core.extension.Setting; +import run.halo.app.core.extension.theme.SettingUtils; import run.halo.app.extension.Comparators; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest.QueryListRequest; import run.halo.app.infra.utils.FileUtils; @@ -95,6 +100,19 @@ public class PluginEndpoint implements CustomEndpoint { .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(InstallRequest.class)))) ) + .PUT("plugins/{name}/reset-config", this::resetSettingConfig, + builder -> builder.operationId("ResetPluginConfig") + .description("Reset the configMap of plugin setting.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ConfigMap.class)) + ) .GET("plugins", this::list, builder -> { builder.operationId("ListPlugins") .tag(tag) @@ -105,6 +123,32 @@ public class PluginEndpoint implements CustomEndpoint { .build(); } + private Mono resetSettingConfig(ServerRequest request) { + String name = request.pathVariable("name"); + return client.fetch(Plugin.class, name) + .filter(plugin -> StringUtils.hasText(plugin.getSpec().getSettingName())) + .flatMap(plugin -> { + String configMapName = plugin.getSpec().getConfigMapName(); + String settingName = plugin.getSpec().getSettingName(); + return client.fetch(Setting.class, settingName) + .map(SettingUtils::settingDefinedDefaultValueMap) + .flatMap(data -> updateConfigMapData(configMapName, data)); + }) + .flatMap(configMap -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(configMap)); + } + + private Mono updateConfigMapData(String configMapName, Map data) { + return client.fetch(ConfigMap.class, configMapName) + .flatMap(configMap -> { + configMap.setData(data); + return client.update(configMap); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof OptimisticLockingFailureException)); + } + private Mono upgrade(ServerRequest request) { var pluginNameInPath = request.pathVariable("name"); var tempDirRef = new AtomicReference(); diff --git a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java index c503dc6bc..28f3e6e98 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java @@ -1,19 +1,15 @@ package run.halo.app.core.extension.reconciler; -import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.FileSystemUtils; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; +import run.halo.app.core.extension.theme.SettingUtils; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; @@ -106,7 +102,7 @@ public class ThemeReconciler implements Reconciler { client.fetch(Setting.class, theme.getSpec().getSettingName()) .ifPresent(setting -> { - Map data = settingDefinedDefaultValueMap(setting); + var data = SettingUtils.settingDefinedDefaultValueMap(setting); if (CollectionUtils.isEmpty(data)) { return; } @@ -118,31 +114,6 @@ public class ThemeReconciler implements Reconciler { }); } - Map settingDefinedDefaultValueMap(Setting setting) { - final String defaultValueField = "value"; - final String nameField = "name"; - List forms = setting.getSpec().getForms(); - if (CollectionUtils.isEmpty(forms)) { - return null; - } - Map data = new LinkedHashMap<>(); - for (Setting.SettingForm form : forms) { - String group = form.getGroup(); - Map groupValue = form.getFormSchema().stream() - .map(o -> JsonUtils.DEFAULT_JSON_MAPPER.convertValue(o, JsonNode.class)) - .filter(jsonNode -> jsonNode.isObject() && jsonNode.has(nameField) - && jsonNode.has(defaultValueField)) - .map(jsonNode -> { - String name = jsonNode.findValue(nameField).asText(); - JsonNode value = jsonNode.findValue(defaultValueField); - return Map.entry(name, value); - }) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - data.put(group, JsonUtils.objectToJson(groupValue)); - } - return data; - } - private void reconcileThemeDeletion(Theme theme) { deleteThemeFiles(theme); // delete theme setting form diff --git a/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java b/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java new file mode 100644 index 000000000..b89ab062c --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java @@ -0,0 +1,48 @@ +package run.halo.app.core.extension.theme; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; +import org.springframework.lang.NonNull; +import org.springframework.util.CollectionUtils; +import run.halo.app.core.extension.Setting; +import run.halo.app.infra.utils.JsonUtils; + +@UtilityClass +public class SettingUtils { + private static final String VALUE_FIELD = "value"; + private static final String NAME_FIELD = "name"; + + /** + * Read setting default value from {@link Setting} forms. + * + * @param setting {@link Setting} extension + * @return a map of setting default value + */ + @NonNull + public static Map settingDefinedDefaultValueMap(Setting setting) { + List forms = setting.getSpec().getForms(); + if (CollectionUtils.isEmpty(forms)) { + return Map.of(); + } + Map data = new LinkedHashMap<>(); + for (Setting.SettingForm form : forms) { + String group = form.getGroup(); + Map groupValue = form.getFormSchema().stream() + .map(o -> JsonUtils.DEFAULT_JSON_MAPPER.convertValue(o, JsonNode.class)) + .filter(jsonNode -> jsonNode.isObject() && jsonNode.has(NAME_FIELD) + && jsonNode.has(VALUE_FIELD)) + .map(jsonNode -> { + String name = jsonNode.get(NAME_FIELD).asText(); + JsonNode value = jsonNode.get(VALUE_FIELD); + return Map.entry(name, value); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + data.put(group, JsonUtils.objectToJson(groupValue)); + } + return data; + } +} diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java index b969daf07..4a5a6127a 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java @@ -32,6 +32,7 @@ import reactor.core.Exceptions; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; @@ -105,6 +106,19 @@ public class ThemeEndpoint implements CustomEndpoint { .response(responseBuilder() .implementation(Theme.class)) ) + .PUT("themes/{name}/reset-config", this::resetSettingConfig, + builder -> builder.operationId("ResetThemeConfig") + .description("Reset the configMap of theme setting.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ConfigMap.class)) + ) .GET("themes", this::listThemes, builder -> { builder.operationId("ListThemes") @@ -222,6 +236,14 @@ public class ThemeEndpoint implements CustomEndpoint { .bodyValue(theme)); } + Mono resetSettingConfig(ServerRequest request) { + String name = request.pathVariable("name"); + return themeService.resetSettingConfig(name) + .flatMap(theme -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(theme)); + } + public record InstallRequest( @Schema(required = true, description = "Theme zip file.") FilePart file) { } diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeService.java b/src/main/java/run/halo/app/core/extension/theme/ThemeService.java index 93c1752c7..3bfe7e2e3 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeService.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeService.java @@ -3,6 +3,7 @@ package run.halo.app.core.extension.theme; import java.io.InputStream; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Theme; +import run.halo.app.extension.ConfigMap; public interface ThemeService { @@ -11,6 +12,8 @@ public interface ThemeService { Mono upgrade(String themeName, InputStream is); Mono reloadTheme(String name); + + Mono resetSettingConfig(String name); // TODO Migrate other useful methods in ThemeEndpoint in the future. } diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java index ec00a0bd9..6aed6bcea 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; import java.time.Duration; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; @@ -18,6 +19,7 @@ import java.util.zip.ZipInputStream; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.RetryException; import org.springframework.stereotype.Service; import org.springframework.util.Assert; @@ -206,6 +208,29 @@ public class ThemeServiceImpl implements ThemeService { }); } + @Override + public Mono resetSettingConfig(String name) { + return client.fetch(Theme.class, name) + .filter(theme -> StringUtils.isNotBlank(theme.getSpec().getSettingName())) + .flatMap(theme -> { + String configMapName = theme.getSpec().getConfigMapName(); + String settingName = theme.getSpec().getSettingName(); + return client.fetch(Setting.class, settingName) + .map(SettingUtils::settingDefinedDefaultValueMap) + .flatMap(data -> updateConfigMapData(configMapName, data)); + }); + } + + private Mono updateConfigMapData(String configMapName, Map data) { + return client.fetch(ConfigMap.class, configMapName) + .flatMap(configMap -> { + configMap.setData(data); + return client.update(configMap); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof OptimisticLockingFailureException)); + } + private Mono waitForSettingDeleted(String settingName) { return client.fetch(Setting.class, settingName) .flatMap(setting -> client.delete(setting) diff --git a/src/main/resources/extensions/role-template-plugin.yaml b/src/main/resources/extensions/role-template-plugin.yaml index 57900aa5a..63593a629 100644 --- a/src/main/resources/extensions/role-template-plugin.yaml +++ b/src/main/resources/extensions/role-template-plugin.yaml @@ -15,6 +15,9 @@ rules: - apiGroups: [ "plugin.halo.run" ] resources: [ "plugins" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] + - apiGroups: [ "api.console.halo.run" ] + resources: [ "plugins/upgrade", "plugins/resetconfig" ] + verbs: [ "*" ] - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/*" ] verbs: [ "create" ] --- diff --git a/src/main/resources/extensions/role-template-theme.yaml b/src/main/resources/extensions/role-template-theme.yaml index e3915cdbf..617689eb3 100644 --- a/src/main/resources/extensions/role-template-theme.yaml +++ b/src/main/resources/extensions/role-template-theme.yaml @@ -15,7 +15,7 @@ rules: resources: [ "themes" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "themes", "themes/reload" ] + resources: [ "themes", "themes/reload", "themes/resetconfig" ] verbs: [ "*" ] - nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ] verbs: [ "create" ] diff --git a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java index 2cced97cb..8d7165013 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java @@ -174,21 +174,6 @@ class ThemeReconcilerTest { true); } - @Test - void settingDefinedDefaultValueMap() throws JSONException { - Setting setting = getFakeSetting(); - when(haloProperties.getWorkDir()).thenReturn(tempDirectory); - Map map = new ThemeReconciler(extensionClient, haloProperties) - .settingDefinedDefaultValueMap(setting); - JSONAssert.assertEquals(""" - { - "sns": "{\\"email\\":\\"example@exmple.com\\"}" - } - """, - JsonUtils.objectToJson(map), - true); - } - private static Setting getFakeSetting() { String settingJson = """ { diff --git a/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java b/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java new file mode 100644 index 000000000..952a8e62e --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java @@ -0,0 +1,67 @@ +package run.halo.app.core.extension.theme; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.app.core.extension.Setting; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link SettingUtils}. + * + * @author guqing + * @since 2.0.1 + */ +class SettingUtilsTest { + + @Test + void settingDefinedDefaultValueMap() throws JSONException { + Setting setting = getFakeSetting(); + var map = SettingUtils.settingDefinedDefaultValueMap(setting); + JSONAssert.assertEquals(""" + { + "sns": "{\\"email\\":\\"example@exmple.com\\"}" + } + """, + JsonUtils.objectToJson(map), + true); + } + + private static Setting getFakeSetting() { + String settingJson = """ + { + "apiVersion": "v1alpha1", + "kind": "Setting", + "metadata": { + "name": "theme-default-setting" + }, + "spec": { + "forms": [{ + "formSchema": [ + { + "$el": "h1", + "children": "Register" + }, + { + "$formkit": "text", + "label": "Email", + "name": "email", + "value": "example@exmple.com" + }, + { + "$formkit": "password", + "label": "Password", + "name": "password", + "validation": "required|length:5,16", + "value": null + } + ], + "group": "sns", + "label": "社交资料" + }] + } + } + """; + return JsonUtils.jsonToObject(settingJson, Setting.class); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java index 881af14f1..465b56fbf 100644 --- a/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java @@ -170,4 +170,13 @@ class ThemeEndpointTest { .exchange() .expectStatus().isOk(); } + + @Test + void resetSettingConfig() { + when(themeService.resetSettingConfig(any())).thenReturn(Mono.empty()); + webTestClient.put() + .uri("/themes/fake/reset-config") + .exchange() + .expectStatus().isOk(); + } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java index 639ebf6b5..5f991b08e 100644 --- a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java @@ -1,6 +1,7 @@ package run.halo.app.core.extension.theme; import static java.nio.file.Files.createTempDirectory; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -17,6 +18,7 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import org.json.JSONException; import org.junit.jupiter.api.AfterEach; @@ -35,6 +37,7 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Unstructured; @@ -375,4 +378,69 @@ class ThemeServiceImplTest { }) .verifyComplete(); } + + @Test + void resetSettingConfig() { + Theme theme = new Theme(); + theme.setMetadata(new Metadata()); + theme.getMetadata().setName("fake-theme"); + theme.setSpec(new Theme.ThemeSpec()); + theme.getSpec().setSettingName("fake-setting"); + theme.getSpec().setConfigMapName("fake-config"); + theme.getSpec().setDisplayName("Hello"); + when(client.fetch(Theme.class, "fake-theme")) + .thenReturn(Mono.just(theme)); + + Setting setting = new Setting(); + setting.setMetadata(new Metadata()); + setting.getMetadata().setName("fake-setting"); + setting.setSpec(new Setting.SettingSpec()); + var formSchemaItem = Map.of("name", "email", "value", "example@exmple.com"); + Setting.SettingForm settingForm = new Setting.SettingForm(); + settingForm.setGroup("basic"); + settingForm.setFormSchema(List.of(formSchemaItem)); + setting.getSpec().setForms(List.of(settingForm)); + when(client.fetch(eq(Setting.class), eq("fake-setting"))) + .thenReturn(Mono.just(setting)); + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName("fake-config"); + when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) + .thenReturn(Mono.just(configMap)); + + when(client.update(any(ConfigMap.class))) + .thenAnswer((Answer>) invocation -> { + ConfigMap argument = invocation.getArgument(0); + JSONAssert.assertEquals(""" + { + "data": { + "basic": "{\\"email\\":\\"example@exmple.com\\"}" + }, + "apiVersion": "v1alpha1", + "kind": "ConfigMap", + "metadata": { + "name": "fake-config" + } + } + """, + JsonUtils.objectToJson(argument), + true); + return Mono.just(invocation.getArgument(0)); + }); + + themeService.resetSettingConfig("fake-theme") + .as(StepVerifier::create) + .consumeNextWith(next -> { + assertThat(next).isNotNull(); + }) + .verifyComplete(); + + verify(client, times(1)) + .fetch(eq(Setting.class), eq(setting.getMetadata().getName())); + + verify(client, times(1)).fetch(eq(ConfigMap.class), eq("fake-config")); + + verify(client, times(1)).update(any(ConfigMap.class)); + } } \ No newline at end of file