From 264f9e39cb03b037440ad3d9d62f83f9eb94b9c2 Mon Sep 17 00:00:00 2001 From: John Niang Date: Tue, 17 May 2022 18:36:13 +0800 Subject: [PATCH] Add support for Extension mechanism (#2086) * Add support for Extension mechanism 1. Add ExtensionStore 2. Add Extension 3. Add Scheme * Add more unit tests * Fix checkstyle violations * Remove unused import --- .../halo/app/extension/AbstractExtension.java | 43 ++++ .../app/extension/DefaultExtensionClient.java | 91 +++++++ .../run/halo/app/extension/Extension.java | 37 +++ .../halo/app/extension/ExtensionClient.java | 81 ++++++ .../app/extension/ExtensionConverter.java | 31 +++ .../run/halo/app/extension/ExtensionUtil.java | 41 +++ src/main/java/run/halo/app/extension/GVK.java | 42 +++ .../run/halo/app/extension/GroupKind.java | 11 + .../run/halo/app/extension/GroupVersion.java | 40 +++ .../halo/app/extension/GroupVersionKind.java | 42 +++ .../app/extension/JSONExtensionConverter.java | 51 ++++ .../java/run/halo/app/extension/Metadata.java | 45 ++++ .../java/run/halo/app/extension/Scheme.java | 31 +++ .../java/run/halo/app/extension/Schemes.java | 118 +++++++++ .../exception/ExtensionConvertException.java | 29 +++ .../exception/ExtensionException.java | 30 +++ .../exception/SchemeNotFoundException.java | 29 +++ .../app/extension/store/ExtensionStore.java | 55 ++++ .../extension/store/ExtensionStoreClient.java | 58 +++++ .../store/ExtensionStoreClientJPAImpl.java | 53 ++++ .../store/ExtensionStoreRepository.java | 23 ++ .../app/extension/AbstractExtensionTest.java | 52 ++++ .../extension/DefaultExtensionClientTest.java | 241 ++++++++++++++++++ .../halo/app/extension/ExtensionUtilTest.java | 46 ++++ .../run/halo/app/extension/FakeExtension.java | 9 + .../halo/app/extension/GroupVersionTest.java | 29 +++ .../extension/JSONExtensionConverterTest.java | 81 ++++++ .../run/halo/app/extension/SchemeTest.java | 33 +++ .../run/halo/app/extension/SchemesTest.java | 47 ++++ .../ExtensionStoreClientJPAImplTest.java | 102 ++++++++ 30 files changed, 1621 insertions(+) create mode 100644 src/main/java/run/halo/app/extension/AbstractExtension.java create mode 100644 src/main/java/run/halo/app/extension/DefaultExtensionClient.java create mode 100644 src/main/java/run/halo/app/extension/Extension.java create mode 100644 src/main/java/run/halo/app/extension/ExtensionClient.java create mode 100644 src/main/java/run/halo/app/extension/ExtensionConverter.java create mode 100644 src/main/java/run/halo/app/extension/ExtensionUtil.java create mode 100644 src/main/java/run/halo/app/extension/GVK.java create mode 100644 src/main/java/run/halo/app/extension/GroupKind.java create mode 100644 src/main/java/run/halo/app/extension/GroupVersion.java create mode 100644 src/main/java/run/halo/app/extension/GroupVersionKind.java create mode 100644 src/main/java/run/halo/app/extension/JSONExtensionConverter.java create mode 100644 src/main/java/run/halo/app/extension/Metadata.java create mode 100644 src/main/java/run/halo/app/extension/Scheme.java create mode 100644 src/main/java/run/halo/app/extension/Schemes.java create mode 100644 src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java create mode 100644 src/main/java/run/halo/app/extension/exception/ExtensionException.java create mode 100644 src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java create mode 100644 src/main/java/run/halo/app/extension/store/ExtensionStore.java create mode 100644 src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java create mode 100644 src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java create mode 100644 src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java create mode 100644 src/test/java/run/halo/app/extension/AbstractExtensionTest.java create mode 100644 src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionUtilTest.java create mode 100644 src/test/java/run/halo/app/extension/FakeExtension.java create mode 100644 src/test/java/run/halo/app/extension/GroupVersionTest.java create mode 100644 src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java create mode 100644 src/test/java/run/halo/app/extension/SchemeTest.java create mode 100644 src/test/java/run/halo/app/extension/SchemesTest.java create mode 100644 src/test/java/run/halo/app/extension/store/ExtensionStoreClientJPAImplTest.java diff --git a/src/main/java/run/halo/app/extension/AbstractExtension.java b/src/main/java/run/halo/app/extension/AbstractExtension.java new file mode 100644 index 000000000..f34d3be85 --- /dev/null +++ b/src/main/java/run/halo/app/extension/AbstractExtension.java @@ -0,0 +1,43 @@ +package run.halo.app.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * AbstractExtension contains basic structure of Extension and implements the Extension interface. + * + * @author johnniang + */ +@Data +public abstract class AbstractExtension implements Extension { + + @Schema(required = true) + private String apiVersion; + + @Schema(required = true) + private String kind; + + @Schema(required = true) + private Metadata metadata; + + @Override + public void groupVersionKind(GroupVersionKind gvk) { + this.apiVersion = gvk.groupVersion().toString(); + this.kind = gvk.kind(); + } + + @Override + public GroupVersionKind groupVersionKind() { + return GroupVersionKind.fromAPIVersionAndKind(this.apiVersion, this.kind); + } + + @Override + public void metadata(Metadata metadata) { + this.metadata = metadata; + } + + @Override + public Metadata metadata() { + return this.metadata; + } +} diff --git a/src/main/java/run/halo/app/extension/DefaultExtensionClient.java b/src/main/java/run/halo/app/extension/DefaultExtensionClient.java new file mode 100644 index 000000000..c5fbbbba1 --- /dev/null +++ b/src/main/java/run/halo/app/extension/DefaultExtensionClient.java @@ -0,0 +1,91 @@ +package run.halo.app.extension; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import run.halo.app.extension.store.ExtensionStore; +import run.halo.app.extension.store.ExtensionStoreClient; + +/** + * DefaultExtensionClient is default implementation of ExtensionClient. + * + * @author johnniang + */ +@Service +public class DefaultExtensionClient implements ExtensionClient { + + private final ExtensionStoreClient storeClient; + private final ExtensionConverter converter; + + public DefaultExtensionClient(ExtensionStoreClient storeClient, ExtensionConverter converter) { + this.storeClient = storeClient; + this.converter = converter; + } + + @Override + public List list(Class type, Predicate predicate, + Comparator comparator) { + var scheme = Schemes.INSTANCE.get(type); + var storeNamePrefix = ExtensionUtil.buildStoreNamePrefix(scheme); + + var storesStream = storeClient.listByNamePrefix(storeNamePrefix).stream() + .map(extensionStore -> converter.convertFrom(type, extensionStore)); + if (predicate != null) { + storesStream = storesStream.filter(predicate); + } + if (comparator != null) { + storesStream = storesStream.sorted(comparator); + } + return storesStream.toList(); + } + + @Override + public Page page(Class type, Predicate predicate, + Comparator comparators, int page, int size) { + var pageable = PageRequest.of(page, size); + var all = list(type, predicate, comparators); + var total = all.size(); + var content = + all.stream().limit(pageable.getPageSize()).skip(pageable.getOffset()).toList(); + return PageableExecutionUtils.getPage(content, pageable, () -> total); + } + + @Override + public Optional fetch(Class type, String name) { + var scheme = Schemes.INSTANCE.get(type); + + var storeName = ExtensionUtil.buildStoreName(scheme, name); + return storeClient.fetchByName(storeName) + .map(extensionStore -> converter.convertFrom(type, extensionStore)); + } + + @Override + public void create(E extension) { + extension.metadata().setCreationTimestamp(Instant.now()); + var extensionStore = converter.convertTo(extension); + storeClient.create(extensionStore.getName(), extensionStore.getData()); + } + + @Override + public void update(E extension) { + var extensionStore = converter.convertTo(extension); + Assert.notNull(extension.metadata().getVersion(), + "Extension version must not be null when updating"); + storeClient.update(extensionStore.getName(), extensionStore.getVersion(), + extensionStore.getData()); + } + + @Override + public void delete(E extension) { + ExtensionStore extensionStore = converter.convertTo(extension); + storeClient.delete(extensionStore.getName(), extensionStore.getVersion()); + } + +} diff --git a/src/main/java/run/halo/app/extension/Extension.java b/src/main/java/run/halo/app/extension/Extension.java new file mode 100644 index 000000000..9ce661dc1 --- /dev/null +++ b/src/main/java/run/halo/app/extension/Extension.java @@ -0,0 +1,37 @@ +package run.halo.app.extension; + +/** + * Extension is an interface which represents an Extension. It contains setters and getters of + * GroupVersionKind and Metadata. + */ +public interface Extension { + + /** + * Sets GroupVersionKind of the Extension. + * + * @param gvk is GroupVersionKind data. + */ + void groupVersionKind(GroupVersionKind gvk); + + /** + * Gets GroupVersionKind of the Extension. + * + * @return GroupVersionKind of the Extension. + */ + GroupVersionKind groupVersionKind(); + + /** + * Sets metadata of the Extension. + * + * @param metadata metadata of the Extension. + */ + void metadata(Metadata metadata); + + /** + * Gets metadata of the Extension. + * + * @return metadata of the Extension. + */ + Metadata metadata(); + +} diff --git a/src/main/java/run/halo/app/extension/ExtensionClient.java b/src/main/java/run/halo/app/extension/ExtensionClient.java new file mode 100644 index 000000000..a2bf4a455 --- /dev/null +++ b/src/main/java/run/halo/app/extension/ExtensionClient.java @@ -0,0 +1,81 @@ +package run.halo.app.extension; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.springframework.data.domain.Page; + +/** + * ExtensionClient is an interface which contains some operations on Extension instead of + * ExtensionStore. + * + * @author johnniang + */ +public interface ExtensionClient { + + /** + * Lists Extensions by Extension type, filter and sorter. + * + * @param type is the class type of Extension. + * @param predicate filters the result. + * @param comparator sorts the result. + * @param is Extension type. + * @return all filtered and sorted Extensions. + */ + List list(Class type, Predicate predicate, + Comparator comparator); + + /** + * Lists Extensions by Extension type, filter, sorter and page info. + * + * @param type is the class type of Extension. + * @param predicate filters the result. + * @param comparator sorts the result. + * @param page is page number which starts from 0. + * @param size is page size. + * @param is Extension type. + * @return a page of Extensions. + */ + Page page(Class type, Predicate predicate, + Comparator comparator, int page, int size); + + /** + * Fetches Extension by its type and name. + * + * @param type is Extension type. + * @param name is Extension name. + * @param is Extension type. + * @return an optional Extension. + */ + Optional fetch(Class type, String name); + + + /** + * Creates an Extension. + * + * @param extension is fresh Extension to be created. Please make sure the Extension name does + * not exist. + * @param is Extension type. + */ + void create(E extension); + + /** + * Updates an Extension. + * + * @param extension is an Extension to be updated. Please make sure the resource version is + * latest. + * @param is Extension type. + */ + void update(E extension); + + /** + * Deletes an Extension. + * + * @param extension is an Extension to be deleted. Please make sure the resource version is + * latest. + * @param is Extension type. + */ + void delete(E extension); + +} diff --git a/src/main/java/run/halo/app/extension/ExtensionConverter.java b/src/main/java/run/halo/app/extension/ExtensionConverter.java new file mode 100644 index 000000000..cf919330e --- /dev/null +++ b/src/main/java/run/halo/app/extension/ExtensionConverter.java @@ -0,0 +1,31 @@ +package run.halo.app.extension; + +import run.halo.app.extension.store.ExtensionStore; + +/** + * ExtensionConverter contains bidirectional conversions between Extension and ExtensionStore. + * + * @author johnniang + */ +public interface ExtensionConverter { + + /** + * Converts Extension to ExtensionStore. + * + * @param extension is an Extension to be converted. + * @param is Extension type. + * @return an ExtensionStore. + */ + ExtensionStore convertTo(E extension); + + /** + * Converts Extension from ExtensionStore. + * + * @param type is Extension type. + * @param extensionStore is an ExtensionStore + * @param is Extension type. + * @return an Extension + */ + E convertFrom(Class type, ExtensionStore extensionStore); + +} diff --git a/src/main/java/run/halo/app/extension/ExtensionUtil.java b/src/main/java/run/halo/app/extension/ExtensionUtil.java new file mode 100644 index 000000000..650fe0e2a --- /dev/null +++ b/src/main/java/run/halo/app/extension/ExtensionUtil.java @@ -0,0 +1,41 @@ +package run.halo.app.extension; + +import org.springframework.util.StringUtils; + +/** + * Extension utilities. + * + * @author johnniang + */ +public final class ExtensionUtil { + + private ExtensionUtil() { + } + + /** + * Builds the name prefix of ExtensionStore. + * + * @param scheme is scheme of an Extension. + * @return name prefix of ExtensionStore. + */ + public static String buildStoreNamePrefix(Scheme scheme) { + // rule of key: /registry/[group]/plural-name/extension-name + StringBuilder builder = new StringBuilder("/registry/"); + if (StringUtils.hasText(scheme.groupVersionKind().group())) { + builder.append(scheme.groupVersionKind().group()).append('/'); + } + builder.append(scheme.plural()); + return builder.toString(); + } + + /** + * Builds full name of ExtensionStore. + * + * @param scheme is scheme of an Extension. + * @param name the exact name of Extension. + * @return full name of ExtensionStore. + */ + public static String buildStoreName(Scheme scheme, String name) { + return buildStoreNamePrefix(scheme) + "/" + name; + } +} diff --git a/src/main/java/run/halo/app/extension/GVK.java b/src/main/java/run/halo/app/extension/GVK.java new file mode 100644 index 000000000..ebc023a16 --- /dev/null +++ b/src/main/java/run/halo/app/extension/GVK.java @@ -0,0 +1,42 @@ +package run.halo.app.extension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * GVK is an annotation to specific metadata of Extension. + * + * @author johnniang + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface GVK { + + /** + * @return group name of Extension. + */ + String group(); + + /** + * @return version name of Extension. + */ + String version(); + + /** + * @return kind name of Extension. + */ + String kind(); + + /** + * @return plural name of Extension. + */ + String plural(); + + /** + * @return singular name of Extension. + */ + String singular(); + +} diff --git a/src/main/java/run/halo/app/extension/GroupKind.java b/src/main/java/run/halo/app/extension/GroupKind.java new file mode 100644 index 000000000..d1fc85043 --- /dev/null +++ b/src/main/java/run/halo/app/extension/GroupKind.java @@ -0,0 +1,11 @@ +package run.halo.app.extension; + +/** + * GroupKind contains group and kind data only. + * + * @param group is group name of Extension. + * @param kind is kind name of Extension. + * @author johnniang + */ +public record GroupKind(String group, String kind) { +} diff --git a/src/main/java/run/halo/app/extension/GroupVersion.java b/src/main/java/run/halo/app/extension/GroupVersion.java new file mode 100644 index 000000000..bf00f933b --- /dev/null +++ b/src/main/java/run/halo/app/extension/GroupVersion.java @@ -0,0 +1,40 @@ +package run.halo.app.extension; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * GroupVersion contains group and version name of an Extension only. + * + * @param group is group name of Extension. + * @param version is version name of Extension. + * @author johnniang + */ +public record GroupVersion(String group, String version) { + + @Override + public String toString() { + return StringUtils.hasText(group) ? group + "/" + version : version; + } + + /** + * Parses APIVersion into GroupVersion record. + * + * @param apiVersion must not be blank. + * 1. If the given apiVersion does not contain any "/", we treat the group is empty. + * 2. If the given apiVersion contains more than 1 "/", we will throw an + * IllegalArgumentException. + * @return record contains group and version. + */ + public static GroupVersion parseAPIVersion(String apiVersion) { + Assert.hasText(apiVersion, "API version must not be blank"); + + var groupVersion = apiVersion.split("/"); + return switch (groupVersion.length) { + case 1 -> new GroupVersion("", apiVersion); + case 2 -> new GroupVersion(groupVersion[0], groupVersion[1]); + default -> + throw new IllegalArgumentException("Unexpected APIVersion string: " + apiVersion); + }; + } +} diff --git a/src/main/java/run/halo/app/extension/GroupVersionKind.java b/src/main/java/run/halo/app/extension/GroupVersionKind.java new file mode 100644 index 000000000..63490fc04 --- /dev/null +++ b/src/main/java/run/halo/app/extension/GroupVersionKind.java @@ -0,0 +1,42 @@ +package run.halo.app.extension; + +import org.springframework.util.Assert; + +/** + * GroupVersionKind contains group, version and kind name of an Extension. + * + * @param group is group name of Extension. + * @param version is version name of Extension. + * @param kind is kind name of Extension. + * @author johnniang + */ +public record GroupVersionKind(String group, String version, String kind) { + + public GroupVersionKind { + Assert.hasText(version, "Version must not be blank"); + Assert.hasText(kind, "Kind must not be blank"); + } + + /** + * Gets group and version name of Extension. + * + * @return group and version name of Extension. + */ + public GroupVersion groupVersion() { + return new GroupVersion(group, version); + } + + /** + * Composes GroupVersionKind from API version and kind name. + * + * @param apiVersion is API version. Like "core.halo.run/v1alpha1" + * @param kind is kind name of Extension. + * @return GroupVersionKind of an Extension. + */ + public static GroupVersionKind fromAPIVersionAndKind(String apiVersion, String kind) { + Assert.hasText(kind, "Kind must not be blank"); + + var gv = GroupVersion.parseAPIVersion(apiVersion); + return new GroupVersionKind(gv.group(), gv.version(), kind); + } +} diff --git a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java new file mode 100644 index 000000000..c716d162d --- /dev/null +++ b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -0,0 +1,51 @@ +package run.halo.app.extension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import org.springframework.stereotype.Component; +import run.halo.app.extension.exception.ExtensionConvertException; +import run.halo.app.extension.store.ExtensionStore; + +/** + * JSON implementation of ExtensionConverter. + * + * @author johnniang + */ +@Component +public class JSONExtensionConverter implements ExtensionConverter { + + private final ObjectMapper objectMapper; + + public JSONExtensionConverter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public ExtensionStore convertTo(E extension) { + var scheme = Schemes.INSTANCE.get(extension.getClass()); + var storeName = ExtensionUtil.buildStoreName(scheme, extension.metadata().getName()); + try { + // TODO Validate the extension in ExtensionClient + // keep converting + var data = objectMapper.writeValueAsBytes(extension); + var version = extension.metadata().getVersion(); + return new ExtensionStore(storeName, data, version); + } catch (JsonProcessingException e) { + throw new ExtensionConvertException("Failed write Extension as bytes", e); + } + } + + @Override + public E convertFrom(Class type, ExtensionStore extensionStore) { + try { + var extension = objectMapper.readValue(extensionStore.getData(), type); + extension.metadata().setVersion(extensionStore.getVersion()); + return extension; + } catch (IOException e) { + throw new ExtensionConvertException("Failed to read Extension " + type + " from bytes", + e); + } + } + +} diff --git a/src/main/java/run/halo/app/extension/Metadata.java b/src/main/java/run/halo/app/extension/Metadata.java new file mode 100644 index 000000000..da4774bcd --- /dev/null +++ b/src/main/java/run/halo/app/extension/Metadata.java @@ -0,0 +1,45 @@ +package run.halo.app.extension; + +import java.time.Instant; +import java.util.Map; +import lombok.Data; + +/** + * Metadata of Extension. + * + * @author johnniang + */ +@Data +public class Metadata { + + /** + * Metadata name. The name is unique globally. + */ + private String name; + + /** + * Labels are like key-value format. + */ + private Map labels; + + /** + * Annotations are like key-value format. + */ + private Map annotations; + + /** + * Current version of the Extension. It will be bumped up every update. + */ + private Long version; + + /** + * Creation timestamp of the Extension. + */ + private Instant creationTimestamp; + + /** + * Deletion timestamp of the Extension. + */ + private Instant deletionTimestamp; + +} diff --git a/src/main/java/run/halo/app/extension/Scheme.java b/src/main/java/run/halo/app/extension/Scheme.java new file mode 100644 index 000000000..3e13ae9df --- /dev/null +++ b/src/main/java/run/halo/app/extension/Scheme.java @@ -0,0 +1,31 @@ +package run.halo.app.extension; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.util.Assert; + +/** + * This class represents scheme of an Extension. + * + * @param type is Extension type. + * @param groupVersionKind is GroupVersionKind of Extension. + * @param plural is plural name of Extension. + * @param singular is singular name of Extension. + * @param jsonSchema is JSON schema of Extension. + * @author johnniang + */ +public record Scheme(Class type, + GroupVersionKind groupVersionKind, + String plural, + String singular, + ObjectNode jsonSchema) { + public Scheme { + Assert.notNull(type, "Type of Extension must not be null"); + Assert.notNull(groupVersionKind, "GroupVersionKind of Extension must not be null"); + Assert.hasText(plural, "Plural name of Extension must not be blank"); + Assert.hasText(singular, "Singular name of Extension must not be blank"); + + //TODO Validate the json schema when we plan to integrate Extension validation. + // Assert.notNull(jsonSchema, "Json Schema must not be null"); + } + +} diff --git a/src/main/java/run/halo/app/extension/Schemes.java b/src/main/java/run/halo/app/extension/Schemes.java new file mode 100644 index 000000000..cb8c38e77 --- /dev/null +++ b/src/main/java/run/halo/app/extension/Schemes.java @@ -0,0 +1,118 @@ +package run.halo.app.extension; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import run.halo.app.extension.exception.ExtensionException; +import run.halo.app.extension.exception.SchemeNotFoundException; + +/** + * Schemes is aggregation of schemes and responsible for managing and organizing schemes. + * + * @author johnniang + */ +public enum Schemes { + + INSTANCE; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * All registered schemes. + */ + private final Set schemes; + + /** + * The map mapping type and scheme of Extension. + */ + private final Map, Scheme> typeToScheme; + + /** + * The map mapping GroupVersionKind and type of Extension. + */ + private final Map> gvkToType; + + Schemes() { + schemes = new HashSet<>(); + typeToScheme = new HashMap<>(); + gvkToType = new HashMap<>(); + } + + /** + * Clear registered schemes. + *

+ * This method is only for test. + */ + void clear() { + schemes.clear(); + typeToScheme.clear(); + gvkToType.clear(); + } + + /** + * Registers an Extension using its type. + * + * @param type is Extension type. + * @param Extension class. + */ + public void register(Class type) { + // concrete scheme from annotation + var gvk = type.getAnnotation(GVK.class); + if (gvk == null) { + // should never happen + throw new ExtensionException( + String.format("Annotation %s needs to be on Extension %s", GVK.class.getName(), + type.getName())); + } + + // TODO Generate the JSON schema here + var scheme = new Scheme(type, new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()), + gvk.plural(), gvk.singular(), null); + + register(scheme); + } + + /** + * Registers a Scheme of Extension. + * + * @param scheme is fresh scheme of Extension. + */ + public void register(Scheme scheme) { + boolean added = schemes.add(scheme); + if (!added) { + logger.warn("Scheme " + scheme + + " has been registered before, please check the repeat register."); + return; + } + typeToScheme.put(scheme.type(), scheme); + gvkToType.put(scheme.groupVersionKind(), scheme.type()); + } + + /** + * Fetches a Scheme using Extension type. + * + * @param type is Extension type. + * @return an optional Scheme. + */ + public Optional fetch(Class type) { + return Optional.ofNullable(typeToScheme.get(type)); + } + + + /** + * Gets a scheme using Extension type. + * + * @param type is Extension type. + * @return non-null Extension scheme. + * @throws SchemeNotFoundException when the Extension is not found. + */ + public Scheme get(Class type) { + return fetch(type).orElseThrow(() -> new SchemeNotFoundException( + "Scheme was not found for Extension " + type.getSimpleName())); + } + +} diff --git a/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java b/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java new file mode 100644 index 000000000..406a4cd99 --- /dev/null +++ b/src/main/java/run/halo/app/extension/exception/ExtensionConvertException.java @@ -0,0 +1,29 @@ +package run.halo.app.extension.exception; + +/** + * ExtensionConvertException is thrown when an Extension conversion error occurs. + * + * @author johnniang + */ +public class ExtensionConvertException extends ExtensionException { + + public ExtensionConvertException() { + } + + public ExtensionConvertException(String message) { + super(message); + } + + public ExtensionConvertException(String message, Throwable cause) { + super(message, cause); + } + + public ExtensionConvertException(Throwable cause) { + super(cause); + } + + public ExtensionConvertException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/run/halo/app/extension/exception/ExtensionException.java b/src/main/java/run/halo/app/extension/exception/ExtensionException.java new file mode 100644 index 000000000..66ad8d699 --- /dev/null +++ b/src/main/java/run/halo/app/extension/exception/ExtensionException.java @@ -0,0 +1,30 @@ +package run.halo.app.extension.exception; + +/** + * ExtensionException is the superclass of those exceptions that can be thrown by Extension module. + * + * @author johnniang + */ +public class ExtensionException extends RuntimeException { + + public ExtensionException() { + } + + public ExtensionException(String message) { + super(message); + } + + public ExtensionException(String message, Throwable cause) { + super(message, cause); + } + + public ExtensionException(Throwable cause) { + super(cause); + } + + public ExtensionException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + +} diff --git a/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java b/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java new file mode 100644 index 000000000..9eb52ff08 --- /dev/null +++ b/src/main/java/run/halo/app/extension/exception/SchemeNotFoundException.java @@ -0,0 +1,29 @@ +package run.halo.app.extension.exception; + +/** + * SchemeNotFoundException is thrown while we try to get a scheme but not found. + * + * @author johnniang + */ +public class SchemeNotFoundException extends ExtensionException { + + public SchemeNotFoundException() { + } + + public SchemeNotFoundException(String message) { + super(message); + } + + public SchemeNotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public SchemeNotFoundException(Throwable cause) { + super(cause); + } + + public SchemeNotFoundException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/run/halo/app/extension/store/ExtensionStore.java b/src/main/java/run/halo/app/extension/store/ExtensionStore.java new file mode 100644 index 000000000..c239958a0 --- /dev/null +++ b/src/main/java/run/halo/app/extension/store/ExtensionStore.java @@ -0,0 +1,55 @@ +package run.halo.app.extension.store; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Version; +import lombok.Data; + +/** + * ExtensionStore is an entity for storing Extension data into database. + * + * @author johnniang + */ +@Data +@Entity(name = "extensions") +public class ExtensionStore { + + /** + * Extension store name, which is globally unique. + * We will use it to query Extensions by using left-like query clause. + */ + @Id + private String name; + + /** + * Exactly Extension body, which might be base64 format. + */ + @Lob + private byte[] data; + + /** + * This field only for serving optimistic lock value. + */ + @Version + private Long version; + + public ExtensionStore() { + } + + public ExtensionStore(String name, byte[] data) { + this.name = name; + this.data = data; + } + + public ExtensionStore(String name, Long version) { + this.name = name; + this.version = version; + } + + public ExtensionStore(String name, byte[] data, Long version) { + this.name = name; + this.data = data; + this.version = version; + } +} diff --git a/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java b/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java new file mode 100644 index 000000000..e1ad8e8df --- /dev/null +++ b/src/main/java/run/halo/app/extension/store/ExtensionStoreClient.java @@ -0,0 +1,58 @@ +package run.halo.app.extension.store; + +import java.util.List; +import java.util.Optional; + +/** + * An interface to query and operate ExtensionStore. + * + * @author johnniang + */ +public interface ExtensionStoreClient { + + /** + * Lists all ExtensionStores by name prefix. + * + * @param prefix is the prefix of ExtensionStore name. + * @return all ExtensionStores which names start with the prefix. + */ + List listByNamePrefix(String prefix); + + /** + * Fetches an ExtensionStore by unique name. + * + * @param name is the full name of an ExtensionStore. + * @return an optional ExtensionStore. + */ + Optional fetchByName(String name); + + /** + * Creates an ExtensionStore. + * + * @param name is the full name of an ExtensionStore. + * @param data is Extension body to be persisted. + * @return a fresh ExtensionStore created just now. + */ + ExtensionStore create(String name, byte[] data); + + /** + * Updates an ExtensionStore with version to prevent concurrent update. + * + * @param name is the full name of an ExtensionStore. + * @param version is the expected version of ExtensionStore. + * @param data is Extension body to be updated. + * @return updated ExtensionStore with a fresh version. + */ + ExtensionStore update(String name, Long version, byte[] data); + + /** + * Deletes an ExtensionStore by name and current version. + * + * @param name is the full name of an ExtensionStore. + * @param version is the expected version of ExtensionStore. + * @return previous ExtensionStore. + */ + ExtensionStore delete(String name, Long version); + + //TODO add watch method here. +} diff --git a/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java b/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java new file mode 100644 index 000000000..83b77ef06 --- /dev/null +++ b/src/main/java/run/halo/app/extension/store/ExtensionStoreClientJPAImpl.java @@ -0,0 +1,53 @@ +package run.halo.app.extension.store; + +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** + * An implementation of ExtensionStoreClient using JPA. + * + * @author johnniang + */ +@Service +public class ExtensionStoreClientJPAImpl implements ExtensionStoreClient { + + private final ExtensionStoreRepository repository; + + public ExtensionStoreClientJPAImpl(ExtensionStoreRepository repository) { + this.repository = repository; + } + + @Override + public List listByNamePrefix(String prefix) { + return repository.findAllByNameStartingWith(prefix); + } + + @Override + public Optional fetchByName(String name) { + return repository.findById(name); + } + + @Override + public ExtensionStore create(String name, byte[] data) { + var store = new ExtensionStore(name, data); + return repository.save(store); + } + + @Override + public ExtensionStore update(String name, Long version, byte[] data) { + var store = new ExtensionStore(name, data, version); + return repository.save(store); + } + + @Override + public ExtensionStore delete(String name, Long version) { + var extensionStore = + repository.findById(name).orElseThrow(EntityNotFoundException::new); + extensionStore.setVersion(version); + repository.delete(extensionStore); + return extensionStore; + } + +} diff --git a/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java b/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java new file mode 100644 index 000000000..5cc17c63f --- /dev/null +++ b/src/main/java/run/halo/app/extension/store/ExtensionStoreRepository.java @@ -0,0 +1,23 @@ +package run.halo.app.extension.store; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * This repository contains some basic operations on ExtensionStore entity. + * + * @author johnniang + */ +@Repository +public interface ExtensionStoreRepository extends JpaRepository { + + /** + * Finds all ExtensionStore by name prefix. + * + * @param prefix is the prefix of name. + * @return all ExtensionStores which names starts with the given prefix. + */ + List findAllByNameStartingWith(String prefix); + +} diff --git a/src/test/java/run/halo/app/extension/AbstractExtensionTest.java b/src/test/java/run/halo/app/extension/AbstractExtensionTest.java new file mode 100644 index 000000000..ececa5ee3 --- /dev/null +++ b/src/test/java/run/halo/app/extension/AbstractExtensionTest.java @@ -0,0 +1,52 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class AbstractExtensionTest { + + @Test + void groupVersionKind() { + var extension = new AbstractExtension() { + }; + extension.setApiVersion("fake.halo.run/v1alpha1"); + extension.setKind("Fake"); + var gvk = extension.groupVersionKind(); + + assertEquals(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), gvk); + } + + @Test + void testGroupVersionKind() { + var extension = new AbstractExtension() { + }; + extension.groupVersionKind(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake")); + + assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion()); + assertEquals("Fake", extension.getKind()); + } + + @Test + void metadata() { + var extension = new AbstractExtension() { + }; + Metadata metadata = new Metadata(); + metadata.setName("fake"); + extension.setMetadata(metadata); + + assertEquals(metadata, extension.getMetadata()); + } + + @Test + void testMetadata() { + var extension = new AbstractExtension() { + }; + + Metadata metadata = new Metadata(); + metadata.setName("fake"); + extension.metadata(metadata); + + assertEquals(metadata, extension.getMetadata()); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java b/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java new file mode 100644 index 000000000..50129c334 --- /dev/null +++ b/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java @@ -0,0 +1,241 @@ +package run.halo.app.extension; + +import static java.util.Collections.emptyList; +import static java.util.Collections.reverseOrder; +import static java.util.Comparator.comparing; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import run.halo.app.extension.exception.SchemeNotFoundException; +import run.halo.app.extension.store.ExtensionStore; +import run.halo.app.extension.store.ExtensionStoreClient; + +@ExtendWith(MockitoExtension.class) +class DefaultExtensionClientTest { + + @Mock + ExtensionStoreClient storeClient; + + @Mock + ExtensionConverter converter; + + @InjectMocks + DefaultExtensionClient client; + + @BeforeAll + static void before() { + Schemes.INSTANCE.register(FakeExtension.class); + } + + FakeExtension createFakeExtension(String name, Long version) { + var fake = new FakeExtension(); + var metadata = new Metadata(); + metadata.setName(name); + metadata.setVersion(version); + + fake.setMetadata(metadata); + fake.setApiVersion("fake.halo.run/v1alpha1"); + fake.setKind("Fake"); + + return fake; + } + + ExtensionStore createExtensionStore(String name) { + var extensionStore = new ExtensionStore(); + extensionStore.setName(name); + return extensionStore; + } + + @Test + void shouldThrowSchemeNotFoundExceptionWhenSchemeNotRegistered() { + class UnRegisteredExtension extends AbstractExtension { + } + + assertThrows(SchemeNotFoundException.class, + () -> client.list(UnRegisteredExtension.class, null, null)); + assertThrows(SchemeNotFoundException.class, + () -> client.page(UnRegisteredExtension.class, null, null, 0, 10)); + assertThrows(SchemeNotFoundException.class, + () -> client.fetch(UnRegisteredExtension.class, "fake")); + assertThrows(SchemeNotFoundException.class, () -> { + when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); + client.create(createFakeExtension("fake", null)); + }); + assertThrows(SchemeNotFoundException.class, () -> { + when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); + client.update(createFakeExtension("fake", 1L)); + }); + assertThrows(SchemeNotFoundException.class, () -> { + when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class); + client.delete(createFakeExtension("fake", 1L)); + }); + } + + @Test + void shouldReturnEmptyExtensions() { + when(storeClient.listByNamePrefix(anyString())).thenReturn(emptyList()); + var fakes = client.list(FakeExtension.class, null, null); + assertEquals(emptyList(), fakes); + } + + @Test + void shouldReturnExtensionsWithFilterAndSorter() { + var fake1 = createFakeExtension("fake-01", 1L); + var fake2 = createFakeExtension("fake-02", 1L); + + when( + converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn( + fake1); + when( + converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn( + fake2); + when(storeClient.listByNamePrefix(anyString())).thenReturn( + List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02"))); + + // without filter and sorter + var fakes = client.list(FakeExtension.class, null, null); + assertEquals(List.of(fake1, fake2), fakes); + + // with filter + fakes = client.list(FakeExtension.class, fake -> { + String name = fake.getMetadata().getName(); + return "fake-01".equals(name); + }, null); + assertEquals(List.of(fake1), fakes); + + // with sorter + fakes = client.list(FakeExtension.class, null, + reverseOrder(comparing(fake -> fake.getMetadata().getName()))); + assertEquals(List.of(fake2, fake1), fakes); + } + + @Test + void shouldQueryPageableAndCorrectly() { + var fake1 = createFakeExtension("fake-01", 1L); + var fake2 = createFakeExtension("fake-02", 1L); + var fake3 = createFakeExtension("fake-03", 1L); + + when( + converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn( + fake1); + when( + converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn( + fake2); + when( + converter.convertFrom(FakeExtension.class, createExtensionStore("fake-03"))).thenReturn( + fake3); + + when(storeClient.listByNamePrefix(anyString())).thenReturn( + List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02"), + createExtensionStore("fake-03"))); + + // without filter and sorter. + var fakes = client.page(FakeExtension.class, null, null, 0, 10); + assertEquals(new PageImpl<>(List.of(fake1, fake2, fake3), PageRequest.of(0, 10), 3), fakes); + + // out of page range + fakes = client.page(FakeExtension.class, null, null, 100, 10); + assertEquals(new PageImpl<>(emptyList(), PageRequest.of(100, 10), 3), fakes); + + // with filter only + fakes = + client.page(FakeExtension.class, fake -> "fake-03".equals(fake.getMetadata().getName()), + null, 0, 10); + assertEquals(new PageImpl<>(List.of(fake3), PageRequest.of(0, 10), 1), fakes); + + // with sorter only + fakes = client.page(FakeExtension.class, null, + reverseOrder(comparing(fake -> fake.getMetadata().getName())), 0, 10); + assertEquals(new PageImpl<>(List.of(fake3, fake2, fake1), PageRequest.of(0, 10), 3), fakes); + } + + @Test + void shouldFetchNothing() { + when(storeClient.fetchByName(any())).thenReturn(Optional.empty()); + + Optional fake = client.fetch(FakeExtension.class, "fake"); + + assertEquals(Optional.empty(), fake); + verify(converter, times(0)).convertFrom(any(), any()); + verify(storeClient, times(1)).fetchByName(any()); + } + + @Test + void shouldFetchAnExtension() { + var storeName = "/registry/fake.halo.run/fakes/fake"; + when(storeClient.fetchByName(storeName)).thenReturn( + Optional.of(createExtensionStore(storeName))); + + when( + converter.convertFrom(FakeExtension.class, createExtensionStore(storeName))).thenReturn( + createFakeExtension("fake", 1L)); + + Optional fake = client.fetch(FakeExtension.class, "fake"); + assertEquals(Optional.of(createFakeExtension("fake", 1L)), fake); + + verify(storeClient, times(1)).fetchByName(eq(storeName)); + verify(converter, times(1)).convertFrom(eq(FakeExtension.class), + eq(createExtensionStore(storeName))); + } + + @Test + void shouldCreateSuccessfully() { + var fake = createFakeExtension("fake", null); + when(converter.convertTo(any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + when(storeClient.create(any(), any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + + client.create(fake); + + verify(converter, times(1)).convertTo(any()); + verify(storeClient, times(1)).create(any(), any()); + assertNotNull(fake.getMetadata().getCreationTimestamp()); + } + + @Test + void shouldUpdateSuccessfully() { + var fake = createFakeExtension("fake", 2L); + when(converter.convertTo(any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + when(storeClient.update(any(), any(), any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + + client.update(fake); + + verify(converter, times(1)).convertTo(any()); + verify(storeClient, times(1)).update(any(), any(), any()); + } + + @Test + void shouldDeleteSuccessfully() { + var fake = createFakeExtension("fake", 2L); + when(converter.convertTo(any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + when(storeClient.delete(any(), any())).thenReturn( + createExtensionStore("/registry/fake.halo.run/fakes/fake")); + + client.delete(fake); + + verify(converter, times(1)).convertTo(any()); + verify(storeClient, times(1)).delete(any(), any()); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/ExtensionUtilTest.java b/src/test/java/run/halo/app/extension/ExtensionUtilTest.java new file mode 100644 index 000000000..5ea9d2712 --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionUtilTest.java @@ -0,0 +1,46 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ExtensionUtilTest { + + Scheme scheme; + + Scheme grouplessScheme; + + @BeforeEach + void setUp() { + scheme = new Scheme(FakeExtension.class, + new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), + "fakes", + "fake", + null); + grouplessScheme = new Scheme(FakeExtension.class, + new GroupVersionKind("", "v1alpha1", "Fake"), + "fakes", + "fake", + null); + } + + @Test + void buildStoreNamePrefix() { + var prefix = ExtensionUtil.buildStoreNamePrefix(scheme); + assertEquals("/registry/fake.halo.run/fakes", prefix); + + prefix = ExtensionUtil.buildStoreNamePrefix(grouplessScheme); + assertEquals("/registry/fakes", prefix); + } + + @Test + void buildStoreName() { + var storeName = ExtensionUtil.buildStoreName(scheme, "fake-name"); + assertEquals("/registry/fake.halo.run/fakes/fake-name", storeName); + + storeName = ExtensionUtil.buildStoreName(grouplessScheme, "fake-name"); + assertEquals("/registry/fakes/fake-name", storeName); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/FakeExtension.java b/src/test/java/run/halo/app/extension/FakeExtension.java new file mode 100644 index 000000000..1af835883 --- /dev/null +++ b/src/test/java/run/halo/app/extension/FakeExtension.java @@ -0,0 +1,9 @@ +package run.halo.app.extension; + +@GVK(group = "fake.halo.run", + version = "v1alpha1", + kind = "Fake", + plural = "fakes", + singular = "fake") +class FakeExtension extends AbstractExtension { +} diff --git a/src/test/java/run/halo/app/extension/GroupVersionTest.java b/src/test/java/run/halo/app/extension/GroupVersionTest.java new file mode 100644 index 000000000..91edfecd7 --- /dev/null +++ b/src/test/java/run/halo/app/extension/GroupVersionTest.java @@ -0,0 +1,29 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class GroupVersionTest { + + @Test + void shouldThrowIllegalArgumentExceptionWhenAPIVersionIsIllegal() { + assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(null), + "apiVersion is null"); + assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(""), + "apiVersion is empty"); + assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion(" "), + "apiVersion is blank"); + assertThrows(IllegalArgumentException.class, () -> GroupVersion.parseAPIVersion("a/b/c"), + "apiVersion contains more than 1 '/'"); + } + + @Test + void shouldReturnGroupVersionCorrectly() { + assertEquals(new GroupVersion("", "v1"), GroupVersion.parseAPIVersion("v1"), + "only contains version"); + assertEquals(new GroupVersion("core.halo.run", "v1"), + GroupVersion.parseAPIVersion("core.halo.run/v1"), "only contains version"); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java b/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java new file mode 100644 index 000000000..c0c83c34f --- /dev/null +++ b/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java @@ -0,0 +1,81 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.extension.exception.ExtensionConvertException; +import run.halo.app.extension.store.ExtensionStore; + +@ExtendWith(MockitoExtension.class) +class JSONExtensionConverterTest { + + JSONExtensionConverter converter; + + ObjectMapper objectMapper; + + @BeforeAll + static void beforeAll() { + Schemes.INSTANCE.register(FakeExtension.class); + } + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + converter = new JSONExtensionConverter(objectMapper); + } + + @Test + void convertTo() throws IOException { + var fake = createFakeExtension("fake", 10L); + + var extensionStore = converter.convertTo(fake); + + assertEquals("/registry/fake.halo.run/fakes/fake", extensionStore.getName()); + assertEquals(10L, extensionStore.getVersion()); + assertEquals(fake, objectMapper.readValue(extensionStore.getData(), FakeExtension.class)); + } + + @Test + void convertFrom() throws JsonProcessingException { + var fake = createFakeExtension("fake", 20L); + + var store = new ExtensionStore(); + store.setName("/registry/fake.halo.run/fakes/fake"); + store.setVersion(20L); + store.setData(objectMapper.writeValueAsBytes(fake)); + + FakeExtension gotFake = converter.convertFrom(FakeExtension.class, store); + assertEquals(fake, gotFake); + } + + @Test + void shouldThrowExceptionWhenDataIsInvalid() { + var store = new ExtensionStore(); + store.setName("/registry/fake.halo.run/fakes/fake"); + store.setVersion(20L); + store.setData("{".getBytes()); + + assertThrows(ExtensionConvertException.class, + () -> converter.convertFrom(FakeExtension.class, store)); + } + + + FakeExtension createFakeExtension(String name, Long version) { + var fake = new FakeExtension(); + fake.groupVersionKind(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake")); + Metadata metadata = new Metadata(); + metadata.setName(name); + metadata.setVersion(version); + fake.metadata(metadata); + + return fake; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/SchemeTest.java b/src/test/java/run/halo/app/extension/SchemeTest.java new file mode 100644 index 000000000..0ce9481d8 --- /dev/null +++ b/src/test/java/run/halo/app/extension/SchemeTest.java @@ -0,0 +1,33 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +class SchemeTest { + + @Test + void requiredFieldTest() { + assertThrows(IllegalArgumentException.class, + () -> new Scheme(null, new GroupVersionKind("", "v1alpha1", ""), "", "", null)); + assertThrows(IllegalArgumentException.class, + () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "", ""), "", "", + null)); + assertThrows(IllegalArgumentException.class, + () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", ""), "", + "", null)); + assertThrows(IllegalArgumentException.class, + () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "", + "", null)); + assertThrows(IllegalArgumentException.class, + () -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), + "fakes", "", null)); + + new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", + "fake", null); + new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes", + "fake", new ObjectNode(null)); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/SchemesTest.java b/src/test/java/run/halo/app/extension/SchemesTest.java new file mode 100644 index 000000000..684fe28f6 --- /dev/null +++ b/src/test/java/run/halo/app/extension/SchemesTest.java @@ -0,0 +1,47 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import run.halo.app.extension.exception.ExtensionException; + +class SchemesTest { + + + @AfterEach + void cleanUp() { + Schemes.INSTANCE.clear(); + } + + @Test + void testRegister() { + Schemes.INSTANCE.register(FakeExtension.class); + } + + @Test + void shouldThrowExceptionWithoutGVKAnnotation() { + class WithoutGVKExtension extends AbstractExtension { + } + + assertThrows(ExtensionException.class, + () -> Schemes.INSTANCE.register(WithoutGVKExtension.class)); + } + + @Test + void shouldFetchNothingWhenUnregistered() { + var scheme = Schemes.INSTANCE.fetch(FakeExtension.class); + assertEquals(Optional.empty(), scheme); + } + + @Test + void shouldFetchFakeWhenRegistered() { + Schemes.INSTANCE.register(FakeExtension.class); + + var scheme = Schemes.INSTANCE.fetch(FakeExtension.class); + assertTrue(scheme.isPresent()); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/store/ExtensionStoreClientJPAImplTest.java b/src/test/java/run/halo/app/extension/store/ExtensionStoreClientJPAImplTest.java new file mode 100644 index 000000000..e5fadfa71 --- /dev/null +++ b/src/test/java/run/halo/app/extension/store/ExtensionStoreClientJPAImplTest.java @@ -0,0 +1,102 @@ +package run.halo.app.extension.store; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExtensionStoreClientJPAImplTest { + + @Mock + ExtensionStoreRepository repository; + + @InjectMocks + ExtensionStoreClientJPAImpl client; + + @Test + void listByNamePrefix() { + var expectedExtensions = List.of( + new ExtensionStore("/registry/posts/hello-world", "this is post".getBytes(), 1L), + new ExtensionStore("/registry/posts/hello-halo", "this is post".getBytes(), 1L) + ); + + when(repository.findAllByNameStartingWith("/registry/posts")) + .thenReturn(expectedExtensions); + + var gotExtensions = client.listByNamePrefix("/registry/posts"); + assertEquals(expectedExtensions, gotExtensions); + } + + @Test + void fetchByName() { + var expectedExtension = + new ExtensionStore("/registry/posts/hello-world", "this is post".getBytes(), 1L); + + when(repository.findById("/registry/posts/hello-halo")).thenReturn( + Optional.of(expectedExtension)); + + var gotExtension = client.fetchByName("/registry/posts/hello-halo"); + assertTrue(gotExtension.isPresent()); + assertEquals(expectedExtension, gotExtension.get()); + } + + @Test + void create() { + var expectedExtension = + new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); + + when(repository.save(any())).thenReturn(expectedExtension); + + var createdExtension = + client.create("/registry/posts/hello-halo", "hello halo".getBytes()); + + assertEquals(expectedExtension, createdExtension); + } + + @Test + void update() { + var expectedExtension = + new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); + + when(repository.save(any())).thenReturn(expectedExtension); + + var updatedExtension = + client.update("/registry/posts/hello-halo", 1L, "hello halo".getBytes()); + + assertEquals(expectedExtension, updatedExtension); + } + + @Test + void shouldThrowEntityNotFoundExceptionWhenDeletingNonExistExt() { + + when(repository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> client.delete("/registry/posts/hello-halo", 1L)); + } + + @Test + void shouldDeleteSuccessfully() { + var expectedExtension = + new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L); + + when(repository.findById(any())).thenReturn(Optional.of(expectedExtension)); + doNothing().when(repository).delete(any()); + + var deletedExtension = client.delete("/registry/posts/hello-halo", 2L); + + assertEquals(expectedExtension, deletedExtension); + } +} \ No newline at end of file