Add feature to generate APIs for schemes automatically (#2158)

* Add ExtensionEndpointInstaller

* Refactor Schemes with SchemeManager

* Add some unit tests

1. Add ExtensionCompositeRouterFunctionTest
2. Add ExtensionConfigurationTest
3. Refactor Unstructured
4. Fix bad ObjectMapper in Json converter.

* Fix bad scheme registration
pull/2161/head
John Niang 2022-06-16 11:10:14 +08:00 committed by GitHub
parent fcbf0031a4
commit e52db6859f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1711 additions and 299 deletions

View File

@ -1,4 +0,0 @@
@startuml
ExceptionHandlingWebHandler -> FilteringWebHandler
FilteringWebHandler contains filters and DispatcherHandler
@enduml

View File

@ -0,0 +1,43 @@
package run.halo.app.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.extension.DefaultExtensionClient;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.DefaultSchemeWatcherManager;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionCompositeRouterFunction;
import run.halo.app.extension.JSONExtensionConverter;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.SchemeWatcherManager;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
import run.halo.app.extension.store.ExtensionStoreClient;
@Configuration(proxyBeanMethods = false)
public class ExtensionConfiguration {
@Bean
RouterFunction<ServerResponse> extensionsRouterFunction(ExtensionClient client,
SchemeWatcherManager watcherManager) {
return new ExtensionCompositeRouterFunction(client, watcherManager);
}
@Bean
ExtensionClient extensionClient(ExtensionStoreClient storeClient, SchemeManager schemeManager) {
var converter = new JSONExtensionConverter(schemeManager);
return new DefaultExtensionClient(storeClient, converter, schemeManager);
}
@Bean
SchemeManager schemeManager(SchemeWatcherManager watcherManager, List<SchemeWatcher> watchers) {
return new DefaultSchemeManager(watcherManager);
}
@Bean
SchemeWatcherManager schemeWatcherManager() {
return new DefaultSchemeWatcherManager();
}
}

View File

@ -16,4 +16,15 @@ public abstract class AbstractExtension implements Extension {
private MetadataOperator metadata;
@Override
public String getApiVersion() {
var apiVersionFromGvk = Extension.super.getApiVersion();
return apiVersionFromGvk != null ? apiVersionFromGvk : this.apiVersion;
}
@Override
public String getKind() {
var kindFromGvk = Extension.super.getKind();
return kindFromGvk != null ? kindFromGvk : this.kind;
}
}

View File

@ -8,7 +8,6 @@ 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;
@ -18,21 +17,25 @@ import run.halo.app.extension.store.ExtensionStoreClient;
*
* @author johnniang
*/
@Service
public class DefaultExtensionClient implements ExtensionClient {
private final ExtensionStoreClient storeClient;
private final ExtensionConverter converter;
public DefaultExtensionClient(ExtensionStoreClient storeClient, ExtensionConverter converter) {
private final SchemeManager schemeManager;
public DefaultExtensionClient(ExtensionStoreClient storeClient,
ExtensionConverter converter,
SchemeManager schemeManager) {
this.storeClient = storeClient;
this.converter = converter;
this.schemeManager = schemeManager;
}
@Override
public <E extends Extension> List<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator) {
var scheme = Schemes.INSTANCE.get(type);
var scheme = schemeManager.get(type);
var storeNamePrefix = ExtensionUtil.buildStoreNamePrefix(scheme);
var storesStream = storeClient.listByNamePrefix(storeNamePrefix).stream()
@ -59,7 +62,7 @@ public class DefaultExtensionClient implements ExtensionClient {
@Override
public <E extends Extension> Optional<E> fetch(Class<E> type, String name) {
var scheme = Schemes.INSTANCE.get(type);
var scheme = schemeManager.get(type);
var storeName = ExtensionUtil.buildStoreName(scheme, name);
return storeClient.fetchByName(storeName)
@ -68,7 +71,9 @@ public class DefaultExtensionClient implements ExtensionClient {
@Override
public <E extends Extension> void create(E extension) {
extension.getMetadata().setCreationTimestamp(Instant.now());
var metadata = extension.getMetadata();
metadata.setCreationTimestamp(Instant.now());
// extension.setMetadata(metadata);
var extensionStore = converter.convertTo(extension);
storeClient.create(extensionStore.getName(), extensionStore.getData());
}

View File

@ -0,0 +1,53 @@
package run.halo.app.extension;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered;
import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered;
public class DefaultSchemeManager implements SchemeManager {
private final List<Scheme> schemes;
@Nullable
private final SchemeWatcherManager watcherManager;
public DefaultSchemeManager(@Nullable SchemeWatcherManager watcherManager) {
this.watcherManager = watcherManager;
schemes = new LinkedList<>();
}
@Override
public void register(@NonNull Scheme scheme) {
if (!schemes.contains(scheme)) {
schemes.add(scheme);
getWatchers().forEach(watcher -> watcher.onChange(new SchemeRegistered(scheme)));
}
}
@Override
public void unregister(@NonNull Scheme scheme) {
if (schemes.contains(scheme)) {
schemes.remove(scheme);
getWatchers().forEach(watcher -> watcher.onChange(new SchemeUnregistered(scheme)));
}
}
@Override
@NonNull
public List<Scheme> schemes() {
return Collections.unmodifiableList(schemes);
}
@NonNull
private List<SchemeWatcherManager.SchemeWatcher> getWatchers() {
if (this.watcherManager == null) {
return Collections.emptyList();
}
return Optional.ofNullable(this.watcherManager.watchers()).orElse(Collections.emptyList());
}
}

View File

@ -0,0 +1,33 @@
package run.halo.app.extension;
import java.util.LinkedList;
import java.util.List;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
public class DefaultSchemeWatcherManager implements SchemeWatcherManager {
private final List<SchemeWatcher> watchers;
public DefaultSchemeWatcherManager() {
watchers = new LinkedList<>();
}
@Override
public void register(@NonNull SchemeWatcher watcher) {
Assert.notNull(watcher, "Scheme watcher must not be null");
watchers.add(watcher);
}
@Override
public void unregister(@NonNull SchemeWatcher watcher) {
Assert.notNull(watcher, "Scheme watcher must not be null");
watchers.remove(watcher);
}
@Override
public List<SchemeWatcher> watchers() {
// we have to copy the watchers entirely to prevent concurrent modification.
return List.copyOf(watchers);
}
}

View File

@ -0,0 +1,60 @@
package run.halo.app.extension;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.lang.NonNull;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
public class ExtensionCompositeRouterFunction implements
RouterFunction<ServerResponse>, SchemeWatcher {
private final Map<Scheme, RouterFunction<ServerResponse>> schemeRouterFuncMapper;
private final ExtensionClient client;
public ExtensionCompositeRouterFunction(ExtensionClient client,
SchemeWatcherManager watcherManager) {
this.client = client;
schemeRouterFuncMapper = new ConcurrentHashMap<>();
if (watcherManager != null) {
watcherManager.register(this);
}
}
@Override
@NonNull
public Mono<HandlerFunction<ServerResponse>> route(@NonNull ServerRequest request) {
return Flux.fromIterable(getRouterFunctions())
.concatMap(routerFunction -> routerFunction.route(request))
.next();
}
@Override
public void accept(@NonNull RouterFunctions.Visitor visitor) {
getRouterFunctions().forEach(routerFunction -> routerFunction.accept(visitor));
}
private Iterable<RouterFunction<ServerResponse>> getRouterFunctions() {
// TODO Copy router functions here
return Collections.unmodifiableCollection(schemeRouterFuncMapper.values());
}
@Override
public void onChange(SchemeWatcherManager.ChangeEvent event) {
if (event instanceof SchemeWatcherManager.SchemeRegistered registeredEvent) {
var scheme = registeredEvent.getNewScheme();
var factory = new ExtensionRouterFunctionFactory(scheme, client);
this.schemeRouterFuncMapper.put(scheme, factory.create());
} else if (event instanceof SchemeWatcherManager.SchemeUnregistered unregisteredEvent) {
this.schemeRouterFuncMapper.remove(unregisteredEvent.getDeletedScheme());
}
}
}

View File

@ -3,6 +3,7 @@ package run.halo.app.extension;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.util.StringUtils;
/**
* ExtensionOperator contains some getters and setters for required fields of Extension.
@ -13,11 +14,28 @@ public interface ExtensionOperator {
@Schema(required = true)
@JsonProperty("apiVersion")
String getApiVersion();
default String getApiVersion() {
final var gvk = getClass().getAnnotation(GVK.class);
if (gvk == null) {
// return null if having no GVK annotation
return null;
}
if (StringUtils.hasText(gvk.group())) {
return gvk.group() + "/" + gvk.version();
}
return gvk.version();
}
@Schema(required = true)
@JsonProperty("kind")
String getKind();
default String getKind() {
final var gvk = getClass().getAnnotation(GVK.class);
if (gvk == null) {
// return null if having no GVK annotation
return null;
}
return gvk.kind();
}
@Schema(required = true, implementation = Metadata.class)
@JsonProperty("metadata")
@ -46,7 +64,7 @@ public interface ExtensionOperator {
* {@link #setMetadata(MetadataOperator)}.
*
* @param metadata is Extension metadata.
* @see #setMetadata(MetadataOperator)
* @see #setMetadata(MetadataOperator)
*/
@Deprecated(forRemoval = true)
default void metadata(MetadataOperator metadata) {

View File

@ -0,0 +1,162 @@
package run.halo.app.extension;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
public class ExtensionRouterFunctionFactory {
private final Scheme scheme;
private final ExtensionClient client;
public ExtensionRouterFunctionFactory(Scheme scheme, ExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@NonNull
public RouterFunction<ServerResponse> create() {
var getHandler = new ExtensionGetHandler(scheme, client);
var listHandler = new ExtensionListHandler(scheme, client);
var createHandler = new ExtensionCreateHandler(scheme, client);
// TODO More handlers here
return route()
.GET(getHandler.pathPattern(), getHandler)
.GET(listHandler.pathPattern(), listHandler)
.POST(createHandler.pathPattern(), createHandler)
.build();
}
interface PathPatternGenerator {
String pathPattern();
static String buildExtensionPathPattern(Scheme scheme) {
var gvk = scheme.groupVersionKind();
StringBuilder pattern = new StringBuilder();
if (gvk.hasGroup()) {
pattern.append("/apis/").append(gvk.group());
} else {
pattern.append("/api");
}
return pattern.append('/').append(gvk.version()).append('/').append(scheme.plural())
.toString();
}
}
interface GetHandler extends HandlerFunction<ServerResponse>, PathPatternGenerator {
}
interface ListHandler extends HandlerFunction<ServerResponse>, PathPatternGenerator {
}
interface CreateHandler extends HandlerFunction<ServerResponse>, PathPatternGenerator {
}
static class ExtensionCreateHandler implements CreateHandler {
private final Scheme scheme;
private final ExtensionClient client;
public ExtensionCreateHandler(Scheme scheme, ExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@Override
@NonNull
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
return request.bodyToMono(Unstructured.class)
.switchIfEmpty(Mono.error(() -> new ExtensionConvertException(
"Cannot read body to " + scheme.groupVersionKind())))
.doOnSuccess(client::create)
.map(unstructured ->
client.fetch(scheme.type(), unstructured.getMetadata().getName())
.orElseThrow(() -> new ExtensionNotFoundException(
scheme.groupVersionKind() + " " + unstructured.getMetadata().getName()
+ "was not found")))
.flatMap(extension -> ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(extension))
.cast(ServerResponse.class);
}
@Override
public String pathPattern() {
return PathPatternGenerator.buildExtensionPathPattern(scheme);
}
}
static class ExtensionListHandler implements ListHandler {
private final Scheme scheme;
private final ExtensionClient client;
public ExtensionListHandler(Scheme scheme, ExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@Override
@NonNull
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
// TODO Resolve predicate and comparator from request
var extensions = client.list(scheme.type(), null, null);
return ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(extensions);
}
@Override
public String pathPattern() {
return PathPatternGenerator.buildExtensionPathPattern(scheme);
}
}
static class ExtensionGetHandler implements GetHandler {
private final Scheme scheme;
private final ExtensionClient client;
public ExtensionGetHandler(Scheme scheme, ExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@Override
public String pathPattern() {
return PathPatternGenerator.buildExtensionPathPattern(scheme) + "/{name}";
}
@Override
@NonNull
public Mono<ServerResponse> handle(@NonNull ServerRequest request) {
var extensionName = request.pathVariable("name");
var extension = client.fetch(scheme.type(), extensionName)
.orElseThrow(() -> new ExtensionNotFoundException(
scheme.groupVersionKind() + " was not found"));
return ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(extension);
}
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.extension;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* GroupVersionKind contains group, version and kind name of an Extension.
@ -26,6 +27,10 @@ public record GroupVersionKind(String group, String version, String kind) {
return new GroupVersion(group, version);
}
public boolean hasGroup() {
return StringUtils.hasText(group);
}
/**
* Composes GroupVersionKind from API version and kind name.
*

View File

@ -1,12 +1,13 @@
package run.halo.app.extension;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import run.halo.app.extension.exception.ExtensionConvertException;
@ -23,18 +24,28 @@ public class JSONExtensionConverter implements ExtensionConverter {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final ObjectMapper objectMapper;
public static final ObjectMapper OBJECT_MAPPER;
private final JsonSchemaFactory jsonSchemaFactory;
public JSONExtensionConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
private final SchemeManager schemeManager;
static {
OBJECT_MAPPER = Jackson2ObjectMapperBuilder.json()
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToDisable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS)
.build();
}
public JSONExtensionConverter(SchemeManager schemeManager) {
this.schemeManager = schemeManager;
jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
}
@Override
public <E extends Extension> ExtensionStore convertTo(E extension) {
var gvk = extension.groupVersionKind();
var scheme = Schemes.INSTANCE.get(gvk);
var scheme = schemeManager.get(gvk);
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
try {
if (logger.isDebugEnabled()) {
@ -42,9 +53,10 @@ public class JSONExtensionConverter implements ExtensionConverter {
scheme.jsonSchema().toPrettyString());
}
var data = OBJECT_MAPPER.writeValueAsBytes(extension);
var validator = jsonSchemaFactory.getSchema(scheme.jsonSchema());
var extensionNode = objectMapper.valueToTree(extension);
var errors = validator.validate(extensionNode);
var errors = validator.validate(OBJECT_MAPPER.readTree(data));
if (!CollectionUtils.isEmpty(errors)) {
if (logger.isDebugEnabled()) {
// only print the errors when debug mode is enabled
@ -55,11 +67,9 @@ public class JSONExtensionConverter implements ExtensionConverter {
"Failed to validate Extension " + extension.getClass(), errors);
}
// keep converting
var data = objectMapper.writeValueAsBytes(extensionNode);
var version = extension.getMetadata().getVersion();
return new ExtensionStore(storeName, data, version);
} catch (JsonProcessingException e) {
} catch (IOException e) {
throw new ExtensionConvertException("Failed write Extension as bytes", e);
}
}
@ -67,7 +77,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
@Override
public <E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore) {
try {
var extension = objectMapper.readValue(extensionStore.getData(), type);
var extension = OBJECT_MAPPER.readValue(extensionStore.getData(), type);
extension.getMetadata().setVersion(extensionStore.getVersion());
return extension;
} catch (IOException e) {

View File

@ -3,6 +3,7 @@ package run.halo.app.extension;
import java.time.Instant;
import java.util.Map;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Metadata of Extension.
@ -10,6 +11,7 @@ import lombok.Data;
* @author johnniang
*/
@Data
@EqualsAndHashCode
public class Metadata implements MetadataOperator {
/**

View File

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/**
* MetadataOperator contains some getters and setters for required fields of metadata.
@ -51,4 +52,31 @@ public interface MetadataOperator {
void setDeletionTimestamp(Instant deletionTimestamp);
static boolean metadataDeepEquals(MetadataOperator left, MetadataOperator right) {
if (left == null && right == null) {
return true;
}
if (left == null || right == null) {
return false;
}
if (!Objects.equals(left.getName(), right.getName())) {
return false;
}
if (!Objects.equals(left.getLabels(), right.getLabels())) {
return false;
}
if (!Objects.equals(left.getAnnotations(), right.getAnnotations())) {
return false;
}
if (!Objects.equals(left.getCreationTimestamp(), right.getCreationTimestamp())) {
return false;
}
if (!Objects.equals(left.getDeletionTimestamp(), right.getDeletionTimestamp())) {
return false;
}
if (!Objects.equals(left.getVersion(), right.getVersion())) {
return false;
}
return true;
}
}

View File

@ -1,7 +1,15 @@
package run.halo.app.extension;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Option;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.exception.ExtensionException;
/**
* This class represents scheme of an Extension.
@ -26,4 +34,55 @@ public record Scheme(Class<? extends Extension> type,
Assert.notNull(jsonSchema, "Json Schema must not be null");
}
/**
* Builds Scheme from type with @GVK annotation.
*
* @param type is Extension type with GVK annotation.
* @return Scheme definition.
* @throws ExtensionException when the type has not annotated @GVK.
*/
public static Scheme buildFromType(Class<? extends Extension> type) {
// concrete scheme from annotation
var gvk = getGvkFromType(type);
// TODO Move the generation logic outside.
// generate JSON schema
var module = new Swagger2Module();
var config =
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
.with(
// See https://victools.github.io/jsonschema-generator/#generator-options
// fore more.
Option.INLINE_ALL_SCHEMAS,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES
)
.with(module)
.build();
var generator = new SchemaGenerator(config);
var jsonSchema = generator.generateSchema(type);
return new Scheme(type,
new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()),
gvk.plural(),
gvk.singular(),
jsonSchema);
}
/**
* Gets GVK annotation from Extension type.
*
* @param type is Extension type with GVK annotation.
* @return GVK annotation.
* @throws ExtensionException when the type has not annotated @GVK.
*/
@NonNull
public static GVK getGvkFromType(@NonNull Class<? extends Extension> type) {
var gvk = type.getAnnotation(GVK.class);
if (gvk == null) {
throw new ExtensionException(
String.format("Annotation %s needs to be on Extension %s", GVK.class.getName(),
type.getName()));
}
return gvk;
}
}

View File

@ -0,0 +1,58 @@
package run.halo.app.extension;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.lang.NonNull;
import run.halo.app.extension.exception.SchemeNotFoundException;
public interface SchemeManager {
void register(@NonNull Scheme scheme);
/**
* Registers an Extension using its type.
*
* @param type is Extension type.
* @param <T> Extension class.
*/
default <T extends Extension> void register(Class<T> type) {
register(Scheme.buildFromType(type));
}
void unregister(@NonNull Scheme scheme);
default int size() {
return schemes().size();
}
@NonNull
List<Scheme> schemes();
@NonNull
default Optional<Scheme> fetch(@NonNull GroupVersionKind gvk) {
return schemes().stream()
.filter(scheme -> Objects.equals(scheme.groupVersionKind(), gvk))
.findFirst();
}
@NonNull
default Scheme get(@NonNull GroupVersionKind gvk) {
return fetch(gvk).orElseThrow(
() -> new SchemeNotFoundException("Scheme was not found for " + gvk));
}
@NonNull
default Scheme get(Class<? extends Extension> type) {
var gvk = Scheme.getGvkFromType(type);
return get(new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()));
}
@NonNull
default Scheme get(Extension ext) {
var gvk = ext.groupVersionKind();
return get(gvk);
}
}

View File

@ -0,0 +1,49 @@
package run.halo.app.extension;
import java.util.List;
import org.springframework.lang.NonNull;
public interface SchemeWatcherManager {
void register(@NonNull SchemeWatcher watcher);
void unregister(@NonNull SchemeWatcher watcher);
List<SchemeWatcher> watchers();
interface SchemeWatcher {
void onChange(ChangeEvent event);
}
interface ChangeEvent {
}
class SchemeRegistered implements ChangeEvent {
private final Scheme newScheme;
public SchemeRegistered(Scheme newScheme) {
this.newScheme = newScheme;
}
public Scheme getNewScheme() {
return newScheme;
}
}
class SchemeUnregistered implements ChangeEvent {
private final Scheme deletedScheme;
public SchemeUnregistered(Scheme deletedScheme) {
this.deletedScheme = deletedScheme;
}
public Scheme getDeletedScheme() {
return deletedScheme;
}
}
}

View File

@ -1,147 +0,0 @@
package run.halo.app.extension;
import com.github.victools.jsonschema.generator.Option;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
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, Scheme> gvkToScheme;
Schemes() {
schemes = new HashSet<>();
typeToScheme = new HashMap<>();
gvkToScheme = new HashMap<>();
}
/**
* Clear registered schemes.
* This method is only for test.
*/
void clear() {
schemes.clear();
typeToScheme.clear();
gvkToScheme.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 Move the generation logic outside.
// generate JSON schema
var module = new Swagger2Module();
var config =
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
.with(
// See https://victools.github.io/jsonschema-generator/#generator-options
// fore more.
Option.INLINE_ALL_SCHEMAS,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES
)
.with(module)
.build();
var generator = new SchemaGenerator(config);
var jsonSchema = generator.generateSchema(type);
var scheme = new Scheme(type, new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()),
gvk.plural(), gvk.singular(), jsonSchema);
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);
gvkToScheme.put(scheme.groupVersionKind(), scheme);
}
/**
* 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));
}
public Optional<Scheme> fetch(GroupVersionKind gvk) {
return Optional.ofNullable(gvkToScheme.get(gvk));
}
/**
* 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()));
}
public Scheme get(GroupVersionKind gvk) {
return fetch(gvk).orElseThrow(() -> new SchemeNotFoundException(
"Scheme was not found for GVK " + gvk));
}
}

View File

@ -4,15 +4,17 @@ import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* Unstructured is a generic Extension, which wraps ObjectNode to maintain the Extension data, like
@ -22,68 +24,187 @@ import java.io.IOException;
*/
@JsonSerialize(using = Unstructured.UnstructuredSerializer.class)
@JsonDeserialize(using = Unstructured.UnstructuredDeserializer.class)
@SuppressWarnings("rawtypes")
public class Unstructured implements Extension {
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public static final ObjectMapper OBJECT_MAPPER = JSONExtensionConverter.OBJECT_MAPPER;
static {
OBJECT_MAPPER.registerModule(new JavaTimeModule());
}
private final ObjectNode extension;
private final Map data;
public Unstructured() {
this(OBJECT_MAPPER.createObjectNode());
this(new HashMap());
}
public Unstructured(ObjectNode extension) {
this.extension = extension;
public Unstructured(Map data) {
this.data = data;
}
@Override
public String getApiVersion() {
return extension.get("apiVersion").asText();
return (String) data.get("apiVersion");
}
@Override
public String getKind() {
return extension.get("kind").asText();
return (String) data.get("kind");
}
@Override
public MetadataOperator getMetadata() {
var metaMap = extension.get("metadata");
return OBJECT_MAPPER.convertValue(metaMap, Metadata.class);
return new UnstructuredMetadata();
}
class UnstructuredMetadata implements MetadataOperator {
@Override
public String getName() {
return (String) getNestedValue(data, "metadata", "name").orElse(null);
}
@Override
public Map<String, String> getLabels() {
return getNestedStringStringMap(data, "metadata", "labels").orElse(null);
}
@Override
public Map<String, String> getAnnotations() {
return getNestedStringStringMap(data, "metadata", "annotations").orElse(null);
}
@Override
public Long getVersion() {
return getNestedLong(data, "metadata", "version").orElse(null);
}
@Override
public Instant getCreationTimestamp() {
return getNestedInstant(data, "metadata", "creationTimestamp").orElse(null);
}
@Override
public Instant getDeletionTimestamp() {
return getNestedInstant(data, "metadata", "deletionTimestamp").orElse(null);
}
@Override
public void setName(String name) {
setNestedValue(data, name, "metadata", "name");
}
@Override
public void setLabels(Map<String, String> labels) {
setNestedValue(data, labels, "metadata", "labels");
}
@Override
public void setAnnotations(Map<String, String> annotations) {
setNestedValue(data, annotations, "metadata", "annotations");
}
@Override
public void setVersion(Long version) {
setNestedValue(data, version, "metadata", "version");
}
@Override
public void setCreationTimestamp(Instant creationTimestamp) {
setNestedValue(data, creationTimestamp, "metadata", "creationTimestamp");
}
@Override
public void setDeletionTimestamp(Instant deletionTimestamp) {
setNestedValue(data, deletionTimestamp, "metadata", "deletionTimestamp");
}
}
@Override
public void setApiVersion(String apiVersion) {
extension.put("apiVersion", apiVersion);
setNestedValue(data, apiVersion, "apiVersion");
}
@Override
public void setKind(String kind) {
extension.put("kind", kind);
setNestedValue(data, kind, "kind");
}
@Override
@SuppressWarnings("unchecked")
public void setMetadata(MetadataOperator metadata) {
JsonNode metaNode = OBJECT_MAPPER.valueToTree(metadata);
extension.set("metadata", metaNode);
Map metadataMap = OBJECT_MAPPER.convertValue(metadata, Map.class);
data.put("metadata", metadataMap);
}
ObjectNode getExtension() {
return extension;
static Optional<Object> getNestedValue(Map map, String... fields) {
if (fields == null || fields.length == 0) {
return Optional.of(map);
}
Map tempMap = map;
for (int i = 0; i < fields.length - 1; i++) {
Object value = tempMap.get(fields[i]);
if (!(value instanceof Map)) {
return Optional.empty();
}
tempMap = (Map<?, ?>) value;
}
return Optional.ofNullable(tempMap.get(fields[fields.length - 1]));
}
// TODO Add other convenient methods here to set and get nested fields in the future.
@SuppressWarnings("unchecked")
static void setNestedValue(Map map, Object value, String... fields) {
if (fields == null || fields.length == 0) {
// do nothing when no fields provided
return;
}
var prevFields = Arrays.stream(fields, 0, fields.length - 1)
.toArray(String[]::new);
getNestedMap(map, prevFields).ifPresent(m -> {
var lastField = fields[fields.length - 1];
m.put(lastField, value);
});
}
static Optional<Map> getNestedMap(Map map, String... fields) {
return getNestedValue(map, fields).map(value -> (Map) value);
}
@SuppressWarnings("unchecked")
static Optional<Map<String, String>> getNestedStringStringMap(Map map, String... fields) {
return getNestedValue(map, fields)
.map(labelsObj -> {
var labels = (Map) labelsObj;
var result = new HashMap<String, String>();
labels.forEach((key, value) -> result.put((String) key, (String) value));
return result;
});
}
static Optional<Instant> getNestedInstant(Map map, String... fields) {
return getNestedValue(map, fields)
.map(instantValue -> {
if (instantValue instanceof Instant instant) {
return instant;
}
return Instant.parse(instantValue.toString());
});
}
static Optional<Long> getNestedLong(Map map, String... fields) {
return getNestedValue(map, fields)
.map(longObj -> {
if (longObj instanceof Long l) {
return l;
}
return Long.valueOf(longObj.toString());
});
}
public static class UnstructuredSerializer extends JsonSerializer<Unstructured> {
@Override
public void serialize(Unstructured value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeTree(value.extension);
gen.writeObject(value.data);
}
}
@ -93,7 +214,8 @@ public class Unstructured implements Extension {
@Override
public Unstructured deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
return new Unstructured(p.getCodec().readTree(p));
Map data = p.getCodec().readValue(p, Map.class);
return new Unstructured(data);
}
}

View File

@ -0,0 +1,24 @@
package run.halo.app.extension.exception;
public class ExtensionNotFoundException extends ExtensionException {
public ExtensionNotFoundException() {
}
public ExtensionNotFoundException(String message) {
super(message);
}
public ExtensionNotFoundException(String message, Throwable cause) {
super(message, cause);
}
public ExtensionNotFoundException(Throwable cause) {
super(cause);
}
public ExtensionNotFoundException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}

View File

@ -2,19 +2,28 @@ package run.halo.app.infra;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.extension.Schemes;
import run.halo.app.extension.SchemeManager;
import run.halo.app.plugin.Plugin;
import run.halo.app.security.authentication.pat.PersonalAccessToken;
import run.halo.app.security.authorization.Role;
import run.halo.app.security.authorization.RoleBinding;
@Component
public class SchemeInitializer implements ApplicationListener<ApplicationStartedEvent> {
private final SchemeManager schemeManager;
public SchemeInitializer(SchemeManager schemeManager) {
this.schemeManager = schemeManager;
}
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
Schemes.INSTANCE.register(Role.class);
Schemes.INSTANCE.register(PersonalAccessToken.class);
Schemes.INSTANCE.register(Plugin.class);
public void onApplicationEvent(@NonNull ApplicationStartedEvent event) {
schemeManager.register(Role.class);
schemeManager.register(RoleBinding.class);
schemeManager.register(PersonalAccessToken.class);
schemeManager.register(Plugin.class);
}
}

View File

@ -6,7 +6,7 @@ import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Schemes;
import run.halo.app.extension.SchemeManager;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
import run.halo.app.plugin.resources.ReverseProxy;
@ -20,12 +20,12 @@ public class PluginLoadedListener implements ApplicationListener<HaloPluginLoade
private static final String REVERSE_PROXY_NAME = "extensions/reverseProxy.yaml";
private final ExtensionClient extensionClient;
public PluginLoadedListener(ExtensionClient extensionClient) {
public PluginLoadedListener(ExtensionClient extensionClient, SchemeManager schemeManager) {
this.extensionClient = extensionClient;
// TODO Optimize schemes register
Schemes.INSTANCE.register(Plugin.class);
Schemes.INSTANCE.register(ReverseProxy.class);
schemeManager.register(Plugin.class);
schemeManager.register(ReverseProxy.class);
}
@Override

View File

@ -49,6 +49,10 @@ public class PolicyRule {
*/
String[] verbs;
public PolicyRule() {
this(null, null, null, null, null);
}
public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames,
String[] nonResourceURLs, String[] verbs) {
this.apiGroups = nullElseEmpty(apiGroups);

View File

@ -0,0 +1,146 @@
package run.halo.app.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.reactive.server.WebTestClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeManager;
import run.halo.app.security.authorization.PolicyRule;
import run.halo.app.security.authorization.Role;
import run.halo.app.security.authorization.RoleGetter;
@SpringBootTest
@AutoConfigureWebTestClient
@AutoConfigureTestDatabase
class ExtensionConfigurationTest {
@Autowired
WebTestClient webClient;
@Autowired
SchemeManager schemeManager;
@MockBean
RoleGetter roleGetter;
@BeforeEach
void setUp() {
// disable authorization
var rule = new PolicyRule();
rule.setApiGroups(new String[] {"*"});
rule.setResources(new String[] {"*"});
rule.setVerbs(new String[] {"*"});
var role = new Role();
role.setRules(List.of(rule));
when(roleGetter.getRole(anyString())).thenReturn(role);
}
@AfterEach
void cleanUp() {
schemeManager.fetch(Scheme.buildFromType(FakeExtension.class).groupVersionKind())
.ifPresent(schemeManager::unregister);
}
@Test
@WithMockUser
void shouldReturnNotFoundWhenSchemeNotRegistered() {
webClient.get()
.uri("/apis/fake.halo.run/v1alpha1/fakes")
.exchange()
.expectStatus().isNotFound();
webClient.get()
.uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake")
.exchange()
.expectStatus().isNotFound();
webClient.post()
.uri("/apis/fake.halo.run/v1alpha1/fakes")
.bodyValue(new FakeExtension())
.exchange()
.expectStatus().isNotFound();
}
@Test
@WithMockUser
void shouldListExtensionsWhenSchemeRegistered() {
schemeManager.register(FakeExtension.class);
webClient.get()
.uri("/apis/fake.halo.run/v1alpha1/fakes")
.exchange()
.expectStatus().isOk();
}
@Test
@WithMockUser
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
void shouldCreateExtensionWhenSchemeRegistered() {
schemeManager.register(FakeExtension.class);
getCreateExtensionResponse()
.expectStatus().isOk()
.expectBody(FakeExtension.class)
.consumeWith(result -> {
var gotFake = result.getResponseBody();
assertNotNull(gotFake);
assertEquals("my-fake", gotFake.getMetadata().getName());
assertNotNull(gotFake.getMetadata().getVersion());
assertNotNull(gotFake.getMetadata().getCreationTimestamp());
});
}
@Test
@WithMockUser
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
void shouldGetExtensionWhenSchemeRegistered() {
schemeManager.register(FakeExtension.class);
// create the Extension
getCreateExtensionResponse().expectStatus().isOk();
webClient.get()
.uri("/apis/fake.halo.run/v1alpha1/fakes/my-fake")
.exchange()
.expectStatus().isOk()
.expectBody(FakeExtension.class)
.consumeWith(result -> {
var gotFake = result.getResponseBody();
assertNotNull(gotFake);
assertEquals("my-fake", gotFake.getMetadata().getName());
assertNotNull(gotFake.getMetadata().getVersion());
assertNotNull(gotFake.getMetadata().getCreationTimestamp());
});
}
WebTestClient.ResponseSpec getCreateExtensionResponse() {
var metadata = new Metadata();
metadata.setName("my-fake");
var fake = new FakeExtension();
fake.setMetadata(metadata);
return webClient.post()
.uri("/apis/fake.halo.run/v1alpha1/fakes")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fake)
.exchange();
}
}

View File

@ -9,6 +9,7 @@ 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.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -16,7 +17,7 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
import java.util.Optional;
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.InjectMocks;
@ -37,12 +38,16 @@ class DefaultExtensionClientTest {
@Mock
ExtensionConverter converter;
@Mock
SchemeManager schemeManager;
@InjectMocks
DefaultExtensionClient client;
@BeforeAll
static void before() {
Schemes.INSTANCE.register(FakeExtension.class);
@BeforeEach
void setUp() {
lenient().when(schemeManager.get(eq(FakeExtension.class)))
.thenReturn(Scheme.buildFromType(FakeExtension.class));
}
FakeExtension createFakeExtension(String name, Long version) {
@ -93,6 +98,9 @@ class DefaultExtensionClientTest {
class UnRegisteredExtension extends AbstractExtension {
}
when(schemeManager.get(eq(UnRegisteredExtension.class)))
.thenThrow(SchemeNotFoundException.class);
assertThrows(SchemeNotFoundException.class,
() -> client.list(UnRegisteredExtension.class, null, null));
assertThrows(SchemeNotFoundException.class,

View File

@ -0,0 +1,120 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
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 run.halo.app.extension.SchemeWatcherManager.SchemeRegistered;
import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
import run.halo.app.extension.exception.ExtensionException;
import run.halo.app.extension.exception.SchemeNotFoundException;
@ExtendWith(MockitoExtension.class)
class DefaultSchemeManagerTest {
@Mock
SchemeWatcherManager watcherManager;
@InjectMocks
DefaultSchemeManager schemeManager;
@Test
void shouldThrowExceptionWhenNoGvkAnnotation() {
class WithoutGvkExtension extends AbstractExtension {
}
assertThrows(ExtensionException.class,
() -> schemeManager.register(WithoutGvkExtension.class));
}
@Test
void shouldGetNothingWhenUnregistered() {
final var gvk = new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake");
var scheme = schemeManager.fetch(gvk);
assertFalse(scheme.isPresent());
assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(gvk));
assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(FakeExtension.class));
assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(new FakeExtension()));
}
@Test
void shouldGetSchemeWhenRegistered() {
schemeManager.register(FakeExtension.class);
final var gvk = new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake");
var scheme = schemeManager.fetch(gvk);
assertTrue(scheme.isPresent());
assertEquals(gvk, schemeManager.get(gvk).groupVersionKind());
assertEquals(gvk, schemeManager.get(FakeExtension.class).groupVersionKind());
assertEquals(gvk, schemeManager.get(new FakeExtension()).groupVersionKind());
}
@Test
void shouldUnregisterSuccessfully() {
schemeManager.register(FakeExtension.class);
Scheme scheme = schemeManager.get(FakeExtension.class);
assertNotNull(scheme);
schemeManager.unregister(scheme);
assertThrows(SchemeNotFoundException.class, () -> schemeManager.get(FakeExtension.class));
}
@Test
void shouldTriggerOnChangeOnlyOnceWhenRegisterTwice() {
final var watcher = mock(SchemeWatcher.class);
when(watcherManager.watchers()).thenReturn(List.of(watcher));
schemeManager.register(FakeExtension.class);
verify(watcherManager, times(1)).watchers();
verify(watcher, times(1)).onChange(isA(SchemeRegistered.class));
schemeManager.register(FakeExtension.class);
verify(watcherManager, times(1)).watchers();
verify(watcher, times(1)).onChange(isA(SchemeRegistered.class));
}
@Test
void shouldTriggerOnChangeOnlyOnceWhenUnregisterTwice() {
final var watcher = mock(SchemeWatcher.class);
when(watcherManager.watchers()).thenReturn(List.of(watcher));
schemeManager.register(FakeExtension.class);
var scheme = schemeManager.get(FakeExtension.class);
schemeManager.unregister(scheme);
verify(watcherManager, times(2)).watchers();
verify(watcher, times(1)).onChange(isA(SchemeUnregistered.class));
schemeManager.unregister(scheme);
verify(watcherManager, times(2)).watchers();
verify(watcher, times(1)).onChange(isA(SchemeUnregistered.class));
}
@Test
void getSizeOfSchemes() {
assertEquals(0, schemeManager.size());
schemeManager.register(FakeExtension.class);
assertEquals(1, schemeManager.size());
schemeManager.unregister(schemeManager.get(FakeExtension.class));
assertEquals(0, schemeManager.size());
}
}

View File

@ -0,0 +1,63 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
class DefaultSchemeWatcherManagerTest {
DefaultSchemeWatcherManager watcherManager;
@BeforeEach
void setUp() {
watcherManager = new DefaultSchemeWatcherManager();
}
@Test
void shouldThrowExceptionWhenRegisterNullWatcher() {
assertThrows(IllegalArgumentException.class, () -> watcherManager.register(null));
}
@Test
void shouldThrowExceptionWhenUnregisterNullWatcher() {
assertThrows(IllegalArgumentException.class, () -> watcherManager.unregister(null));
}
@Test
void shouldRegisterSuccessfully() {
var watcher = mock(SchemeWatcher.class);
watcherManager.register(watcher);
assertEquals(watcherManager.watchers(), List.of(watcher));
}
@Test
void shouldUnregisterSuccessfully() {
var watcher = mock(SchemeWatcher.class);
watcherManager.register(watcher);
assertEquals(List.of(watcher), watcherManager.watchers());
watcherManager.unregister(watcher);
assertEquals(Collections.emptyList(), watcherManager.watchers());
}
@Test
void shouldReturnCopyOfWatchers() {
var watcher = mock(SchemeWatcher.class);
watcherManager.register(watcher);
assertEquals(List.of(watcher), watcherManager.watchers());
var watchersBeforeRegister = watcherManager.watchers();
watcherManager.unregister(watcher);
// watchers are not changed even if unregistered
assertEquals(List.of(watcher), watchersBeforeRegister);
assertEquals(Collections.emptyList(), watcherManager.watchers());
}
}

View File

@ -0,0 +1,79 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered;
import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered;
@ExtendWith(MockitoExtension.class)
class ExtensionCompositeRouterFunctionTest {
@Mock
ExtensionClient client;
@Test
void shouldRouteWhenSchemeRegistered() {
var extensionRouterFunc = new ExtensionCompositeRouterFunction(client, null);
var exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build());
var messageReaders = HandlerStrategies.withDefaults().messageReaders();
ServerRequest request = ServerRequest.create(exchange, messageReaders);
var handlerFunc = extensionRouterFunc.route(request).block();
assertNull(handlerFunc);
// trigger registering scheme
extensionRouterFunc.onChange(
new SchemeRegistered(Scheme.buildFromType(FakeExtension.class)));
handlerFunc = extensionRouterFunc.route(request).block();
assertNotNull(handlerFunc);
}
@Test
void shouldNotRouteWhenSchemeUnregistered() {
var extensionRouterFunc = new ExtensionCompositeRouterFunction(client, null);
var exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build());
var messageReaders = HandlerStrategies.withDefaults().messageReaders();
// trigger registering scheme
extensionRouterFunc.onChange(
new SchemeRegistered(Scheme.buildFromType(FakeExtension.class)));
ServerRequest request = ServerRequest.create(exchange, messageReaders);
var handlerFunc = extensionRouterFunc.route(request).block();
assertNotNull(handlerFunc);
// trigger registering scheme
extensionRouterFunc.onChange(
new SchemeUnregistered(Scheme.buildFromType(FakeExtension.class)));
handlerFunc = extensionRouterFunc.route(request).block();
assertNull(handlerFunc);
}
@Test
void shouldRegisterWatcherIfWatcherManagerIsNotNull() {
var watcherManager = mock(SchemeWatcherManager.class);
var routerFunction = new ExtensionCompositeRouterFunction(client, watcherManager);
verify(watcherManager, times(1)).register(eq(routerFunction));
}
}

View File

@ -0,0 +1,102 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.EntityResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.ExtensionRouterFunctionFactory.ExtensionCreateHandler;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ExtendWith(MockitoExtension.class)
class ExtensionCreateHandlerTest {
@Mock
ExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionCreateHandler(scheme, client);
var pathPattern = getHandler.pathPattern();
assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern);
}
@Test
void shouldHandleCorrectly() {
final var fake = new FakeExtension();
var metadata = new Metadata();
metadata.setName("my-fake");
fake.setMetadata(metadata);
var unstructured = new Unstructured();
unstructured.setMetadata(metadata);
unstructured.setApiVersion("fake.halo.run/v1alpha1");
unstructured.setKind("Fake");
var serverRequest = MockServerRequest.builder()
.body(Mono.just(unstructured));
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionCreateHandler(scheme, client);
var responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.consumeNextWith(response -> {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType());
assertTrue(response instanceof EntityResponse<?>);
assertEquals(fake, ((EntityResponse<?>) response).entity());
})
.verifyComplete();
verify(client, times(1)).fetch(eq(FakeExtension.class), eq("my-fake"));
verify(client, times(1)).create(eq(unstructured));
}
@Test
void shouldReturnErrorWhenNoBodyProvided() {
var serverRequest = MockServerRequest.builder()
.body(Mono.empty());
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionCreateHandler(scheme, client);
var responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.verifyError(ExtensionConvertException.class);
}
@Test
void shouldReturnErrorWhenExtensionNotFound() {
final var unstructured = new Unstructured();
var metadata = new Metadata();
metadata.setName("my-fake");
unstructured.setMetadata(metadata);
unstructured.setApiVersion("fake.halo.run/v1alpha1");
unstructured.setKind("Fake");
var serverRequest = MockServerRequest.builder()
.body(Mono.just(unstructured));
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.empty());
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionCreateHandler(scheme, client);
var responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.verifyError(ExtensionNotFoundException.class);
}
}

View File

@ -0,0 +1,69 @@
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 static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.EntityResponse;
import reactor.test.StepVerifier;
import run.halo.app.extension.ExtensionRouterFunctionFactory.ExtensionGetHandler;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ExtendWith(MockitoExtension.class)
class ExtensionGetHandlerTest {
@Mock
ExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionGetHandler(scheme, client);
var pathPattern = getHandler.pathPattern();
assertEquals("/apis/fake.halo.run/v1alpha1/fakes/{name}", pathPattern);
}
@Test
void shouldHandleCorrectly() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionGetHandler(scheme, client);
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.build();
final var fake = new FakeExtension();
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
var responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.consumeNextWith(response -> {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType());
assertTrue(response instanceof EntityResponse<?>);
assertEquals(fake, ((EntityResponse<?>) response).entity());
})
.verifyComplete();
}
@Test
void shouldThrowExceptionWhenExtensionNotFound() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionGetHandler(scheme, client);
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.build();
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.empty());
assertThrows(ExtensionNotFoundException.class, () -> getHandler.handle(serverRequest));
}
}

View File

@ -0,0 +1,55 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.EntityResponse;
import reactor.test.StepVerifier;
import run.halo.app.extension.ExtensionRouterFunctionFactory.ExtensionListHandler;
@ExtendWith(MockitoExtension.class)
class ExtensionListHandlerTest {
@Mock
ExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionListHandler(scheme, client);
var pathPattern = getHandler.pathPattern();
assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern);
}
@Test
void shouldHandleCorrectly() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionListHandler(scheme, client);
var serverRequest = MockServerRequest.builder().build();
final var fake = new FakeExtension();
when(client.list(eq(FakeExtension.class), any(), any())).thenReturn(List.of(fake));
var responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.consumeNextWith(response -> {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType());
assertTrue(response instanceof EntityResponse<?>);
assertEquals(List.of(fake), ((EntityResponse<?>) response).entity());
})
.verifyComplete();
}
}

