mirror of https://github.com/halo-dev/halo
feat: add theme setting reload API (#2456)
#### What type of PR is this? /kind feature /milestone 2.0 /area core /kind api-change #### What this PR does / why we need it: 新增主题设置重载 API,调用此 API 会从主题文件中重新加载 settings.yaml 复盖原有记录,便于主题开发和测试 插件不需要此功能,配置了 `fixedPluginPath` 后每次重启都会加载新的配置项 #### Which issue(s) this PR fixes: Fixes #2426 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ```pull/2466/head
parent
f0892b2f4d
commit
02cc2fa7be
|
@ -2,9 +2,11 @@ package run.halo.app.core.extension.endpoint;
|
||||||
|
|
||||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||||
|
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
@ -17,6 +19,7 @@ import java.util.function.Predicate;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.ArrayUtils;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springdoc.core.fn.builders.schema.Builder;
|
import org.springdoc.core.fn.builders.schema.Builder;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
|
@ -87,9 +90,48 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
.response(responseBuilder()
|
.response(responseBuilder()
|
||||||
.implementation(Theme.class))
|
.implementation(Theme.class))
|
||||||
)
|
)
|
||||||
|
.PUT("themes/{name}/reload-setting", this::reloadSetting,
|
||||||
|
builder -> builder.operationId("ReloadThemeSetting")
|
||||||
|
.description("Reload theme setting.")
|
||||||
|
.tag(tag)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("name")
|
||||||
|
.in(ParameterIn.PATH)
|
||||||
|
.required(true)
|
||||||
|
.implementation(String.class)
|
||||||
|
)
|
||||||
|
.response(responseBuilder()
|
||||||
|
.implementation(Setting.class))
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Mono<ServerResponse> reloadSetting(ServerRequest request) {
|
||||||
|
String name = request.pathVariable("name");
|
||||||
|
return client.fetch(Theme.class, name)
|
||||||
|
.filter(theme -> StringUtils.isNotBlank(theme.getSpec().getSettingName()))
|
||||||
|
.flatMap(theme -> {
|
||||||
|
String settingName = theme.getSpec().getSettingName();
|
||||||
|
return ThemeUtils.loadThemeSetting(getThemePath(theme))
|
||||||
|
.stream()
|
||||||
|
.filter(unstructured ->
|
||||||
|
settingName.equals(unstructured.getMetadata().getName()))
|
||||||
|
.findFirst()
|
||||||
|
.map(setting -> Unstructured.OBJECT_MAPPER.convertValue(setting, Setting.class))
|
||||||
|
.map(setting -> client.fetch(Setting.class, settingName)
|
||||||
|
.flatMap(persistent -> {
|
||||||
|
// update spec to persisted setting
|
||||||
|
persistent.setSpec(setting.getSpec());
|
||||||
|
return client.update(persistent);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> client.create(setting))))
|
||||||
|
.orElse(Mono.empty());
|
||||||
|
})
|
||||||
|
.flatMap(setting -> ServerResponse.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(setting));
|
||||||
|
}
|
||||||
|
|
||||||
public record InstallRequest(
|
public record InstallRequest(
|
||||||
@Schema(required = true, description = "Theme zip file.") FilePart file) {
|
@Schema(required = true, description = "Theme zip file.") FilePart file) {
|
||||||
}
|
}
|
||||||
|
@ -180,16 +222,19 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
static class ThemeUtils {
|
static class ThemeUtils {
|
||||||
private static final String THEME_TMP_PREFIX = "halo-theme-";
|
private static final String THEME_TMP_PREFIX = "halo-theme-";
|
||||||
private static final String[] themeManifests = {"theme.yaml", "theme.yml"};
|
private static final String[] themeManifests = {"theme.yaml", "theme.yml"};
|
||||||
private static final String[] THEME_RESOURCES = {
|
|
||||||
"settings.yaml",
|
|
||||||
"settings.yml",
|
|
||||||
"config.yaml",
|
|
||||||
"config.yml"
|
|
||||||
};
|
|
||||||
|
|
||||||
static List<Unstructured> loadThemeResources(Path themePath) {
|
private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"};
|
||||||
|
|
||||||
|
private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"};
|
||||||
|
|
||||||
|
static List<Unstructured> loadThemeSetting(Path themePath) {
|
||||||
|
return loadUnstructured(themePath, THEME_SETTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Unstructured> loadUnstructured(Path themePath,
|
||||||
|
String[] themeSetting) {
|
||||||
List<Resource> resources = new ArrayList<>(4);
|
List<Resource> resources = new ArrayList<>(4);
|
||||||
for (String themeResource : THEME_RESOURCES) {
|
for (String themeResource : themeSetting) {
|
||||||
Path resourcePath = themePath.resolve(themeResource);
|
Path resourcePath = themePath.resolve(themeResource);
|
||||||
if (Files.exists(resourcePath)) {
|
if (Files.exists(resourcePath)) {
|
||||||
resources.add(new FileSystemResource(resourcePath));
|
resources.add(new FileSystemResource(resourcePath));
|
||||||
|
@ -202,6 +247,11 @@ public class ThemeEndpoint implements CustomEndpoint {
|
||||||
.load();
|
.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<Unstructured> loadThemeResources(Path themePath) {
|
||||||
|
String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG);
|
||||||
|
return loadUnstructured(themePath, resourceNames);
|
||||||
|
}
|
||||||
|
|
||||||
static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
|
static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
|
||||||
return unzipThemeTo(inputStream, themeWorkDir, false);
|
return unzipThemeTo(inputStream, themeWorkDir, false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,16 @@ import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import org.json.JSONException;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.skyscreamer.jsonassert.JSONAssert;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.client.MultipartBodyBuilder;
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
|
@ -24,10 +28,13 @@ import org.springframework.util.FileSystemUtils;
|
||||||
import org.springframework.util.ResourceUtils;
|
import org.springframework.util.ResourceUtils;
|
||||||
import org.springframework.web.reactive.function.BodyInserters;
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -108,4 +115,84 @@ class ThemeEndpointTest {
|
||||||
.expectStatus()
|
.expectStatus()
|
||||||
.is5xxServerError();
|
.is5xxServerError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reloadSetting() throws IOException {
|
||||||
|
Theme theme = new Theme();
|
||||||
|
theme.setMetadata(new Metadata());
|
||||||
|
theme.getMetadata().setName("fake-theme");
|
||||||
|
theme.setSpec(new Theme.ThemeSpec());
|
||||||
|
theme.getSpec().setSettingName("fake-setting");
|
||||||
|
when(extensionClient.fetch(Theme.class, "fake-theme"))
|
||||||
|
.thenReturn(Mono.just(theme));
|
||||||
|
Setting setting = new Setting();
|
||||||
|
setting.setMetadata(new Metadata());
|
||||||
|
setting.setSpec(new Setting.SettingSpec());
|
||||||
|
setting.getSpec().setForms(List.of());
|
||||||
|
when(extensionClient.fetch(Setting.class, "fake-setting"))
|
||||||
|
.thenReturn(Mono.just(setting));
|
||||||
|
|
||||||
|
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||||
|
Path themeWorkDir = tmpHaloWorkDir.resolve("themes")
|
||||||
|
.resolve(theme.getMetadata().getName());
|
||||||
|
if (!Files.exists(themeWorkDir)) {
|
||||||
|
Files.createDirectories(themeWorkDir);
|
||||||
|
}
|
||||||
|
Files.writeString(themeWorkDir.resolve("settings.yaml"), """
|
||||||
|
apiVersion: v1alpha1
|
||||||
|
kind: Setting
|
||||||
|
metadata:
|
||||||
|
name: fake-setting
|
||||||
|
spec:
|
||||||
|
forms:
|
||||||
|
- group: sns
|
||||||
|
label: 社交资料
|
||||||
|
formSchema:
|
||||||
|
- $el: h1
|
||||||
|
children: Register
|
||||||
|
""");
|
||||||
|
|
||||||
|
when(extensionClient.update(any(Setting.class)))
|
||||||
|
.thenReturn(Mono.just(setting));
|
||||||
|
ArgumentCaptor<Setting> captor = ArgumentCaptor.forClass(Setting.class);
|
||||||
|
webTestClient.put()
|
||||||
|
.uri("/themes/fake-theme/reload-setting")
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.isOk()
|
||||||
|
.expectBody(Setting.class)
|
||||||
|
.value(settingRes -> {
|
||||||
|
verify(extensionClient, times(1)).update(captor.capture());
|
||||||
|
verify(extensionClient, times(0)).create(any(Setting.class));
|
||||||
|
Setting value = captor.getValue();
|
||||||
|
System.out.println(JsonUtils.objectToJson(value));
|
||||||
|
try {
|
||||||
|
JSONAssert.assertEquals("""
|
||||||
|
{
|
||||||
|
"spec": {
|
||||||
|
"forms": [
|
||||||
|
{
|
||||||
|
"group": "sns",
|
||||||
|
"label": "社交资料",
|
||||||
|
"formSchema": [
|
||||||
|
{
|
||||||
|
"$el": "h1",
|
||||||
|
"children": "Register"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"apiVersion": "v1alpha1",
|
||||||
|
"kind": "Setting",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
JsonUtils.objectToJson(value),
|
||||||
|
true);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue