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
pull/2093/head
John Niang 2022-05-17 18:36:13 +08:00 committed by GitHub
parent be2c0654a2
commit 264f9e39cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1621 additions and 0 deletions

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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) {
}

View File

@ -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);
};
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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");
}
}

View File

@ -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()));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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.
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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 {
}

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}