View File

@ -0,0 +1,67 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import run.halo.app.extension.ExtensionRouterFunctionFactory.CreateHandler;
import run.halo.app.extension.ExtensionRouterFunctionFactory.GetHandler;
import run.halo.app.extension.ExtensionRouterFunctionFactory.ListHandler;
@ExtendWith(MockitoExtension.class)
class ExtensionRouterFunctionFactoryTest {
@Mock
ExtensionClient client;
@Test
void shouldCreateSuccessfully() {
var scheme = Scheme.buildFromType(FakeExtension.class);
var factory = new ExtensionRouterFunctionFactory(scheme, client);
var routerFunction = factory.create();
testCases().forEach(testCase -> {
List<HttpMessageReader<?>> messageReaders =
HandlerStrategies.withDefaults().messageReaders();
var request = ServerRequest.create(testCase.webExchange, messageReaders);
var handlerFunc = routerFunction.route(request).block();
assertInstanceOf(testCase.expectHandlerType, handlerFunc);
});
}
List<TestCase> testCases() {
var listWebExchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes").build());
var getWebExchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/apis/fake.halo.run/v1alpha1/fakes/my-fake").build()
);
var createWebExchange = MockServerWebExchange.from(
MockServerHttpRequest.post("/apis/fake.halo.run/v1alpha1/fakes").body("{}")
);
return List.of(
new TestCase(listWebExchange, ListHandler.class),
new TestCase(getWebExchange, GetHandler.class),
new TestCase(createWebExchange, CreateHandler.class)
);
}
record TestCase(ServerWebExchange webExchange,
Class<? extends HandlerFunction<ServerResponse>> expectHandlerType) {
}
}

View File

@ -5,5 +5,5 @@ package run.halo.app.extension;
kind = "Fake",
plural = "fakes",
singular = "fake")
class FakeExtension extends AbstractExtension {
public class FakeExtension extends AbstractExtension {
}

View File

@ -6,31 +6,25 @@ 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.exception.SchemaViolationException;
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);
DefaultSchemeManager schemeManager = new DefaultSchemeManager(null);
converter = new JSONExtensionConverter(schemeManager);
objectMapper = JSONExtensionConverter.OBJECT_MAPPER;
schemeManager.register(FakeExtension.class);
}
@Test

View File

@ -0,0 +1,82 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.MetadataOperator.metadataDeepEquals;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
class MetadataOperatorTest {
Instant now = Instant.now();
@Test
void testMetadataDeepEqualsWithSameType() {
assertTrue(metadataDeepEquals(null, null));
var left = createFullMetadata();
var right = createFullMetadata();
assertFalse(metadataDeepEquals(left, null));
assertFalse(metadataDeepEquals(null, right));
assertTrue(metadataDeepEquals(left, right));
left.setDeletionTimestamp(null);
assertFalse(metadataDeepEquals(left, right));
right.setDeletionTimestamp(null);
assertTrue(metadataDeepEquals(left, right));
left.setCreationTimestamp(null);
assertFalse(metadataDeepEquals(left, right));
right.setCreationTimestamp(null);
assertTrue(metadataDeepEquals(left, right));
left.setVersion(null);
assertFalse(metadataDeepEquals(left, right));
right.setVersion(null);
assertTrue(metadataDeepEquals(left, right));
left.setAnnotations(null);
assertFalse(metadataDeepEquals(left, right));
right.setAnnotations(null);
assertTrue(metadataDeepEquals(left, right));
left.setLabels(null);
assertFalse(metadataDeepEquals(left, right));
right.setLabels(null);
assertTrue(metadataDeepEquals(left, right));
left.setName(null);
assertFalse(metadataDeepEquals(left, right));
right.setName(null);
assertTrue(metadataDeepEquals(left, right));
}
@Test
void testMetadataDeepEqualsWithDifferentType() {
var mockMetadata = mock(MetadataOperator.class);
when(mockMetadata.getName()).thenReturn("fake-name");
when(mockMetadata.getLabels()).thenReturn(Map.of("fake-label-key", "fake-label-value"));
when(mockMetadata.getAnnotations()).thenReturn(Map.of("fake-anno-key", "fake-anno-value"));
when(mockMetadata.getVersion()).thenReturn(123L);
when(mockMetadata.getCreationTimestamp()).thenReturn(now);
when(mockMetadata.getDeletionTimestamp()).thenReturn(now);
var metadata = createFullMetadata();
assertTrue(metadataDeepEquals(metadata, mockMetadata));
}
Metadata createFullMetadata() {
var metadata = new Metadata();
metadata.setName("fake-name");
metadata.setLabels(Map.of("fake-label-key", "fake-label-value"));
metadata.setAnnotations(Map.of("fake-anno-key", "fake-anno-value"));
metadata.setVersion(123L);
metadata.setCreationTimestamp(now);
metadata.setDeletionTimestamp(now);
return metadata;
}
}

View File

@ -0,0 +1,33 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.ExtensionRouterFunctionFactory.PathPatternGenerator;
class PathPatternGeneratorTest {
@GVK(group = "fake.halo.run", version = "v1alpha1", kind = "Fake",
singular = "fake", plural = "fakes")
private static class GroupExtension extends AbstractExtension {
}
@GVK(group = "", version = "v1alpha1", kind = "Fake",
singular = "fake", plural = "fakes")
private static class GrouplessExtension extends AbstractExtension {
}
@Test
void buildGroupedExtensionPathPattern() {
var scheme = Scheme.buildFromType(GroupExtension.class);
var pathPattern = PathPatternGenerator.buildExtensionPathPattern(scheme);
assertEquals("/apis/fake.halo.run/v1alpha1/fakes", pathPattern);
}
@Test
void buildGrouplessExtensionPathPattern() {
var scheme = Scheme.buildFromType(GrouplessExtension.class);
var pathPattern = PathPatternGenerator.buildExtensionPathPattern(scheme);
assertEquals("/api/v1alpha1/fakes", pathPattern);
}
}

View File

@ -1,9 +1,12 @@
package run.halo.app.extension;
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 com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.exception.ExtensionException;
class SchemeTest {
@ -31,4 +34,37 @@ class SchemeTest {
"fake", new ObjectNode(null));
}
@Test
void shouldThrowExceptionWhenTypeHasNoGvkAnno() {
class NoGvkExtension extends AbstractExtension {
}
assertThrows(ExtensionException.class,
() -> Scheme.getGvkFromType(NoGvkExtension.class));
assertThrows(ExtensionException.class,
() -> Scheme.buildFromType(NoGvkExtension.class));
}
@Test
void shouldGetGvkFromTypeWithGvkAnno() {
var gvk = Scheme.getGvkFromType(FakeExtension.class);
assertEquals("fake.halo.run", gvk.group());
assertEquals("v1alpha1", gvk.version());
assertEquals("Fake", gvk.kind());
assertEquals("fake", gvk.singular());
assertEquals("fakes", gvk.plural());
}
@Test
void shouldCreateSchemeSuccessfully() {
var scheme = Scheme.buildFromType(FakeExtension.class);
assertEquals(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"),
scheme.groupVersionKind());
assertEquals("fake", scheme.singular());
assertEquals("fakes", scheme.plural());
assertNotNull(scheme.jsonSchema());
assertEquals(FakeExtension.class, scheme.type());
}
}

View File

@ -1,59 +0,0 @@
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 static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.exception.ExtensionException;
import run.halo.app.extension.exception.SchemeNotFoundException;
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);
assertThrows(SchemeNotFoundException.class,
() -> Schemes.INSTANCE.get(FakeExtension.class));
var gvk = fromAPIVersionAndKind("fake.halo.run/v1alpha1", "Fake");
scheme = Schemes.INSTANCE.fetch(gvk);
assertEquals(Optional.empty(), scheme);
assertThrows(SchemeNotFoundException.class, () -> Schemes.INSTANCE.get(gvk));
}
@Test
void shouldFetchFakeWhenRegistered() {
Schemes.INSTANCE.register(FakeExtension.class);
var scheme = Schemes.INSTANCE.fetch(FakeExtension.class);
assertTrue(scheme.isPresent());
scheme = Schemes.INSTANCE.fetch(fromAPIVersionAndKind("fake.halo.run/v1alpha1", "Fake"));
assertTrue(scheme.isPresent());
}
}

View File

@ -1,14 +1,16 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static run.halo.app.extension.MetadataOperator.metadataDeepEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
class UnstructuredTest {
@ -30,25 +32,36 @@ class UnstructuredTest {
}
""";
@BeforeAll
static void setUpGlobally() {
Schemes.INSTANCE.register(FakeExtension.class);
}
@Test
void shouldSerializeCorrectly() throws JsonProcessingException {
var extensionNode = (ObjectNode) objectMapper.readTree(extensionJson);
var extension = new Unstructured(extensionNode);
Map extensionMap = objectMapper.readValue(extensionJson, Map.class);
var extension = new Unstructured(extensionMap);
var gotNode = objectMapper.valueToTree(extension);
assertEquals(extensionNode, gotNode);
assertEquals(objectMapper.readTree(extensionJson), gotNode);
}
@Test
void shouldDeserializeCorrectly() throws JsonProcessingException {
void shouldSetCreationTimestamp() throws JsonProcessingException, JSONException {
Map extensionMap = objectMapper.readValue(extensionJson, Map.class);
var extension = new Unstructured(extensionMap);
System.out.println(objectMapper.writeValueAsString(extension));
var beforeChange = objectMapper.writeValueAsString(extension);
var metadata = extension.getMetadata();
metadata.setCreationTimestamp(metadata.getCreationTimestamp());
var afterChange = objectMapper.writeValueAsString(extension);
JSONAssert.assertEquals(beforeChange, afterChange, true);
}
@Test
void shouldDeserializeCorrectly() throws JsonProcessingException, JSONException {
var extension = objectMapper.readValue(extensionJson, Unstructured.class);
var wantJsonNode = objectMapper.readTree(extensionJson);
assertEquals(wantJsonNode, extension.getExtension());
var gotJson = objectMapper.writeValueAsString(extension);
JSONAssert.assertEquals(extensionJson, gotJson, true);
}
@Test
@ -57,7 +70,7 @@ class UnstructuredTest {
assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion());
assertEquals("Fake", extension.getKind());
assertEquals(createMetadata(), extension.getMetadata());
metadataDeepEquals(createMetadata(), extension.getMetadata());
}
@Test
@ -69,7 +82,7 @@ class UnstructuredTest {
assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion());
assertEquals("Fake", extension.getKind());
assertEquals(createMetadata(), extension.getMetadata());
assertTrue(metadataDeepEquals(createMetadata(), extension.getMetadata()));
}
private Metadata createMetadata() {
@ -81,4 +94,4 @@ class UnstructuredTest {
return metadata;
}
}
}

View File

@ -59,7 +59,7 @@ class YamlPluginFinderTest {
"requires": ">=2.0.0",
"pluginClass": "run.halo.app.plugin.BasePlugin"
},
"apiVersion": "v1",
"apiVersion": "plugin.halo.run/v1alpha1",
"kind": "Plugin",
"metadata": {
"name": "plugin-1",
@ -72,7 +72,7 @@ class YamlPluginFinderTest {
}
""",
JsonUtils.objectToJson(plugin),
false);
true);
}
@Test
@ -154,7 +154,7 @@ class YamlPluginFinderTest {
void deserializeLicense() throws JSONException, JsonProcessingException {
String pluginJson = """
{
"apiVersion": "v1",
"apiVersion": "plugin.halo.run/v1alpha1",
"kind": "Plugin",
"metadata": {
"name": "plugin-1"