mirror of https://github.com/halo-dev/halo
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 importpull/2093/head
parent
be2c0654a2
commit
264f9e39cb
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 <E extends Extension> List<E> list(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> 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 <E extends Extension> Page<E> page(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> 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 <E extends Extension> Optional<E> fetch(Class<E> 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 <E extends Extension> void create(E extension) {
|
||||
extension.metadata().setCreationTimestamp(Instant.now());
|
||||
var extensionStore = converter.convertTo(extension);
|
||||
storeClient.create(extensionStore.getName(), extensionStore.getData());
|
||||
}
|
||||
|
||||
@Override
|
||||
public <E extends Extension> 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 <E extends Extension> void delete(E extension) {
|
||||
ExtensionStore extensionStore = converter.convertTo(extension);
|
||||
storeClient.delete(extensionStore.getName(), extensionStore.getVersion());
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -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 <E> is Extension type.
|
||||
* @return all filtered and sorted Extensions.
|
||||
*/
|
||||
<E extends Extension> List<E> list(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> 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 <E> is Extension type.
|
||||
* @return a page of Extensions.
|
||||
*/
|
||||
<E extends Extension> Page<E> page(Class<E> type, Predicate<E> predicate,
|
||||
Comparator<E> comparator, int page, int size);
|
||||
|
||||
/**
|
||||
* Fetches Extension by its type and name.
|
||||
*
|
||||
* @param type is Extension type.
|
||||
* @param name is Extension name.
|
||||
* @param <E> is Extension type.
|
||||
* @return an optional Extension.
|
||||
*/
|
||||
<E extends Extension> Optional<E> fetch(Class<E> type, String name);
|
||||
|
||||
|
||||
/**
|
||||
* Creates an Extension.
|
||||
*
|
||||
* @param extension is fresh Extension to be created. Please make sure the Extension name does
|
||||
* not exist.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> void create(E extension);
|
||||
|
||||
/**
|
||||
* Updates an Extension.
|
||||
*
|
||||
* @param extension is an Extension to be updated. Please make sure the resource version is
|
||||
* latest.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> void update(E extension);
|
||||
|
||||
/**
|
||||
* Deletes an Extension.
|
||||
*
|
||||
* @param extension is an Extension to be deleted. Please make sure the resource version is
|
||||
* latest.
|
||||
* @param <E> is Extension type.
|
||||
*/
|
||||
<E extends Extension> void delete(E extension);
|
||||
|
||||
}
|
|
@ -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 <E> is Extension type.
|
||||
* @return an ExtensionStore.
|
||||
*/
|
||||
<E extends Extension> ExtensionStore convertTo(E extension);
|
||||
|
||||
/**
|
||||
* Converts Extension from ExtensionStore.
|
||||
*
|
||||
* @param type is Extension type.
|
||||
* @param extensionStore is an ExtensionStore
|
||||
* @param <E> is Extension type.
|
||||
* @return an Extension
|
||||
*/
|
||||
<E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore);
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 <E extends Extension> 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 extends Extension> E convertFrom(Class<E> 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, String> labels;
|
||||
|
||||
/**
|
||||
* Annotations are like key-value format.
|
||||
*/
|
||||
private Map<String, String> 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;
|
||||
|
||||
}
|
|
@ -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<? extends Extension> 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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Scheme> schemes;
|
||||
|
||||
/**
|
||||
* The map mapping type and scheme of Extension.
|
||||
*/
|
||||
private final Map<Class<? extends Extension>, Scheme> typeToScheme;
|
||||
|
||||
/**
|
||||
* The map mapping GroupVersionKind and type of Extension.
|
||||
*/
|
||||
private final Map<GroupVersionKind, Class<? extends Extension>> gvkToType;
|
||||
|
||||
Schemes() {
|
||||
schemes = new HashSet<>();
|
||||
typeToScheme = new HashMap<>();
|
||||
gvkToType = new HashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear registered schemes.
|
||||
* <p>
|
||||
* 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 <T> Extension class.
|
||||
*/
|
||||
public <T extends Extension> void register(Class<T> 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<Scheme> fetch(Class<? extends Extension> 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<? extends Extension> type) {
|
||||
return fetch(type).orElseThrow(() -> new SchemeNotFoundException(
|
||||
"Scheme was not found for Extension " + type.getSimpleName()));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<ExtensionStore> listByNamePrefix(String prefix);
|
||||
|
||||
/**
|
||||
* Fetches an ExtensionStore by unique name.
|
||||
*
|
||||
* @param name is the full name of an ExtensionStore.
|
||||
* @return an optional ExtensionStore.
|
||||
*/
|
||||
Optional<ExtensionStore> 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.
|
||||
}
|
|
@ -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<ExtensionStore> listByNamePrefix(String prefix) {
|
||||
return repository.findAllByNameStartingWith(prefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ExtensionStore> 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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<ExtensionStore, String> {
|
||||
|
||||
/**
|
||||
* Finds all ExtensionStore by name prefix.
|
||||
*
|
||||
* @param prefix is the prefix of name.
|
||||
* @return all ExtensionStores which names starts with the given prefix.
|
||||
*/
|
||||
List<ExtensionStore> findAllByNameStartingWith(String prefix);
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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<FakeExtension> 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<FakeExtension> 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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue