From e52db6859f8305a6208795389ff29ea734cf0140 Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 16 Jun 2022 11:10:14 +0800 Subject: [PATCH] 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 --- docs/security.puml | 4 - .../app/config/ExtensionConfiguration.java | 43 +++++ .../halo/app/extension/AbstractExtension.java | 11 ++ .../app/extension/DefaultExtensionClient.java | 17 +- .../app/extension/DefaultSchemeManager.java | 53 ++++++ .../DefaultSchemeWatcherManager.java | 33 ++++ .../ExtensionCompositeRouterFunction.java | 60 ++++++ .../halo/app/extension/ExtensionOperator.java | 24 ++- .../ExtensionRouterFunctionFactory.java | 162 +++++++++++++++++ .../halo/app/extension/GroupVersionKind.java | 5 + .../app/extension/JSONExtensionConverter.java | 32 ++-- .../java/run/halo/app/extension/Metadata.java | 2 + .../halo/app/extension/MetadataOperator.java | 28 +++ .../java/run/halo/app/extension/Scheme.java | 59 ++++++ .../run/halo/app/extension/SchemeManager.java | 58 ++++++ .../app/extension/SchemeWatcherManager.java | 49 +++++ .../java/run/halo/app/extension/Schemes.java | 147 --------------- .../run/halo/app/extension/Unstructured.java | 172 +++++++++++++++--- .../exception/ExtensionNotFoundException.java | 24 +++ .../run/halo/app/infra/SchemeInitializer.java | 19 +- .../halo/app/plugin/PluginLoadedListener.java | 8 +- .../security/authorization/PolicyRule.java | 4 + .../config/ExtensionConfigurationTest.java | 146 +++++++++++++++ .../extension/DefaultExtensionClientTest.java | 16 +- .../extension/DefaultSchemeManagerTest.java | 120 ++++++++++++ .../DefaultSchemeWatcherManagerTest.java | 63 +++++++ .../ExtensionCompositeRouterFunctionTest.java | 79 ++++++++ .../extension/ExtensionCreateHandlerTest.java | 102 +++++++++++ .../extension/ExtensionGetHandlerTest.java | 69 +++++++ .../extension/ExtensionListHandlerTest.java | 55 ++++++ .../ExtensionRouterFunctionFactoryTest.java | 67 +++++++ .../run/halo/app/extension/FakeExtension.java | 2 +- .../extension/JSONExtensionConverterTest.java | 16 +- .../app/extension/MetadataOperatorTest.java | 82 +++++++++ .../extension/PathPatternGeneratorTest.java | 33 ++++ .../run/halo/app/extension/SchemeTest.java | 36 ++++ .../run/halo/app/extension/SchemesTest.java | 59 ------ .../halo/app/extension/UnstructuredTest.java | 45 +++-- .../halo/app/plugin/YamlPluginFinderTest.java | 6 +- 39 files changed, 1711 insertions(+), 299 deletions(-) delete mode 100644 docs/security.puml create mode 100644 src/main/java/run/halo/app/config/ExtensionConfiguration.java create mode 100644 src/main/java/run/halo/app/extension/DefaultSchemeManager.java create mode 100644 src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java create mode 100644 src/main/java/run/halo/app/extension/ExtensionCompositeRouterFunction.java create mode 100644 src/main/java/run/halo/app/extension/ExtensionRouterFunctionFactory.java create mode 100644 src/main/java/run/halo/app/extension/SchemeManager.java create mode 100644 src/main/java/run/halo/app/extension/SchemeWatcherManager.java delete mode 100644 src/main/java/run/halo/app/extension/Schemes.java create mode 100644 src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java create mode 100644 src/test/java/run/halo/app/config/ExtensionConfigurationTest.java create mode 100644 src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java create mode 100644 src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionCompositeRouterFunctionTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionCreateHandlerTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionGetHandlerTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionListHandlerTest.java create mode 100644 src/test/java/run/halo/app/extension/ExtensionRouterFunctionFactoryTest.java create mode 100644 src/test/java/run/halo/app/extension/MetadataOperatorTest.java create mode 100644 src/test/java/run/halo/app/extension/PathPatternGeneratorTest.java delete mode 100644 src/test/java/run/halo/app/extension/SchemesTest.java diff --git a/docs/security.puml b/docs/security.puml deleted file mode 100644 index ed4176a6e..000000000 --- a/docs/security.puml +++ /dev/null @@ -1,4 +0,0 @@ -@startuml -ExceptionHandlingWebHandler -> FilteringWebHandler -FilteringWebHandler contains filters and DispatcherHandler -@enduml \ No newline at end of file diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java new file mode 100644 index 000000000..cd388de9e --- /dev/null +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -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 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 watchers) { + return new DefaultSchemeManager(watcherManager); + } + + @Bean + SchemeWatcherManager schemeWatcherManager() { + return new DefaultSchemeWatcherManager(); + } +} diff --git a/src/main/java/run/halo/app/extension/AbstractExtension.java b/src/main/java/run/halo/app/extension/AbstractExtension.java index b495d4551..65f6b6fac 100644 --- a/src/main/java/run/halo/app/extension/AbstractExtension.java +++ b/src/main/java/run/halo/app/extension/AbstractExtension.java @@ -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; + } } diff --git a/src/main/java/run/halo/app/extension/DefaultExtensionClient.java b/src/main/java/run/halo/app/extension/DefaultExtensionClient.java index f5f53ef2a..f46fab592 100644 --- a/src/main/java/run/halo/app/extension/DefaultExtensionClient.java +++ b/src/main/java/run/halo/app/extension/DefaultExtensionClient.java @@ -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 List list(Class type, Predicate predicate, Comparator 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 Optional fetch(Class 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 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()); } diff --git a/src/main/java/run/halo/app/extension/DefaultSchemeManager.java b/src/main/java/run/halo/app/extension/DefaultSchemeManager.java new file mode 100644 index 000000000..b21685700 --- /dev/null +++ b/src/main/java/run/halo/app/extension/DefaultSchemeManager.java @@ -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 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 schemes() { + return Collections.unmodifiableList(schemes); + } + + @NonNull + private List getWatchers() { + if (this.watcherManager == null) { + return Collections.emptyList(); + } + return Optional.ofNullable(this.watcherManager.watchers()).orElse(Collections.emptyList()); + } +} diff --git a/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java b/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java new file mode 100644 index 000000000..2ec86edd1 --- /dev/null +++ b/src/main/java/run/halo/app/extension/DefaultSchemeWatcherManager.java @@ -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 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 watchers() { + // we have to copy the watchers entirely to prevent concurrent modification. + return List.copyOf(watchers); + } +} diff --git a/src/main/java/run/halo/app/extension/ExtensionCompositeRouterFunction.java b/src/main/java/run/halo/app/extension/ExtensionCompositeRouterFunction.java new file mode 100644 index 000000000..aa3d9b3e9 --- /dev/null +++ b/src/main/java/run/halo/app/extension/ExtensionCompositeRouterFunction.java @@ -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, SchemeWatcher { + + private final Map> 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> 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> 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()); + } + } +} diff --git a/src/main/java/run/halo/app/extension/ExtensionOperator.java b/src/main/java/run/halo/app/extension/ExtensionOperator.java index ba6b9bd9c..343f9d3ab 100644 --- a/src/main/java/run/halo/app/extension/ExtensionOperator.java +++ b/src/main/java/run/halo/app/extension/ExtensionOperator.java @@ -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) { diff --git a/src/main/java/run/halo/app/extension/ExtensionRouterFunctionFactory.java b/src/main/java/run/halo/app/extension/ExtensionRouterFunctionFactory.java new file mode 100644 index 000000000..f0d0d0991 --- /dev/null +++ b/src/main/java/run/halo/app/extension/ExtensionRouterFunctionFactory.java @@ -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 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, PathPatternGenerator { + + } + + interface ListHandler extends HandlerFunction, PathPatternGenerator { + + } + + interface CreateHandler extends HandlerFunction, 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 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 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 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); + } + } + +} diff --git a/src/main/java/run/halo/app/extension/GroupVersionKind.java b/src/main/java/run/halo/app/extension/GroupVersionKind.java index 63490fc04..a0a3d4099 100644 --- a/src/main/java/run/halo/app/extension/GroupVersionKind.java +++ b/src/main/java/run/halo/app/extension/GroupVersionKind.java @@ -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. * diff --git a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java index 7c1fb1a91..9dfb00f27 100644 --- a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java +++ b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -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 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 convertFrom(Class 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) { diff --git a/src/main/java/run/halo/app/extension/Metadata.java b/src/main/java/run/halo/app/extension/Metadata.java index 83c5e9b10..ba15f39f4 100644 --- a/src/main/java/run/halo/app/extension/Metadata.java +++ b/src/main/java/run/halo/app/extension/Metadata.java @@ -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 { /** diff --git a/src/main/java/run/halo/app/extension/MetadataOperator.java b/src/main/java/run/halo/app/extension/MetadataOperator.java index a712ee642..67e3663fa 100644 --- a/src/main/java/run/halo/app/extension/MetadataOperator.java +++ b/src/main/java/run/halo/app/extension/MetadataOperator.java @@ -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; + } } diff --git a/src/main/java/run/halo/app/extension/Scheme.java b/src/main/java/run/halo/app/extension/Scheme.java index dec4e354b..a5aab8f84 100644 --- a/src/main/java/run/halo/app/extension/Scheme.java +++ b/src/main/java/run/halo/app/extension/Scheme.java @@ -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 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 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 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; + } } diff --git a/src/main/java/run/halo/app/extension/SchemeManager.java b/src/main/java/run/halo/app/extension/SchemeManager.java new file mode 100644 index 000000000..041c776e2 --- /dev/null +++ b/src/main/java/run/halo/app/extension/SchemeManager.java @@ -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 Extension class. + */ + default void register(Class type) { + register(Scheme.buildFromType(type)); + } + + + void unregister(@NonNull Scheme scheme); + + default int size() { + return schemes().size(); + } + + @NonNull + List schemes(); + + @NonNull + default Optional 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 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); + } + +} diff --git a/src/main/java/run/halo/app/extension/SchemeWatcherManager.java b/src/main/java/run/halo/app/extension/SchemeWatcherManager.java new file mode 100644 index 000000000..5c69a4764 --- /dev/null +++ b/src/main/java/run/halo/app/extension/SchemeWatcherManager.java @@ -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 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; + } + + } +} diff --git a/src/main/java/run/halo/app/extension/Schemes.java b/src/main/java/run/halo/app/extension/Schemes.java deleted file mode 100644 index 7af21f79b..000000000 --- a/src/main/java/run/halo/app/extension/Schemes.java +++ /dev/null @@ -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 schemes; - - /** - * The map mapping type and scheme of Extension. - */ - private final Map, Scheme> typeToScheme; - - /** - * The map mapping GroupVersionKind and type of Extension. - */ - private final Map 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 Extension class. - */ - public void register(Class type) { - // concrete scheme from annotation - var gvk = type.getAnnotation(GVK.class); - if (gvk == null) { - // should never happen - throw new ExtensionException( - String.format("Annotation %s needs to be on Extension %s", GVK.class.getName(), - type.getName())); - } - - // TODO 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 fetch(Class type) { - return Optional.ofNullable(typeToScheme.get(type)); - } - - - public Optional 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 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)); - } - -} diff --git a/src/main/java/run/halo/app/extension/Unstructured.java b/src/main/java/run/halo/app/extension/Unstructured.java index ed4aa809c..86256f742 100644 --- a/src/main/java/run/halo/app/extension/Unstructured.java +++ b/src/main/java/run/halo/app/extension/Unstructured.java @@ -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 getLabels() { + return getNestedStringStringMap(data, "metadata", "labels").orElse(null); + } + + @Override + public Map 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 labels) { + setNestedValue(data, labels, "metadata", "labels"); + } + + @Override + public void setAnnotations(Map 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 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 getNestedMap(Map map, String... fields) { + return getNestedValue(map, fields).map(value -> (Map) value); + } + + @SuppressWarnings("unchecked") + static Optional> getNestedStringStringMap(Map map, String... fields) { + return getNestedValue(map, fields) + .map(labelsObj -> { + var labels = (Map) labelsObj; + var result = new HashMap(); + labels.forEach((key, value) -> result.put((String) key, (String) value)); + return result; + }); + } + + static Optional 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 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 { @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); } } diff --git a/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java b/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java new file mode 100644 index 000000000..08e4c2ed2 --- /dev/null +++ b/src/main/java/run/halo/app/extension/exception/ExtensionNotFoundException.java @@ -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); + } +} diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index d554bd569..e6edc6ca1 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -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 { + 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); } } diff --git a/src/main/java/run/halo/app/plugin/PluginLoadedListener.java b/src/main/java/run/halo/app/plugin/PluginLoadedListener.java index db1fac41b..64d0ad7a9 100644 --- a/src/main/java/run/halo/app/plugin/PluginLoadedListener.java +++ b/src/main/java/run/halo/app/plugin/PluginLoadedListener.java @@ -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 { + 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(); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java b/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java index 683a4c523..862c2c6db 100644 --- a/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java +++ b/src/test/java/run/halo/app/extension/DefaultExtensionClientTest.java @@ -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, diff --git a/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java b/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java new file mode 100644 index 000000000..bb6a90d2a --- /dev/null +++ b/src/test/java/run/halo/app/extension/DefaultSchemeManagerTest.java @@ -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()); + } +} + diff --git a/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java b/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java new file mode 100644 index 000000000..8016d2661 --- /dev/null +++ b/src/test/java/run/halo/app/extension/DefaultSchemeWatcherManagerTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/ExtensionCompositeRouterFunctionTest.java b/src/test/java/run/halo/app/extension/ExtensionCompositeRouterFunctionTest.java new file mode 100644 index 000000000..b57961a6b --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionCompositeRouterFunctionTest.java @@ -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)); + } + +} diff --git a/src/test/java/run/halo/app/extension/ExtensionCreateHandlerTest.java b/src/test/java/run/halo/app/extension/ExtensionCreateHandlerTest.java new file mode 100644 index 000000000..6e0451e2b --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionCreateHandlerTest.java @@ -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); + } +} diff --git a/src/test/java/run/halo/app/extension/ExtensionGetHandlerTest.java b/src/test/java/run/halo/app/extension/ExtensionGetHandlerTest.java new file mode 100644 index 000000000..d681baaaa --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionGetHandlerTest.java @@ -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)); + } +} diff --git a/src/test/java/run/halo/app/extension/ExtensionListHandlerTest.java b/src/test/java/run/halo/app/extension/ExtensionListHandlerTest.java new file mode 100644 index 000000000..fee0817bb --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionListHandlerTest.java @@ -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(); + } + +} diff --git a/src/test/java/run/halo/app/extension/ExtensionRouterFunctionFactoryTest.java b/src/test/java/run/halo/app/extension/ExtensionRouterFunctionFactoryTest.java new file mode 100644 index 000000000..aa66b0cda --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionRouterFunctionFactoryTest.java @@ -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> messageReaders = + HandlerStrategies.withDefaults().messageReaders(); + var request = ServerRequest.create(testCase.webExchange, messageReaders); + var handlerFunc = routerFunction.route(request).block(); + assertInstanceOf(testCase.expectHandlerType, handlerFunc); + }); + } + + List 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> expectHandlerType) { + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/FakeExtension.java b/src/test/java/run/halo/app/extension/FakeExtension.java index 1af835883..81ab39cf5 100644 --- a/src/test/java/run/halo/app/extension/FakeExtension.java +++ b/src/test/java/run/halo/app/extension/FakeExtension.java @@ -5,5 +5,5 @@ package run.halo.app.extension; kind = "Fake", plural = "fakes", singular = "fake") -class FakeExtension extends AbstractExtension { +public class FakeExtension extends AbstractExtension { } diff --git a/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java b/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java index 14d56f5db..73c772eaf 100644 --- a/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java +++ b/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java @@ -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 diff --git a/src/test/java/run/halo/app/extension/MetadataOperatorTest.java b/src/test/java/run/halo/app/extension/MetadataOperatorTest.java new file mode 100644 index 000000000..7d7da3816 --- /dev/null +++ b/src/test/java/run/halo/app/extension/MetadataOperatorTest.java @@ -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; + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/PathPatternGeneratorTest.java b/src/test/java/run/halo/app/extension/PathPatternGeneratorTest.java new file mode 100644 index 000000000..352913afc --- /dev/null +++ b/src/test/java/run/halo/app/extension/PathPatternGeneratorTest.java @@ -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); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/SchemeTest.java b/src/test/java/run/halo/app/extension/SchemeTest.java index f6c954710..8ec11bd0b 100644 --- a/src/test/java/run/halo/app/extension/SchemeTest.java +++ b/src/test/java/run/halo/app/extension/SchemeTest.java @@ -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()); + } + } \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/SchemesTest.java b/src/test/java/run/halo/app/extension/SchemesTest.java deleted file mode 100644 index 9d53c94ce..000000000 --- a/src/test/java/run/halo/app/extension/SchemesTest.java +++ /dev/null @@ -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()); - } -} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/UnstructuredTest.java b/src/test/java/run/halo/app/extension/UnstructuredTest.java index 97197c74e..2d64b88d4 100644 --- a/src/test/java/run/halo/app/extension/UnstructuredTest.java +++ b/src/test/java/run/halo/app/extension/UnstructuredTest.java @@ -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; } -} \ No newline at end of file +} diff --git a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java index c1cfce555..3f643ff71 100644 --- a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java +++ b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -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"