mirror of https://github.com/halo-dev/halo
				
				
				
			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 registrationpull/2161/head
							parent
							
								
									fcbf0031a4
								
							
						
					
					
						commit
						e52db6859f
					
				| 
						 | 
				
			
			@ -1,4 +0,0 @@
 | 
			
		|||
@startuml
 | 
			
		||||
ExceptionHandlingWebHandler -> FilteringWebHandler
 | 
			
		||||
FilteringWebHandler contains filters and DispatcherHandler
 | 
			
		||||
@enduml
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,5 +5,5 @@ package run.halo.app.extension;
 | 
			
		|||
    kind = "Fake",
 | 
			
		||||
    plural = "fakes",
 | 
			
		||||
    singular = "fake")
 | 
			
		||||
class FakeExtension extends AbstractExtension {
 | 
			
		||||
public class FakeExtension extends AbstractExtension {
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue