mirror of https://github.com/halo-dev/halo
feat: add theme install endpoint (#2302)
<!-- 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: 新增主题安装功能,endpoint:POST /apis/api.halo.run/v1alpha1/themes/install 限制:主题不允许重复安装,重复安装属于更新功能 #### 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 #2291 #### 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/2321/head
parent
ebecef89c6
commit
349db687e3
|
@ -68,4 +68,7 @@ nbdist/
|
|||
### Local file
|
||||
application-local.yml
|
||||
application-local.yaml
|
||||
application-local.properties
|
||||
application-local.properties
|
||||
|
||||
### Zip file for test
|
||||
!src/test/resources/themes/*.zip
|
|
@ -15,10 +15,12 @@ import run.halo.app.extension.GVK;
|
|||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = "", version = "v1alpha1", kind = "Setting",
|
||||
@GVK(group = "", version = "v1alpha1", kind = Setting.KIND,
|
||||
plural = "settings", singular = "setting")
|
||||
public class Setting extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Setting";
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private List<SettingSpec> spec;
|
||||
|
||||
|
|
|
@ -16,10 +16,12 @@ import run.halo.app.extension.GVK;
|
|||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = "theme.halo.run", version = "v1alpha1", kind = "Theme",
|
||||
@GVK(group = "theme.halo.run", version = "v1alpha1", kind = Theme.KIND,
|
||||
plural = "themes", singular = "theme")
|
||||
public class Theme extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "Theme";
|
||||
|
||||
@Schema(required = true)
|
||||
private ThemeSpec spec;
|
||||
|
||||
|
|
|
@ -0,0 +1,280 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
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.requestbody.Builder.requestBodyBuilder;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.SequenceInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.schema.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.http.codec.multipart.Part;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
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.ExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.ThemeInstallationException;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
|
||||
/**
|
||||
* Endpoint for managing themes.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class ThemeEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final HaloProperties haloProperties;
|
||||
|
||||
public ThemeEndpoint(ExtensionClient client, HaloProperties haloProperties) {
|
||||
this.client = client;
|
||||
this.haloProperties = haloProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "api.halo.run/v1alpha1/Theme";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.POST("themes/install", contentType(MediaType.MULTIPART_FORM_DATA),
|
||||
this::install, builder -> builder.operationId("InstallTheme")
|
||||
.description("Install a theme by uploading a zip file.")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
.schema(Builder.schemaBuilder()
|
||||
.implementation(InstallRequest.class))
|
||||
))
|
||||
.response(responseBuilder()
|
||||
.implementation(Theme.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
public record InstallRequest(
|
||||
@Schema(required = true, description = "Theme zip file.") FilePart file) {
|
||||
}
|
||||
|
||||
Mono<ServerResponse> install(ServerRequest request) {
|
||||
return request.bodyToMono(new ParameterizedTypeReference<MultiValueMap<String, Part>>() {
|
||||
})
|
||||
.flatMap(this::getZipFilePart)
|
||||
.flatMap(file -> file.content()
|
||||
.map(DataBuffer::asInputStream)
|
||||
.reduce(SequenceInputStream::new)
|
||||
.map(inputStream -> ThemeUtils.unzipThemeTo(inputStream, getThemeWorkDir())))
|
||||
.map(this::persistent)
|
||||
.flatMap(theme -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(theme));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates theme manifest and related unstructured resources.
|
||||
* TODO: In case of failure in saving midway, the problem of data consistency needs to be
|
||||
* solved.
|
||||
*
|
||||
* @param themeManifest the theme custom model
|
||||
* @return a theme custom model
|
||||
* @see Theme
|
||||
*/
|
||||
public Theme persistent(Unstructured themeManifest) {
|
||||
Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()),
|
||||
"Theme manifest kind must be Theme.");
|
||||
client.create(themeManifest);
|
||||
Theme theme = client.fetch(Theme.class, themeManifest.getMetadata().getName())
|
||||
.orElseThrow();
|
||||
List<Unstructured> unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
|
||||
if (unstructureds.stream()
|
||||
.filter(unstructured -> unstructured.getKind().equals(Setting.KIND))
|
||||
.filter(unstructured -> unstructured.getMetadata().getName()
|
||||
.equals(theme.getSpec().getSettingName()))
|
||||
.count() > 1) {
|
||||
throw new IllegalStateException(
|
||||
"Theme must only have one settings.yaml or settings.yml.");
|
||||
}
|
||||
if (unstructureds.stream()
|
||||
.filter(unstructured -> unstructured.getKind().equals(ConfigMap.KIND))
|
||||
.filter(unstructured -> unstructured.getMetadata().getName()
|
||||
.equals(theme.getSpec().getConfigMapName()))
|
||||
.count() > 1) {
|
||||
throw new IllegalStateException(
|
||||
"Theme must only have one config.yaml or config.yml.");
|
||||
}
|
||||
Theme.ThemeSpec spec = theme.getSpec();
|
||||
for (Unstructured unstructured : unstructureds) {
|
||||
String name = unstructured.getMetadata().getName();
|
||||
|
||||
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
|
||||
&& StringUtils.equals(spec.getSettingName(), name);
|
||||
|
||||
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
|
||||
&& StringUtils.equals(spec.getConfigMapName(), name);
|
||||
if (isThemeSetting || isThemeConfig) {
|
||||
client.create(unstructured);
|
||||
}
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
private Path getThemePath(Theme theme) {
|
||||
return getThemeWorkDir().resolve(theme.getMetadata().getName());
|
||||
}
|
||||
|
||||
static class ThemeUtils {
|
||||
private static final String THEME_TMP_PREFIX = "halo-theme-";
|
||||
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) {
|
||||
List<Resource> resources = new ArrayList<>(4);
|
||||
for (String themeResource : THEME_RESOURCES) {
|
||||
Path resourcePath = themePath.resolve(themeResource);
|
||||
if (Files.exists(resourcePath)) {
|
||||
resources.add(new FileSystemResource(resourcePath));
|
||||
}
|
||||
}
|
||||
if (CollectionUtils.isEmpty(resources)) {
|
||||
return List.of();
|
||||
}
|
||||
return new YamlUnstructuredLoader(resources.toArray(new Resource[0]))
|
||||
.load();
|
||||
}
|
||||
|
||||
static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
|
||||
return unzipThemeTo(inputStream, themeWorkDir, false);
|
||||
}
|
||||
|
||||
static Unstructured unzipThemeTo(InputStream inputStream, Path themeWorkDir,
|
||||
boolean override) {
|
||||
|
||||
Path tempDirectory = null;
|
||||
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
|
||||
tempDirectory = Files.createTempDirectory(THEME_TMP_PREFIX);
|
||||
|
||||
ZipEntry firstEntry = zipInputStream.getNextEntry();
|
||||
if (firstEntry == null) {
|
||||
throw new IllegalArgumentException("Theme zip file is empty.");
|
||||
}
|
||||
|
||||
Path themeTempWorkDir = tempDirectory.resolve(firstEntry.getName());
|
||||
FileUtils.unzip(zipInputStream, tempDirectory);
|
||||
|
||||
Path themeManifestPath = resolveThemeManifest(themeTempWorkDir);
|
||||
if (themeManifestPath == null) {
|
||||
FileSystemUtils.deleteRecursively(tempDirectory);
|
||||
throw new IllegalArgumentException(
|
||||
"It's an invalid zip format for the theme, manifest "
|
||||
+ "file [theme.yaml] is required.");
|
||||
}
|
||||
Unstructured unstructured = loadThemeManifest(themeManifestPath);
|
||||
String themeName = unstructured.getMetadata().getName();
|
||||
Path themeTargetPath = themeWorkDir.resolve(themeName);
|
||||
if (!override && !FileUtils.isEmpty(themeTargetPath)) {
|
||||
throw new ThemeInstallationException("Theme already exists.");
|
||||
}
|
||||
// install theme to theme work dir
|
||||
FileSystemUtils.copyRecursively(themeTempWorkDir, themeTargetPath);
|
||||
return unstructured;
|
||||
} catch (IOException e) {
|
||||
throw new ThemeInstallationException("Unable to install theme", e);
|
||||
} finally {
|
||||
// clean temp directory
|
||||
try {
|
||||
// null safe
|
||||
FileSystemUtils.deleteRecursively(tempDirectory);
|
||||
} catch (IOException e) {
|
||||
// ignore this exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Unstructured loadThemeManifest(Path themeManifestPath) {
|
||||
List<Unstructured> unstructureds =
|
||||
new YamlUnstructuredLoader(new FileSystemResource(themeManifestPath))
|
||||
.load();
|
||||
if (CollectionUtils.isEmpty(unstructureds)) {
|
||||
throw new IllegalArgumentException(
|
||||
"The [theme.yaml] does not conform to the theme specification.");
|
||||
}
|
||||
return unstructureds.get(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Path resolveThemeManifest(Path tempDirectory) {
|
||||
for (String themeManifest : themeManifests) {
|
||||
Path path = tempDirectory.resolve(themeManifest);
|
||||
if (Files.exists(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Path getThemeWorkDir() {
|
||||
Path themePath = haloProperties.getWorkDir()
|
||||
.resolve("themes");
|
||||
if (Files.notExists(themePath)) {
|
||||
try {
|
||||
Files.createDirectories(themePath);
|
||||
} catch (IOException e) {
|
||||
throw new UnsupportedOperationException(
|
||||
"Failed to create directory " + themePath, e);
|
||||
}
|
||||
}
|
||||
return themePath;
|
||||
}
|
||||
|
||||
Mono<FilePart> getZipFilePart(MultiValueMap<String, Part> formData) {
|
||||
Part part = formData.getFirst("file");
|
||||
if (!(part instanceof FilePart file)) {
|
||||
return Mono.error(new ServerWebInputException(
|
||||
"Invalid parameter of file, binary data is required"));
|
||||
}
|
||||
if (!Paths.get(file.filename()).toString().endsWith(".zip")) {
|
||||
return Mono.error(new ServerWebInputException(
|
||||
"Invalid file type, only zip format is supported"));
|
||||
}
|
||||
return Mono.just(file);
|
||||
}
|
||||
}
|
|
@ -15,10 +15,12 @@ import lombok.ToString;
|
|||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = "", version = "v1alpha1", kind = "ConfigMap", plural = "configmaps",
|
||||
@GVK(group = "", version = "v1alpha1", kind = ConfigMap.KIND, plural = "configmaps",
|
||||
singular = "configmap")
|
||||
public class ConfigMap extends AbstractExtension {
|
||||
|
||||
public static final String KIND = "ConfigMap";
|
||||
|
||||
private Map<String, String> data;
|
||||
|
||||
public ConfigMap putDataItem(String key, String dataItem) {
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeInstallationException extends RuntimeException {
|
||||
public ThemeInstallationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ThemeInstallationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
package run.halo.app.infra.utils;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryNotEmptyException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class FileUtils {
|
||||
|
||||
public static void unzip(@NonNull ZipInputStream zis, @NonNull Path targetPath)
|
||||
throws IOException {
|
||||
// 1. unzip file to folder
|
||||
// 2. return the folder path
|
||||
Assert.notNull(zis, "Zip input stream must not be null");
|
||||
Assert.notNull(targetPath, "Target path must not be null");
|
||||
|
||||
// Create path if absent
|
||||
createIfAbsent(targetPath);
|
||||
|
||||
// Folder must be empty
|
||||
ensureEmpty(targetPath);
|
||||
|
||||
ZipEntry zipEntry = zis.getNextEntry();
|
||||
|
||||
while (zipEntry != null) {
|
||||
// Resolve the entry path
|
||||
Path entryPath = targetPath.resolve(zipEntry.getName());
|
||||
|
||||
// Check directory
|
||||
if (targetPath.normalize().startsWith(entryPath)) {
|
||||
throw new IllegalArgumentException("Cannot unzip to a subdirectory of itself");
|
||||
}
|
||||
|
||||
if (Files.notExists(entryPath.getParent())) {
|
||||
Files.createDirectories(entryPath.getParent());
|
||||
}
|
||||
|
||||
if (zipEntry.isDirectory()) {
|
||||
// Create directory
|
||||
Files.createDirectory(entryPath);
|
||||
} else {
|
||||
// Copy file
|
||||
Files.copy(zis, entryPath);
|
||||
}
|
||||
|
||||
zipEntry = zis.getNextEntry();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates directories if absent.
|
||||
*
|
||||
* @param path path must not be null
|
||||
* @throws IOException io exception
|
||||
*/
|
||||
public static void createIfAbsent(@NonNull Path path) throws IOException {
|
||||
Assert.notNull(path, "Path must not be null");
|
||||
|
||||
if (Files.notExists(path)) {
|
||||
// Create directories
|
||||
Files.createDirectories(path);
|
||||
|
||||
log.debug("Created directory: [{}]", path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given path must be empty.
|
||||
*
|
||||
* @param path path must not be null
|
||||
* @throws IOException io exception
|
||||
*/
|
||||
public static void ensureEmpty(@NonNull Path path) throws IOException {
|
||||
if (!isEmpty(path)) {
|
||||
throw new DirectoryNotEmptyException("Target directory: " + path + " was not empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given path is empty.
|
||||
*
|
||||
* @param path path must not be null
|
||||
* @return true if the given path is empty; false otherwise
|
||||
* @throws IOException io exception
|
||||
*/
|
||||
public static boolean isEmpty(@NonNull Path path) throws IOException {
|
||||
Assert.notNull(path, "Path must not be null");
|
||||
|
||||
if (!Files.isDirectory(path) || Files.notExists(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try (Stream<Path> pathStream = Files.list(path)) {
|
||||
return pathStream.findAny().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public static void closeQuietly(final Closeable closeable) {
|
||||
closeQuietly(closeable, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the given {@link Closeable} as a null-safe operation while consuming IOException by
|
||||
* the given {@code consumer}.
|
||||
*
|
||||
* @param closeable The resource to close, may be null.
|
||||
* @param consumer Consumes the IOException thrown by {@link Closeable#close()}.
|
||||
*/
|
||||
public static void closeQuietly(final Closeable closeable,
|
||||
final Consumer<IOException> consumer) {
|
||||
if (closeable != null) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (IOException e) {
|
||||
if (consumer != null) {
|
||||
consumer.accept(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
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 java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThemeEndpoint}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ThemeEndpointTest {
|
||||
|
||||
@Mock
|
||||
private ExtensionClient extensionClient;
|
||||
|
||||
@Mock
|
||||
private HaloProperties haloProperties;
|
||||
|
||||
private Path tmpHaloWorkDir;
|
||||
|
||||
WebTestClient webTestClient;
|
||||
|
||||
private File defaultTheme;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
tmpHaloWorkDir = Files.createTempDirectory("halo-unit-test");
|
||||
|
||||
ThemeEndpoint themeEndpoint = new ThemeEndpoint(extensionClient, haloProperties);
|
||||
|
||||
when(haloProperties.getWorkDir()).thenReturn(tmpHaloWorkDir);
|
||||
|
||||
defaultTheme = ResourceUtils.getFile("classpath:themes/test-theme.zip");
|
||||
|
||||
webTestClient = WebTestClient
|
||||
.bindToRouterFunction(themeEndpoint.endpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
FileSystemUtils.deleteRecursively(tmpHaloWorkDir.toFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void install() {
|
||||
when(extensionClient.fetch(eq(Theme.class), eq("default")))
|
||||
.then(answer -> {
|
||||
Path defaultThemeManifestPath = tmpHaloWorkDir.resolve("themes/default/theme.yaml");
|
||||
assertThat(Files.exists(defaultThemeManifestPath)).isTrue();
|
||||
|
||||
Unstructured unstructured =
|
||||
new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath))
|
||||
.load()
|
||||
.get(0);
|
||||
return Optional.of(
|
||||
Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class));
|
||||
});
|
||||
|
||||
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();
|
||||
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
webTestClient.post()
|
||||
.uri("/themes/install")
|
||||
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(Theme.class)
|
||||
.value(theme -> {
|
||||
verify(extensionClient, times(1)).create(any(Unstructured.class));
|
||||
|
||||
assertThat(theme).isNotNull();
|
||||
assertThat(theme.getMetadata().getName()).isEqualTo("default");
|
||||
});
|
||||
|
||||
// Verify the theme is installed.
|
||||
webTestClient.post()
|
||||
.uri("/themes/install")
|
||||
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.is5xxServerError();
|
||||
}
|
||||
}
|
Binary file not shown.
Loading…
Reference in New Issue