Replace JDBC to R2DBC (#2324)

#### What type of PR is this?

/kind feature
/kind improvement
/area core
/milestone 2.0

#### What this PR does / why we need it:

1. Replace JDBC to R2DBC
2. Make our system fully reactive

#### Which issue(s) this PR fixes

Fixes #2308

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/2333/head
John Niang 2022-08-17 10:56:11 +08:00 committed by GitHub
parent 84eef54603
commit 9911ba927d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1514 additions and 726 deletions

View File

@ -26,11 +26,6 @@ repositories {
configurations {
implementation {
exclude module: "spring-boot-starter-tomcat"
exclude module: "slf4j-log4j12"
exclude module: 'junit'
}
compileOnly {
extendsFrom annotationProcessor
}
@ -57,6 +52,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
@ -80,9 +76,15 @@ dependencies {
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly "com.h2database:h2"
// R2DBC
// Currently, official doesn't support mssql and mariadb yet until drivers are available.
// See https://github.com/spring-projects/spring-data-relational/commit/ee6c2c89b5c433748b22a79cf40dc8e01142caa3
// for more.
runtimeOnly 'io.r2dbc:r2dbc-h2'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.postgresql:r2dbc-postgresql'
annotationProcessor 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

View File

@ -1,6 +1,5 @@
package run.halo.app.config;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -21,19 +20,16 @@ import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.reconciler.ThemeReconciler;
import run.halo.app.core.extension.reconciler.UserReconciler;
import run.halo.app.core.extension.service.RoleService;
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.JSONExtensionConverter;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.SchemeWatcherManager;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.ControllerManager;
import run.halo.app.extension.router.ExtensionCompositeRouterFunction;
import run.halo.app.extension.store.ExtensionStoreClient;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.resources.JsBundleRuleProvider;
@ -42,19 +38,13 @@ import run.halo.app.plugin.resources.JsBundleRuleProvider;
public class ExtensionConfiguration {
@Bean
RouterFunction<ServerResponse> extensionsRouterFunction(ExtensionClient client,
RouterFunction<ServerResponse> extensionsRouterFunction(ReactiveExtensionClient 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) {
SchemeManager schemeManager(SchemeWatcherManager watcherManager) {
return new DefaultSchemeManager(watcherManager);
}

View File

@ -33,7 +33,7 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.security.DefaultUserDetailService;
@ -135,9 +135,9 @@ public class WebServerSecurityConfig {
@ConditionalOnProperty(name = "halo.security.initializer.disabled",
havingValue = "false",
matchIfMissing = true)
SuperAdminInitializer superAdminInitializer(ExtensionClient client, HaloProperties halo) {
return new SuperAdminInitializer(client,
passwordEncoder(),
SuperAdminInitializer superAdminInitializer(ReactiveExtensionClient client,
HaloProperties halo) {
return new SuperAdminInitializer(client, passwordEncoder(),
halo.getSecurity().getInitializer());
}
}

View File

@ -25,7 +25,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.YamlPluginFinder;
@ -35,9 +35,9 @@ public class PluginEndpoint implements CustomEndpoint {
private final PluginProperties pluginProperties;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public PluginEndpoint(PluginProperties pluginProperties, ExtensionClient client) {
public PluginEndpoint(PluginProperties pluginProperties, ReactiveExtensionClient client) {
this.pluginProperties = pluginProperties;
this.client = client;
}
@ -74,19 +74,16 @@ public class PluginEndpoint implements CustomEndpoint {
createDirectoriesIfNotExists(pluginRoot);
var pluginPath = pluginRoot.resolve(file.filename());
return file.transferTo(pluginPath).thenReturn(pluginPath);
}).map(pluginPath -> {
})
.flatMap(pluginPath -> {
log.info("Plugin uploaded at {}", pluginPath);
var plugin = new YamlPluginFinder().find(pluginPath);
// overwrite the enabled flag
plugin.getSpec().setEnabled(false);
var createdPlugin =
client.fetch(Plugin.class, plugin.getMetadata().getName()).orElseGet(() -> {
client.create(plugin);
return client.fetch(Plugin.class, plugin.getMetadata().getName())
.orElseThrow();
});
return createdPlugin;
}).flatMap(plugin -> ServerResponse.ok()
return client.fetch(Plugin.class, plugin.getMetadata().getName())
.switchIfEmpty(Mono.defer(() -> client.create(plugin)));
})
.flatMap(plugin -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(plugin));
}

View File

@ -14,6 +14,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.commons.lang3.StringUtils;
@ -36,11 +37,12 @@ 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 org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.exception.ThemeInstallationException;
import run.halo.app.infra.properties.HaloProperties;
@ -56,10 +58,10 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
@Component
public class ThemeEndpoint implements CustomEndpoint {
private final ExtensionClient client;
private final ReactiveExtensionClient client;
private final HaloProperties haloProperties;
public ThemeEndpoint(ExtensionClient client, HaloProperties haloProperties) {
public ThemeEndpoint(ReactiveExtensionClient client, HaloProperties haloProperties) {
this.client = client;
this.haloProperties = haloProperties;
}
@ -97,7 +99,7 @@ public class ThemeEndpoint implements CustomEndpoint {
.map(DataBuffer::asInputStream)
.reduce(SequenceInputStream::new)
.map(inputStream -> ThemeUtils.unzipThemeTo(inputStream, getThemeWorkDir())))
.map(this::persistent)
.flatMap(this::persistent)
.flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(theme));
@ -112,49 +114,58 @@ public class ThemeEndpoint implements CustomEndpoint {
* @return a theme custom model
* @see Theme
*/
public Theme persistent(Unstructured themeManifest) {
public Mono<Theme> persistent(Unstructured themeManifest) {
Assert.state(StringUtils.equals(Theme.KIND, themeManifest.getKind()),
"Theme manifest kind must be Theme.");
client.create(themeManifest);
Theme theme = client.fetch(Theme.class, themeManifest.getMetadata().getName())
.orElseThrow();
List<Unstructured> unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
if (unstructureds.stream()
.filter(unstructured -> unstructured.getKind().equals(Setting.KIND))
.filter(unstructured -> unstructured.getMetadata().getName()
.equals(theme.getSpec().getSettingName()))
.count() > 1) {
throw new IllegalStateException(
"Theme must only have one settings.yaml or settings.yml.");
}
if (unstructureds.stream()
.filter(unstructured -> unstructured.getKind().equals(ConfigMap.KIND))
.filter(unstructured -> unstructured.getMetadata().getName()
.equals(theme.getSpec().getConfigMapName()))
.count() > 1) {
throw new IllegalStateException(
"Theme must only have one config.yaml or config.yml.");
}
Theme.ThemeSpec spec = theme.getSpec();
for (Unstructured unstructured : unstructureds) {
String name = unstructured.getMetadata().getName();
return client.create(themeManifest)
.map(theme -> Unstructured.OBJECT_MAPPER.convertValue(theme, Theme.class))
.flatMap(theme -> {
var unstructureds = ThemeUtils.loadThemeResources(getThemePath(theme));
if (unstructureds.stream()
.filter(hasSettingsYaml(theme))
.count() > 1) {
return Mono.error(new IllegalStateException(
"Theme must only have one settings.yaml or settings.yml."));
}
if (unstructureds.stream()
.filter(hasConfigYaml(theme))
.count() > 1) {
return Mono.error(new IllegalStateException(
"Theme must only have one config.yaml or config.yml."));
}
return Flux.fromIterable(unstructureds)
.flatMap(unstructured -> {
var spec = theme.getSpec();
String name = unstructured.getMetadata().getName();
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
&& StringUtils.equals(spec.getSettingName(), name);
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
&& StringUtils.equals(spec.getSettingName(), name);
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
&& StringUtils.equals(spec.getConfigMapName(), name);
if (isThemeSetting || isThemeConfig) {
client.create(unstructured);
}
}
return theme;
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
&& StringUtils.equals(spec.getConfigMapName(), name);
if (isThemeSetting || isThemeConfig) {
return client.create(unstructured);
}
return Mono.empty();
})
.then(Mono.just(theme));
});
}
private Path getThemePath(Theme theme) {
return getThemeWorkDir().resolve(theme.getMetadata().getName());
}
private Predicate<Unstructured> hasSettingsYaml(Theme theme) {
return unstructured -> Setting.KIND.equals(unstructured.getKind())
&& theme.getSpec().getSettingName().equals(unstructured.getMetadata().getName());
}
private Predicate<Unstructured> hasConfigYaml(Theme theme) {
return unstructured -> ConfigMap.KIND.equals(unstructured.getKind())
&& theme.getSpec().getConfigMapName().equals(unstructured.getMetadata().getName());
}
static class ThemeUtils {
private static final String THEME_TMP_PREFIX = "halo-theme-";
private static final String[] themeManifests = {"theme.yaml", "theme.yml"};

View File

@ -15,7 +15,6 @@ import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
@ -24,25 +23,24 @@ import org.springframework.util.CollectionUtils;
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 org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
@Component
public class UserEndpoint implements CustomEndpoint {
private static final String SELF_USER = "-";
private final ExtensionClient client;
private final ReactiveExtensionClient client;
private final UserService userService;
public UserEndpoint(ExtensionClient client, UserService userService) {
public UserEndpoint(ReactiveExtensionClient client, UserService userService) {
this.client = client;
this.userService = userService;
}
@ -118,10 +116,9 @@ public class UserEndpoint implements CustomEndpoint {
@NonNull
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> {
.flatMap(ctx -> {
var name = ctx.getAuthentication().getName();
return client.fetch(User.class, name)
.orElseThrow(() -> new ExtensionNotFoundException(name));
return client.get(User.class, name);
})
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
@ -134,18 +131,15 @@ public class UserEndpoint implements CustomEndpoint {
return request.bodyToMono(GrantRequest.class)
.switchIfEmpty(
Mono.error(() -> new ServerWebInputException("Request body is empty")))
.flatMap(grant -> {
// preflight check
client.fetch(User.class, username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"User " + username + " was not found"));
grant.roles.forEach(roleName -> client.fetch(Role.class, roleName)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Role " + roleName + " was not found")));
var bindings =
client.list(RoleBinding.class, RoleBinding.containsUser(username), null);
.flatMap(grant -> client.get(User.class, username).thenReturn(grant))
.flatMap(grant -> Flux.fromIterable(grant.roles)
.flatMap(roleName -> client.get(Role.class, roleName))
.then().thenReturn(grant))
.zipWith(client.list(RoleBinding.class, RoleBinding.containsUser(username), null)
.collectList())
.flatMap(tuple2 -> {
var grant = tuple2.getT1();
var bindings = tuple2.getT2();
var bindingToUpdate = new HashSet<RoleBinding>();
var bindingToDelete = new HashSet<RoleBinding>();
var existingRoles = new HashSet<String>();

View File

@ -14,11 +14,12 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
/**
@ -29,23 +30,28 @@ import run.halo.app.infra.utils.JsonUtils;
@Service
public class DefaultRoleService implements RoleService {
private final ExtensionClient extensionClient;
private final ReactiveExtensionClient extensionClient;
public DefaultRoleService(ExtensionClient extensionClient) {
public DefaultRoleService(ReactiveExtensionClient extensionClient) {
this.extensionClient = extensionClient;
}
@Override
@NonNull
public Role getRole(@NonNull String name) {
return extensionClient.fetch(Role.class, name).orElseThrow();
return extensionClient.fetch(Role.class, name).blockOptional().orElseThrow();
}
@Override
public Mono<Role> getMonoRole(String name) {
return extensionClient.get(Role.class, name);
}
@Override
public Flux<RoleRef> listRoleRefs(Subject subject) {
return Flux.fromIterable(extensionClient.list(RoleBinding.class,
return extensionClient.list(RoleBinding.class,
binding -> binding.getSubjects().contains(subject),
null))
null)
.map(RoleBinding::getRoleRef);
}
@ -67,16 +73,16 @@ public class DefaultRoleService implements RoleService {
continue;
}
visited.add(roleName);
extensionClient.fetch(Role.class, roleName).ifPresent(role -> {
result.add(role);
// add role dependencies to queue
Map<String, String> annotations = role.getMetadata().getAnnotations();
if (annotations != null) {
String roleNameDependencies = annotations.get(Role.ROLE_DEPENDENCIES_ANNO);
List<String> roleDependencies = stringToList(roleNameDependencies);
queue.addAll(roleDependencies);
}
});
extensionClient.fetch(Role.class, roleName)
.subscribe(role -> {
result.add(role);
Map<String, String> annotations = role.getMetadata().getAnnotations();
if (annotations != null) {
String roleNameDependencies = annotations.get(Role.ROLE_DEPENDENCIES_ANNO);
List<String> roleDependencies = stringToList(roleNameDependencies);
queue.addAll(roleDependencies);
}
});
}
return result;
}

View File

@ -4,6 +4,7 @@ import java.util.List;
import java.util.Set;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
@ -15,8 +16,11 @@ import run.halo.app.core.extension.RoleBinding.Subject;
public interface RoleService {
@NonNull
@Deprecated
Role getRole(String name);
Mono<Role> getMonoRole(String name);
Flux<RoleRef> listRoleRefs(Subject subject);
List<Role> listDependencies(Set<String> names);

View File

@ -9,8 +9,7 @@ public interface UserService {
Mono<User> getUser(String username);
@Deprecated
Mono<Void> updatePassword(String username, String newPassword);
Mono<User> updatePassword(String username, String newPassword);
Mono<User> updateWithRawPassword(String username, String rawPassword);

View File

@ -3,72 +3,58 @@ package run.halo.app.core.extension.service;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import java.util.Objects;
import java.util.function.Predicate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
@Service
public class UserServiceImpl implements UserService {
private final ExtensionClient client;
private final ReactiveExtensionClient client;
private final PasswordEncoder passwordEncoder;
public UserServiceImpl(ExtensionClient client, PasswordEncoder passwordEncoder) {
public UserServiceImpl(ReactiveExtensionClient client, PasswordEncoder passwordEncoder) {
this.client = client;
this.passwordEncoder = passwordEncoder;
}
@Override
public Mono<User> getUser(String username) {
return Mono.justOrEmpty(client.fetch(User.class, username));
return client.get(User.class, username);
}
@Override
public Mono<Void> updatePassword(String username, String newPassword) {
public Mono<User> updatePassword(String username, String newPassword) {
return getUser(username)
.doOnNext(user -> {
.filter(user -> !Objects.equals(user.getSpec().getPassword(), newPassword))
.flatMap(user -> {
user.getSpec().setPassword(newPassword);
client.update(user);
})
.then();
return client.update(user);
});
}
@Override
public Mono<User> updateWithRawPassword(String username, String rawPassword) {
return getUser(username)
.filter(Predicate.not(hasPassword().and(passwordMatches(rawPassword))))
.filter(user -> !passwordEncoder.matches(rawPassword, user.getSpec().getPassword()))
.flatMap(user -> {
// TODO Validate the password
user.getSpec().setPassword(passwordEncoder.encode(rawPassword));
client.update(user);
// get the latest user
return getUser(username);
return client.update(user);
});
}
private Predicate<User> hasPassword() {
return user -> StringUtils.hasText(user.getSpec().getPassword());
}
private Predicate<User> passwordMatches(String rawPassword) {
return user -> passwordEncoder.matches(rawPassword, user.getSpec().getPassword());
}
@Override
public Flux<Role> listRoles(String name) {
return Flux.fromStream(client.list(RoleBinding.class, containsUser(name), null)
.stream()
return client.list(RoleBinding.class, containsUser(name), null)
.filter(roleBinding -> Role.KIND.equals(roleBinding.getRoleRef().getKind()))
.map(roleBinding -> roleBinding.getRoleRef().getName())
.map(roleName -> client.fetch(Role.class, roleName).orElse(null))
.filter(Objects::nonNull));
.flatMap(roleName -> client.fetch(Role.class, roleName));
}
}

View File

@ -5,6 +5,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.extension.store.ExtensionStoreClient;
@ -13,6 +14,7 @@ import run.halo.app.extension.store.ExtensionStoreClient;
*
* @author johnniang
*/
@Component
public class DefaultExtensionClient implements ExtensionClient {
private final ExtensionStoreClient storeClient;
@ -22,12 +24,15 @@ public class DefaultExtensionClient implements ExtensionClient {
private final Watcher.WatcherComposite watchers;
private final ReactiveExtensionClient reactiveClient;
public DefaultExtensionClient(ExtensionStoreClient storeClient,
ExtensionConverter converter,
SchemeManager schemeManager) {
SchemeManager schemeManager, ReactiveExtensionClient reactiveClient) {
this.storeClient = storeClient;
this.converter = converter;
this.schemeManager = schemeManager;
this.reactiveClient = reactiveClient;
watchers = new Watcher.WatcherComposite();
}
@ -116,6 +121,8 @@ public class DefaultExtensionClient implements ExtensionClient {
@Override
public void watch(Watcher watcher) {
this.watchers.addWatcher(watcher);
// TODO Refactor the watch process. At present, we have to ensure the compatibility.
reactiveClient.watch(watcher);
}
}

View File

@ -1,9 +1,23 @@
package run.halo.app.extension;
import java.util.Comparator;
import java.util.Objects;
/**
* Extension is an interface which represents an Extension. It contains setters and getters of
* GroupVersionKind and Metadata.
*/
public interface Extension extends ExtensionOperator {
public interface Extension extends ExtensionOperator, Comparable<Extension> {
@Override
default int compareTo(Extension another) {
if (another == null || another.getMetadata() == null) {
return 1;
}
if (getMetadata() == null) {
return -1;
}
return Objects.compare(getMetadata().getName(), another.getMetadata().getName(),
Comparator.naturalOrder());
}
}

View File

@ -10,7 +10,9 @@ import java.util.function.Predicate;
* ExtensionStore.
*
* @author johnniang
* @deprecated Use {@link ReactiveExtensionClient} instead.
*/
@Deprecated(forRemoval = true, since = "2.0")
public interface ExtensionClient {
/**

View File

@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.openapi4j.core.exception.ResolutionException;
import org.openapi4j.schema.validator.ValidationData;
import org.openapi4j.schema.validator.v3.SchemaValidator;
import org.springframework.stereotype.Component;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore;
@ -17,6 +18,7 @@ import run.halo.app.extension.store.ExtensionStore;
* @author johnniang
*/
@Slf4j
@Component
public class JSONExtensionConverter implements ExtensionConverter {
public final ObjectMapper objectMapper;

View File

@ -0,0 +1,85 @@
package run.halo.app.extension;
import java.util.Comparator;
import java.util.function.Predicate;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* ExtensionClient is an interface which contains some operations on Extension instead of
* ExtensionStore.
*
* @author johnniang
*/
public interface ReactiveExtensionClient {
/**
* Lists Extensions by Extension type, filter and sorter.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param <E> is Extension type.
* @return all filtered and sorted Extensions.
*/
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
/**
* Lists Extensions by Extension type, filter, sorter and page info.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param page is page number which starts from 0.
* @param size is page size.
* @param <E> is Extension type.
* @return a list of Extensions.
*/
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
/**
* Fetches Extension by its type and name.
*
* @param type is Extension type.
* @param name is Extension name.
* @param <E> is Extension type.
* @return an optional Extension.
*/
<E extends Extension> Mono<E> fetch(Class<E> type, String name);
Mono<Unstructured> fetch(GroupVersionKind gvk, String name);
<E extends Extension> Mono<E> get(Class<E> type, String name);
/**
* Creates an Extension.
*
* @param extension is fresh Extension to be created. Please make sure the Extension name does
* not exist.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> create(E extension);
/**
* Updates an Extension.
*
* @param extension is an Extension to be updated. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> update(E extension);
/**
* Deletes an Extension.
*
* @param extension is an Extension to be deleted. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> delete(E extension);
void watch(Watcher watcher);
}

View File

@ -0,0 +1,148 @@
package run.halo.app.extension;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.function.Predicate;
import org.springframework.data.util.Predicates;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
@Component
public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
private final ReactiveExtensionStoreClient client;
private final ExtensionConverter converter;
private final SchemeManager schemeManager;
private final Watcher.WatcherComposite watchers;
public ReactiveExtensionClientImpl(ReactiveExtensionStoreClient client,
ExtensionConverter converter, SchemeManager schemeManager) {
this.client = client;
this.converter = converter;
this.schemeManager = schemeManager;
this.watchers = new Watcher.WatcherComposite();
}
@Override
public <E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator) {
var scheme = schemeManager.get(type);
var prefix = ExtensionUtil.buildStoreNamePrefix(scheme);
return client.listByNamePrefix(prefix)
.map(extensionStore -> converter.convertFrom(type, extensionStore))
.filter(predicate == null ? Predicates.isTrue() : predicate)
.sort(comparator == null ? Comparator.naturalOrder() : comparator);
}
@Override
public <E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size) {
var extensions = list(type, predicate, comparator);
var totalMono = extensions.count();
if (page > 0) {
extensions = extensions.skip(((long) (page - 1)) * (long) size);
}
if (size > 0) {
extensions = extensions.take(size);
}
return extensions.collectList().zipWith(totalMono)
.map(tuple -> {
List<E> content = tuple.getT1();
Long total = tuple.getT2();
return new ListResult<>(page, size, total, content);
});
}
@Override
public <E extends Extension> Mono<E> fetch(Class<E> type, String name) {
var storeName = ExtensionUtil.buildStoreName(schemeManager.get(type), name);
return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(type, extensionStore));
}
@Override
public Mono<Unstructured> fetch(GroupVersionKind gvk, String name) {
var storeName = ExtensionUtil.buildStoreName(schemeManager.get(gvk), name);
return client.fetchByName(storeName)
.map(extensionStore -> converter.convertFrom(Unstructured.class, extensionStore));
}
@Override
public <E extends Extension> Mono<E> get(Class<E> type, String name) {
return fetch(type, name)
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(
"Extension " + type.getName() + " with name " + name + " not found")));
}
private Mono<Unstructured> get(GroupVersionKind gvk, String name) {
return fetch(gvk, name)
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(
"Extension " + gvk + " with name " + name + " not found")));
}
@Override
public <E extends Extension> Mono<E> create(E extension) {
var metadata = extension.getMetadata();
// those fields should be managed by halo.
metadata.setCreationTimestamp(Instant.now());
metadata.setDeletionTimestamp(null);
metadata.setVersion(null);
var extensionStore = converter.convertTo(extension);
return client.create(extensionStore.getName(), extensionStore.getData())
.map(created -> converter.convertFrom((Class<E>) extension.getClass(), created))
.doOnNext(watchers::onAdd);
}
@Override
public <E extends Extension> Mono<E> update(E extension) {
// overwrite some fields
Mono<? extends Extension> mono;
if (extension instanceof Unstructured unstructured) {
mono = get(unstructured.groupVersionKind(), extension.getMetadata().getName());
} else {
mono = get(extension.getClass(), extension.getMetadata().getName());
}
return mono
.map(old -> {
// reset some fields
var oldMetadata = old.getMetadata();
var newMetadata = extension.getMetadata();
newMetadata.setCreationTimestamp(oldMetadata.getCreationTimestamp());
newMetadata.setDeletionTimestamp(oldMetadata.getDeletionTimestamp());
extension.setMetadata(newMetadata);
return converter.convertTo(extension);
})
.flatMap(extensionStore -> client.update(extensionStore.getName(),
extensionStore.getVersion(),
extensionStore.getData()))
.map(updated -> converter.convertFrom((Class<E>) extension.getClass(), updated))
.doOnNext(updated -> watchers.onUpdate(extension, updated));
}
@Override
public <E extends Extension> Mono<E> delete(E extension) {
// set deletionTimestamp
extension.getMetadata().setDeletionTimestamp(Instant.now());
var extensionStore = converter.convertTo(extension);
return client.update(extensionStore.getName(), extensionStore.getVersion(),
extensionStore.getData())
.map(deleted -> converter.convertFrom((Class<E>) extension.getClass(), deleted))
.doOnNext(watchers::onDelete);
}
@Override
public void watch(Watcher watcher) {
this.watchers.addWatcher(watcher);
}
}

View File

@ -11,7 +11,7 @@ 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.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeWatcherManager;
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
@ -21,9 +21,9 @@ public class ExtensionCompositeRouterFunction implements
private final Map<Scheme, RouterFunction<ServerResponse>> schemeRouterFuncMapper;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public ExtensionCompositeRouterFunction(ExtensionClient client,
public ExtensionCompositeRouterFunction(ReactiveExtensionClient client,
SchemeWatcherManager watcherManager) {
this.client = client;
schemeRouterFuncMapper = new ConcurrentHashMap<>();

View File

@ -6,19 +6,18 @@ import org.springframework.lang.NonNull;
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.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionCreateHandler implements ExtensionRouterFunctionFactory.CreateHandler {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public ExtensionCreateHandler(Scheme scheme, ExtensionClient client) {
public ExtensionCreateHandler(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@ -29,18 +28,11 @@ class ExtensionCreateHandler implements ExtensionRouterFunctionFactory.CreateHan
return request.bodyToMono(Unstructured.class)
.switchIfEmpty(Mono.error(() -> new ExtensionConvertException(
"Cannot read body to " + scheme.groupVersionKind())))
.flatMap(extToCreate -> Mono.fromCallable(() -> {
var name = extToCreate.getMetadata().getName();
client.create(extToCreate);
return client.fetch(scheme.type(), name)
.orElseThrow(() -> new ExtensionNotFoundException(
"Extension with name " + name + " was not found"));
}))
.flatMap(client::create)
.flatMap(createdExt -> ServerResponse
.created(URI.create(pathPattern() + "/" + createdExt.getMetadata().getName()))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(createdExt))
.cast(ServerResponse.class);
.bodyValue(createdExt));
}
@Override

View File

@ -4,39 +4,29 @@ import org.springframework.http.MediaType;
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.Extension;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionDeleteHandler implements ExtensionRouterFunctionFactory.DeleteHandler {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
ExtensionDeleteHandler(Scheme scheme, ExtensionClient client) {
ExtensionDeleteHandler(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@Override
public Mono<ServerResponse> handle(ServerRequest request) {
String name = request.pathVariable("name");
return getExtension(name)
.flatMap(extension ->
Mono.fromRunnable(() -> client.delete(extension)).thenReturn(extension))
.flatMap(extension -> this.getExtension(name))
.flatMap(extension -> ServerResponse
var name = request.pathVariable("name");
return client.get(scheme.type(), name)
.flatMap(client::delete)
.flatMap(deleted -> ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(extension));
}
private Mono<? extends Extension> getExtension(String name) {
return Mono.justOrEmpty(client.fetch(scheme.type(), name))
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(
"Extension with name " + name + " was not found")));
.bodyValue(deleted));
}
@Override

View File

@ -5,16 +5,15 @@ import org.springframework.lang.NonNull;
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.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionGetHandler implements ExtensionRouterFunctionFactory.GetHandler {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public ExtensionGetHandler(Scheme scheme, ExtensionClient client) {
public ExtensionGetHandler(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@ -30,12 +29,9 @@ class ExtensionGetHandler implements ExtensionRouterFunctionFactory.GetHandler {
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);
return client.get(scheme.type(), extensionName)
.flatMap(extension -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(extension));
}
}

View File

@ -8,15 +8,15 @@ import org.springframework.lang.NonNull;
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.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public ExtensionListHandler(Scheme scheme, ExtensionClient client) {
public ExtensionListHandler(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@ -38,12 +38,12 @@ class ExtensionListHandler implements ExtensionRouterFunctionFactory.ListHandler
var fieldSelectors = request.queryParams().get("fieldSelector");
// TODO Resolve comparator from request
var listResult = client.list(scheme.type(),
labelAndFieldSelectorToPredicate(labelSelectors, fieldSelectors), null, page, size);
return ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(listResult);
return client.list(scheme.type(),
labelAndFieldSelectorToPredicate(labelSelectors, fieldSelectors), null, page, size)
.flatMap(listResult -> ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(listResult));
}
@Override

View File

@ -12,17 +12,17 @@ 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.ServerResponse;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
public class ExtensionRouterFunctionFactory {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public ExtensionRouterFunctionFactory(Scheme scheme, ExtensionClient client) {
public ExtensionRouterFunctionFactory(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}

View File

@ -5,20 +5,19 @@ import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionUpdateHandler implements ExtensionRouterFunctionFactory.UpdateHandler {
private final Scheme scheme;
private final ExtensionClient client;
private final ReactiveExtensionClient client;
ExtensionUpdateHandler(Scheme scheme, ExtensionClient client) {
ExtensionUpdateHandler(Scheme scheme, ReactiveExtensionClient client) {
this.scheme = scheme;
this.client = client;
}
@ -30,14 +29,9 @@ class ExtensionUpdateHandler implements ExtensionRouterFunctionFactory.UpdateHan
.filter(unstructured -> unstructured.getMetadata() != null
&& StringUtils.hasText(unstructured.getMetadata().getName())
&& Objects.equals(unstructured.getMetadata().getName(), name))
.switchIfEmpty(Mono.error(() -> new ExtensionConvertException(
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
"Cannot read body to " + scheme.groupVersionKind())))
.flatMap(extToUpdate -> Mono.fromCallable(() -> {
client.update(extToUpdate);
return client.fetch(scheme.type(), name)
.orElseThrow(() -> new ExtensionNotFoundException(
"Extension with name " + name + " was not found"));
}))
.flatMap(client::update)
.flatMap(updated -> ServerResponse
.ok()
.contentType(MediaType.APPLICATION_JSON)

View File

@ -1,10 +1,10 @@
package run.halo.app.extension.store;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Version;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Table;
/**
* ExtensionStore is an entity for storing Extension data into database.
@ -12,7 +12,7 @@ import lombok.Data;
* @author johnniang
*/
@Data
@Entity(name = "extensions")
@Table(name = "extensions")
public class ExtensionStore {
/**

View File

@ -22,31 +22,31 @@ public class ExtensionStoreClientJPAImpl implements ExtensionStoreClient {
@Override
public List<ExtensionStore> listByNamePrefix(String prefix) {
return repository.findAllByNameStartingWith(prefix);
return repository.findAllByNameStartingWith(prefix).collectList().block();
}
@Override
public Optional<ExtensionStore> fetchByName(String name) {
return repository.findById(name);
return repository.findById(name).blockOptional();
}
@Override
public ExtensionStore create(String name, byte[] data) {
var store = new ExtensionStore(name, data);
return repository.save(store);
return repository.save(store).block();
}
@Override
public ExtensionStore update(String name, Long version, byte[] data) {
var store = new ExtensionStore(name, data, version);
return repository.save(store);
return repository.save(store).block();
}
@Override
@Transactional
public ExtensionStore delete(String name, Long version) {
var extensionStore =
repository.findById(name).orElseThrow(EntityNotFoundException::new);
repository.findById(name).blockOptional().orElseThrow(EntityNotFoundException::new);
extensionStore.setVersion(version);
repository.delete(extensionStore);
return extensionStore;

View File

@ -1,8 +1,8 @@
package run.halo.app.extension.store;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
/**
* This repository contains some basic operations on ExtensionStore entity.
@ -10,7 +10,7 @@ import org.springframework.stereotype.Repository;
* @author johnniang
*/
@Repository
public interface ExtensionStoreRepository extends JpaRepository<ExtensionStore, String> {
public interface ExtensionStoreRepository extends R2dbcRepository<ExtensionStore, String> {
/**
* Finds all ExtensionStore by name prefix.
@ -18,6 +18,6 @@ public interface ExtensionStoreRepository extends JpaRepository<ExtensionStore,
* @param prefix is the prefix of name.
* @return all ExtensionStores which names starts with the given prefix.
*/
List<ExtensionStore> findAllByNameStartingWith(String prefix);
Flux<ExtensionStore> findAllByNameStartingWith(String prefix);
}

View File

@ -0,0 +1,18 @@
package run.halo.app.extension.store;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface ReactiveExtensionStoreClient {
Flux<ExtensionStore> listByNamePrefix(String prefix);
Mono<ExtensionStore> fetchByName(String name);
Mono<ExtensionStore> create(String name, byte[] data);
Mono<ExtensionStore> update(String name, Long version, byte[] data);
Mono<ExtensionStore> delete(String name, Long version);
}

View File

@ -0,0 +1,47 @@
package run.halo.app.extension.store;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Component
public class ReactiveExtensionStoreClientImpl implements ReactiveExtensionStoreClient {
private final ExtensionStoreRepository repository;
public ReactiveExtensionStoreClientImpl(ExtensionStoreRepository repository) {
this.repository = repository;
}
@Override
public Flux<ExtensionStore> listByNamePrefix(String prefix) {
return repository.findAllByNameStartingWith(prefix);
}
@Override
public Mono<ExtensionStore> fetchByName(String name) {
return repository.findById(name);
}
@Override
public Mono<ExtensionStore> create(String name, byte[] data) {
return repository.save(new ExtensionStore(name, data));
}
@Override
public Mono<ExtensionStore> update(String name, Long version, byte[] data) {
return repository.save(new ExtensionStore(name, data, version));
}
@Override
@Transactional
public Mono<ExtensionStore> delete(String name, Long version) {
return repository.findById(name)
.flatMap(extensionStore -> {
// reset the version
extensionStore.setVersion(version);
return repository.delete(extensionStore).thenReturn(extensionStore);
});
}
}

View File

@ -6,13 +6,14 @@ import java.util.List;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.EventListener;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import run.halo.app.extension.ExtensionClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
@ -27,21 +28,21 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
*/
@Slf4j
@Component
public class ExtensionResourceInitializer implements ApplicationListener<ApplicationReadyEvent> {
public class ExtensionResourceInitializer {
public static final Set<String> REQUIRED_EXTENSION_LOCATIONS =
Set.of("classpath:/extensions/*.yaml", "classpath:/extensions/*.yml");
private final HaloProperties haloProperties;
private final ExtensionClient extensionClient;
private final ReactiveExtensionClient extensionClient;
public ExtensionResourceInitializer(HaloProperties haloProperties,
ExtensionClient extensionClient) {
ReactiveExtensionClient extensionClient) {
this.haloProperties = haloProperties;
this.extensionClient = extensionClient;
}
@Override
public void onApplicationEvent(@NonNull ApplicationReadyEvent event) {
@EventListener
public Mono<Void> initialize(ApplicationReadyEvent readyEvent) {
var locations = new HashSet<String>();
if (!haloProperties.isRequiredExtensionDisabled()) {
locations.addAll(REQUIRED_EXTENSION_LOCATIONS);
@ -49,30 +50,35 @@ public class ExtensionResourceInitializer implements ApplicationListener<Applica
if (haloProperties.getInitialExtensionLocations() != null) {
locations.addAll(haloProperties.getInitialExtensionLocations());
}
if (CollectionUtils.isEmpty(locations)) {
return;
return Mono.empty();
}
var resources = locations.stream()
return Flux.fromIterable(locations)
.doOnNext(location ->
log.debug("Trying to initialize extension resources from location: {}", location))
.map(this::listResources)
.flatMap(List::stream)
.distinct()
.toArray(Resource[]::new);
log.info("Initializing [{}] extensions in locations: {}", resources.length, locations);
new YamlUnstructuredLoader(resources).load()
.forEach(unstructured -> extensionClient.fetch(unstructured.groupVersionKind(),
unstructured.getMetadata().getName())
.ifPresentOrElse(persisted -> {
unstructured.getMetadata()
.setVersion(persisted.getMetadata().getVersion());
// TODO Patch the unstructured instead of update it in the future
extensionClient.update(unstructured);
}, () -> extensionClient.create(unstructured)));
log.info("Initialized [{}] extensions in locations: {}", resources.length, locations);
.flatMapIterable(resources -> resources)
.doOnNext(resource -> log.debug("Initializing extension resource: {}", resource))
.map(resource -> new YamlUnstructuredLoader(resource).load())
.flatMapIterable(extensions -> extensions)
.flatMap(extension -> extensionClient.fetch(extension.groupVersionKind(),
extension.getMetadata().getName())
.flatMap(createdExtension -> {
extension.getMetadata()
.setVersion(createdExtension.getMetadata().getVersion());
return extensionClient.update(extension);
})
.switchIfEmpty(Mono.defer(() -> extensionClient.create(extension)))
)
.doOnNext(extension -> {
if (log.isDebugEnabled()) {
log.debug("Initialized extension resource: {}/{}", extension.groupVersionKind(),
extension.getMetadata().getName());
}
})
.then();
}
private List<Resource> listResources(String location) {

View File

@ -1,12 +1,12 @@
package run.halo.app.infra;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.convert.ConversionService;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
/**
@ -17,35 +17,30 @@ import run.halo.app.infra.utils.JsonUtils;
public class SystemConfigurableEnvironmentFetcher {
private static final String SYSTEM_CONFIGMAP_NAME = "system";
private final ExtensionClient extensionClient;
private final ReactiveExtensionClient extensionClient;
private final ConversionService conversionService;
public SystemConfigurableEnvironmentFetcher(ExtensionClient extensionClient,
public SystemConfigurableEnvironmentFetcher(ReactiveExtensionClient extensionClient,
ConversionService conversionService) {
this.extensionClient = extensionClient;
this.conversionService = conversionService;
}
public <T> Optional<T> fetch(String key, Class<T> type) {
var stringValue = getInternal(key);
if (stringValue == null) {
return Optional.empty();
}
if (conversionService.canConvert(String.class, type)) {
return Optional.ofNullable(conversionService.convert(stringValue, type));
}
return Optional.of(JsonUtils.jsonToObject(stringValue, type));
}
private String getInternal(String group) {
return getValuesInternal().get(group);
public <T> Mono<T> fetch(String key, Class<T> type) {
return getValuesInternal().map(map -> map.get(key))
.map(stringValue -> {
if (conversionService.canConvert(String.class, type)) {
return conversionService.convert(stringValue, type);
}
return JsonUtils.jsonToObject(stringValue, type);
});
}
@NonNull
private Map<String, String> getValuesInternal() {
private Mono<Map<String, String>> getValuesInternal() {
return extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIGMAP_NAME)
.filter(configMap -> configMap.getData() != null)
.map(ConfigMap::getData)
.orElse(Map.of());
.defaultIfEmpty(Map.of());
}
}

View File

@ -1,12 +1,11 @@
package run.halo.app.plugin;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
/**
@ -16,29 +15,29 @@ import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
* @since 2.0.0
*/
@Component
public class PluginBeforeStopSyncListener
implements ApplicationListener<HaloPluginBeforeStopEvent> {
public class PluginBeforeStopSyncListener {
private final ExtensionClient client;
private final ReactiveExtensionClient client;
public PluginBeforeStopSyncListener(ExtensionClient client) {
public PluginBeforeStopSyncListener(ReactiveExtensionClient client) {
this.client = client;
}
@Override
public void onApplicationEvent(@NonNull HaloPluginBeforeStopEvent event) {
PluginWrapper pluginWrapper = event.getPlugin();
PluginApplicationContext pluginContext = ExtensionContextRegistry.getInstance()
@EventListener
public Mono<Void> onApplicationEvent(@NonNull HaloPluginBeforeStopEvent event) {
var pluginWrapper = event.getPlugin();
var pluginContext = ExtensionContextRegistry.getInstance()
.getByPluginId(pluginWrapper.getPluginId());
cleanUpPluginExtensionResources(pluginContext);
return cleanUpPluginExtensionResources(pluginContext);
}
private void cleanUpPluginExtensionResources(PluginApplicationContext context) {
MultiValueMap<GroupVersionKind, String> gvkExtensionNames =
context.extensionNamesMapping();
gvkExtensionNames.forEach((gvk, extensionNames) ->
extensionNames.forEach(extensionName -> client.fetch(gvk, extensionName)
.ifPresent(client::delete)));
private Mono<Void> cleanUpPluginExtensionResources(PluginApplicationContext context) {
var gvkExtensionNames = context.extensionNamesMapping();
return Flux.fromIterable(gvkExtensionNames.entrySet())
.flatMap(entry -> Flux.fromIterable(entry.getValue())
.flatMap(extensionName -> client.fetch(entry.getKey(), extensionName))
.flatMap(client::delete))
.then();
}
}

View File

@ -1,6 +1,5 @@
package run.halo.app.plugin;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@ -66,30 +65,18 @@ public class PluginCompositeRouterFunction implements RouterFunction<ServerRespo
* @param haloPluginStartedEvent event for plugin started
*/
@EventListener(HaloPluginStartedEvent.class)
public void onPluginStarted(HaloPluginStartedEvent haloPluginStartedEvent) {
public Mono<Void> onPluginStarted(HaloPluginStartedEvent haloPluginStartedEvent) {
PluginWrapper plugin = haloPluginStartedEvent.getPlugin();
// Obtain plugin application context
PluginApplicationContext pluginApplicationContext =
ExtensionContextRegistry.getInstance().getByPluginId(plugin.getPluginId());
// create reverse proxy router function for plugin
RouterFunction<ServerResponse> reverseProxyRouterFunction =
reverseProxyRouterFunctionFactory.create(pluginApplicationContext);
List<RouterFunction<ServerResponse>> routerFunctions =
routerFunctions(pluginApplicationContext);
List<RouterFunction<ServerResponse>> combinedRouterFunctions =
new ArrayList<>(routerFunctions);
if (reverseProxyRouterFunction != null) {
combinedRouterFunctions.add(reverseProxyRouterFunction);
}
combinedRouterFunctions.stream()
return Flux.fromIterable(routerFunctions(pluginApplicationContext))
.concatWith(reverseProxyRouterFunctionFactory.create(pluginApplicationContext))
.reduce(RouterFunction::and)
.ifPresent(compositeRouterFunction -> {
routerFunctionRegistry.put(plugin.getPluginId(), compositeRouterFunction);
});
.doOnNext(routerFunction ->
routerFunctionRegistry.put(plugin.getPluginId(), routerFunction))
.then();
}
@EventListener(HaloPluginStoppedEvent.class)

View File

@ -7,8 +7,6 @@ import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
@ -19,13 +17,14 @@ import org.pf4j.DevelopmentPluginClasspath;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.EventListener;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.event.HaloPluginStartedEvent;
@ -36,50 +35,55 @@ import run.halo.app.plugin.event.HaloPluginStartedEvent;
* @since 2.0.0
*/
@Component
public class PluginStartedListener implements ApplicationListener<HaloPluginStartedEvent> {
public class PluginStartedListener {
private final ExtensionClient extensionClient;
private final ReactiveExtensionClient client;
public PluginStartedListener(ExtensionClient extensionClient) {
this.extensionClient = extensionClient;
public PluginStartedListener(ReactiveExtensionClient extensionClient) {
this.client = extensionClient;
}
@Override
public void onApplicationEvent(HaloPluginStartedEvent event) {
@EventListener
public Mono<Void> onApplicationEvent(HaloPluginStartedEvent event) {
PluginWrapper pluginWrapper = event.getPlugin();
Plugin plugin =
extensionClient.fetch(Plugin.class, pluginWrapper.getPluginId()).orElseThrow();
// load unstructured
DefaultResourceLoader resourceLoader =
var resourceLoader =
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
PluginApplicationContext pluginApplicationContext = ExtensionContextRegistry.getInstance()
var pluginApplicationContext = ExtensionContextRegistry.getInstance()
.getByPluginId(pluginWrapper.getPluginId());
return client.get(Plugin.class, pluginWrapper.getPluginId())
.zipWith(Mono.just(
lookupExtensions(pluginWrapper.getPluginPath(), pluginWrapper.getRuntimeMode())))
.flatMap(tuple2 -> {
var plugin = tuple2.getT1();
var extensionLocations = tuple2.getT2();
return Flux.fromIterable(extensionLocations)
.map(resourceLoader::getResource)
.filter(Resource::exists)
.map(resource -> new YamlUnstructuredLoader(resource).load())
.flatMapIterable(rs -> rs)
.flatMap(unstructured -> {
var metadata = unstructured.getMetadata();
// collector plugin initialize extension resources
pluginApplicationContext.addExtensionMapping(
unstructured.groupVersionKind(),
metadata.getName());
var labels = metadata.getLabels();
if (labels == null) {
labels = new HashMap<>();
}
labels.put(PluginConst.PLUGIN_NAME_LABEL_NAME,
plugin.getMetadata().getName());
metadata.setLabels(labels);
lookupExtensions(pluginWrapper.getPluginPath(),
pluginWrapper.getRuntimeMode())
.stream()
.map(resourceLoader::getResource)
.filter(Resource::exists)
.map(resource -> new YamlUnstructuredLoader(resource).load())
.flatMap(List::stream)
.forEach(unstructured -> {
MetadataOperator metadata = unstructured.getMetadata();
// collector plugin initialize extension resources
pluginApplicationContext.addExtensionMapping(unstructured.groupVersionKind(),
metadata.getName());
Map<String, String> labels = metadata.getLabels();
if (labels == null) {
labels = new HashMap<>();
metadata.setLabels(labels);
}
labels.put(PluginConst.PLUGIN_NAME_LABEL_NAME, plugin.getMetadata().getName());
extensionClient.fetch(unstructured.groupVersionKind(), metadata.getName())
.ifPresentOrElse(persisted -> {
unstructured.getMetadata().setVersion(persisted.getMetadata().getVersion());
extensionClient.update(unstructured);
}, () -> extensionClient.create(unstructured));
});
return client.fetch(unstructured.groupVersionKind(), metadata.getName())
.flatMap(extension -> {
unstructured.getMetadata()
.setVersion(extension.getMetadata().getVersion());
return client.update(unstructured);
})
.switchIfEmpty(Mono.defer(() -> client.create(unstructured)));
}).then();
}).then();
}
Set<String> lookupExtensions(Path pluginPath, RuntimeMode runtimeMode) {

View File

@ -5,6 +5,7 @@ import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* <p>This {@link SharedApplicationContextHolder} class is used to hold a singleton instance of
@ -52,8 +53,11 @@ public class SharedApplicationContextHolder {
(DefaultListableBeanFactory) sharedApplicationContext.getBeanFactory();
// register shared object here
ExtensionClient extensionClient = rootApplicationContext.getBean(ExtensionClient.class);
var extensionClient = rootApplicationContext.getBean(ExtensionClient.class);
var reactiveExtensionClient = rootApplicationContext.getBean(ReactiveExtensionClient.class);
beanFactory.registerSingleton("extensionClient", extensionClient);
beanFactory.registerSingleton("reactiveExtensionClient", reactiveExtensionClient);
DefaultSchemeManager defaultSchemeManager =
rootApplicationContext.getBean(DefaultSchemeManager.class);
beanFactory.registerSingleton("schemeManager", defaultSchemeManager);

View File

@ -6,12 +6,11 @@ import static org.springframework.web.reactive.function.server.RequestPredicates
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.Assert;
@ -19,10 +18,12 @@ 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.core.extension.ReverseProxy;
import run.halo.app.core.extension.ReverseProxy.FileReverseProxyProvider;
import run.halo.app.core.extension.ReverseProxy.ReverseProxyRule;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.plugin.PluginApplicationContext;
import run.halo.app.plugin.PluginConst;
@ -40,11 +41,11 @@ import run.halo.app.plugin.PluginConst;
public class ReverseProxyRouterFunctionFactory {
private static final String REVERSE_PROXY_API_PREFIX = "/assets";
private final ExtensionClient extensionClient;
private final ReactiveExtensionClient extensionClient;
private final JsBundleRuleProvider jsBundleRuleProvider;
public ReverseProxyRouterFunctionFactory(ExtensionClient extensionClient,
public ReverseProxyRouterFunctionFactory(ReactiveExtensionClient extensionClient,
JsBundleRuleProvider jsBundleRuleProvider) {
this.extensionClient = extensionClient;
this.jsBundleRuleProvider = jsBundleRuleProvider;
@ -59,57 +60,52 @@ public class ReverseProxyRouterFunctionFactory {
* @param pluginApplicationContext plugin application context
* @return A reverse proxy RouterFunction handle(nullable)
*/
@Nullable
public RouterFunction<ServerResponse> create(
@NonNull
public Mono<RouterFunction<ServerResponse>> create(
PluginApplicationContext pluginApplicationContext) {
String pluginId = pluginApplicationContext.getPluginId();
List<ReverseProxyRule> reverseProxyRules = getReverseProxyRules(pluginId);
return createReverseProxyRouterFunction(reverseProxyRules, pluginApplicationContext);
return createReverseProxyRouterFunction(pluginApplicationContext);
}
private RouterFunction<ServerResponse> createReverseProxyRouterFunction(
List<ReverseProxyRule> rules, PluginApplicationContext pluginApplicationContext) {
Assert.notNull(rules, "The reverseProxyRules must not be null.");
private Mono<RouterFunction<ServerResponse>> createReverseProxyRouterFunction(
PluginApplicationContext pluginApplicationContext) {
Assert.notNull(pluginApplicationContext, "The pluginApplicationContext must not be null.");
String pluginId = pluginApplicationContext.getPluginId();
return rules.stream()
.map(rule -> {
String routePath = buildRoutePath(pluginId, rule);
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginId,
routePath);
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
request -> {
Resource resource =
loadResourceByFileRule(pluginApplicationContext, rule, request);
if (!resource.exists()) {
return ServerResponse.notFound().build();
}
return ServerResponse.ok()
.bodyValue(resource);
});
})
.reduce(RouterFunction::and)
.orElse(null);
var pluginId = pluginApplicationContext.getPluginId();
var rules = getReverseProxyRules(pluginId);
return rules.map(rule -> {
String routePath = buildRoutePath(pluginId, rule);
log.debug("Plugin [{}] registered reverse proxy route path [{}]", pluginId,
routePath);
return RouterFunctions.route(GET(routePath).and(accept(ALL)),
request -> {
Resource resource =
loadResourceByFileRule(pluginApplicationContext, rule, request);
if (!resource.exists()) {
return ServerResponse.notFound().build();
}
return ServerResponse.ok()
.bodyValue(resource);
});
}).reduce(RouterFunction::and);
}
private List<ReverseProxyRule> getReverseProxyRules(String pluginId) {
List<ReverseProxyRule> rules = extensionClient.list(ReverseProxy.class,
reverseProxy -> {
String pluginName = reverseProxy.getMetadata()
.getLabels()
.get(PluginConst.PLUGIN_NAME_LABEL_NAME);
return pluginId.equals(pluginName);
},
null)
.stream()
private Flux<ReverseProxyRule> getReverseProxyRules(String pluginId) {
return extensionClient.list(ReverseProxy.class, hasPluginId(pluginId), null)
.map(ReverseProxy::getRules)
.flatMap(List::stream)
.collect(Collectors.toList());
.flatMapIterable(rules -> rules)
.concatWith(Flux.fromIterable(getJsBundleRules(pluginId)));
}
// populate plugin js bundle rules.
rules.addAll(getJsBundleRules(pluginId));
return rules;
private Predicate<ReverseProxy> hasPluginId(String pluginId) {
return proxy -> {
var labels = proxy.getMetadata().getLabels();
if (labels == null) {
return false;
}
var pluginName = labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME);
return pluginId.equals(pluginName);
};
}
private List<ReverseProxyRule> getJsBundleRules(String pluginId) {

View File

@ -26,13 +26,8 @@ public class DefaultUserDetailService
@Override
public Mono<UserDetails> updatePassword(UserDetails user, String newPassword) {
return Mono.just(user)
.map(userDetails -> withNewPassword(user, newPassword))
.flatMap(userDetails -> userService.updatePassword(
userDetails.getUsername(),
userDetails.getPassword())
.then(Mono.just(userDetails))
);
return userService.updatePassword(user.getUsername(), newPassword)
.map(u -> withNewPassword(user, newPassword));
}
@Override

View File

@ -7,9 +7,10 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.EventListener;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.Role.PolicyRule;
import run.halo.app.core.extension.RoleBinding;
@ -17,39 +18,41 @@ import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.User.UserSpec;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.properties.SecurityProperties.Initializer;
@Slf4j
public class SuperAdminInitializer implements ApplicationListener<ApplicationReadyEvent> {
public class SuperAdminInitializer {
private static final String SUPER_ROLE_NAME = "super-role";
private final ExtensionClient client;
private final ReactiveExtensionClient client;
private final PasswordEncoder passwordEncoder;
private final Initializer initializer;
public SuperAdminInitializer(ExtensionClient client, PasswordEncoder passwordEncoder,
public SuperAdminInitializer(ReactiveExtensionClient client, PasswordEncoder passwordEncoder,
Initializer initializer) {
this.client = client;
this.passwordEncoder = passwordEncoder;
this.initializer = initializer;
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
client.fetch(User.class, initializer.getSuperAdminUsername()).ifPresentOrElse(user -> {
// do nothing if admin has been initialized
}, () -> {
var admin = createAdmin();
var superRole = createSuperRole();
var roleBinding = bindAdminAndSuperRole(admin, superRole);
client.create(admin);
client.create(superRole);
client.create(roleBinding);
});
@EventListener
public Mono<Void> initialize(ApplicationReadyEvent readyEvent) {
return client.fetch(User.class, initializer.getSuperAdminUsername())
.switchIfEmpty(Mono.defer(() -> client.create(createAdmin()))
.flatMap(admin -> {
var superRole = createSuperRole();
return client.create(superRole)
.flatMap(role -> {
var binding = bindAdminAndSuperRole(admin, superRole);
return client.create(binding).thenReturn(role);
})
.thenReturn(admin);
}))
.then();
}
RoleBinding bindAdminAndSuperRole(User admin, Role superRole) {

View File

@ -1,6 +1,7 @@
package run.halo.app.security.authorization;
import org.springframework.security.core.userdetails.UserDetails;
import reactor.core.publisher.Mono;
/**
* @author guqing
@ -17,6 +18,7 @@ public interface AuthorizationRuleResolver {
*
* @param user authenticated user info
*/
@Deprecated(forRemoval = true, since = "2.0.0")
PolicyRuleList rulesFor(UserDetails user);
/**
@ -27,5 +29,8 @@ public interface AuthorizationRuleResolver {
* @param user user info
* @param visitor visitor
*/
@Deprecated(forRemoval = true, since = "2.0.0")
void visitRulesFor(UserDetails user, RuleAccumulator visitor);
Mono<AuthorizingVisitor> visitRules(UserDetails user, RequestInfo requestInfo);
}

View File

@ -7,10 +7,13 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.service.DefaultRoleBindingService;
import run.halo.app.core.extension.service.RoleBindingService;
@ -77,6 +80,39 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
}
}
@Override
public Mono<AuthorizingVisitor> visitRules(UserDetails user, RequestInfo requestInfo) {
var roleNamesImmutable = roleBindingService.listBoundRoleNames(user.getAuthorities());
var roleNames = new HashSet<>(roleNamesImmutable);
roleNames.add(AUTHENTICATED_ROLE);
var record = new AttributesRecord(user, requestInfo);
var visitor = new AuthorizingVisitor(record);
var stopVisiting = new AtomicBoolean(false);
return Flux.fromIterable(roleNames)
.flatMap(roleName -> {
if (stopVisiting.get()) {
return Mono.empty();
}
return roleService.getMonoRole(roleName)
.onErrorResume(t -> visitor.visit(null, null, t), t -> {
//Do nothing here
return Mono.empty();
})
.doOnNext(role -> {
var rules = fetchRules(role);
var source = roleBindingDescriber(roleName, user.getUsername());
for (var rule : rules) {
if (!visitor.visit(source, rule, null)) {
stopVisiting.set(true);
return;
}
}
});
})
.then(Mono.just(visitor));
}
private List<Role.PolicyRule> fetchRules(Role role) {
MetadataOperator metadata = role.getMetadata();
if (metadata == null || metadata.getAnnotations() == null) {

View File

@ -34,16 +34,16 @@ public class RequestInfoAuthorizationManager
ServerHttpRequest request = context.getExchange().getRequest();
RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
return authentication.map(auth -> {
UserDetails userDetails = this.createUserDetails(auth);
var record = new AttributesRecord(userDetails, requestInfo);
var visitor = new AuthorizingVisitor(record);
ruleResolver.visitRulesFor(userDetails, visitor);
if (!visitor.isAllowed()) {
showErrorMessage(visitor.getErrors());
return new AuthorizationDecision(false);
}
return new AuthorizationDecision(isGranted(auth));
return authentication.flatMap(auth -> {
var userDetails = this.createUserDetails(auth);
return this.ruleResolver.visitRules(userDetails, requestInfo)
.map(visitor -> {
if (!visitor.isAllowed()) {
showErrorMessage(visitor.getErrors());
return new AuthorizationDecision(false);
}
return new AuthorizationDecision(isGranted(auth));
});
});
}

View File

@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
import reactor.core.publisher.Mono;
@ -28,17 +27,11 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver {
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
// calculate the engine before rendering
var theme = themeResolver.getTheme(exchange.getRequest());
var templateEngine = engineManager.getTemplateEngine(theme);
setTemplateEngine(templateEngine);
return super.render(model, contentType, exchange);
}
@Override
protected ISpringWebFluxTemplateEngine getTemplateEngine() {
return super.getTemplateEngine();
return themeResolver.getTheme(exchange.getRequest()).flatMap(theme -> {
// calculate the engine before rendering
setTemplateEngine(engineManager.getTemplateEngine(theme));
return super.render(model, contentType, exchange);
});
}
}

View File

@ -1,9 +1,9 @@
package run.halo.app.theme;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting.Theme;
import run.halo.app.infra.properties.HaloProperties;
@ -17,51 +17,37 @@ import run.halo.app.infra.utils.FilePathUtils;
public class ThemeResolver {
private static final String THEME_WORK_DIR = "themes";
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private Function<ServerHttpRequest, ThemeContext> themeContextFunction;
private final HaloProperties haloProperties;
public ThemeResolver(SystemConfigurableEnvironmentFetcher environmentFetcher,
HaloProperties haloProperties) {
this.environmentFetcher = environmentFetcher;
this.haloProperties = haloProperties;
themeContextFunction = this::defaultThemeContextFunction;
}
public ThemeContext getTheme(ServerHttpRequest request) {
return themeContextFunction.apply(request);
}
private ThemeContext defaultThemeContextFunction(ServerHttpRequest request) {
var builder = ThemeContext.builder();
var themeName = request.getQueryParams().getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME);
// TODO Fetch activated theme name from other place.
String activation = environmentFetcher.fetch(Theme.GROUP, Theme.class)
public Mono<ThemeContext> getTheme(ServerHttpRequest request) {
return environmentFetcher.fetch(Theme.GROUP, Theme.class)
.map(Theme::getActive)
.orElseThrow();
if (StringUtils.isBlank(themeName)) {
themeName = activation;
}
if (StringUtils.equals(activation, themeName)) {
builder.active(true);
}
// TODO Validate the existence of the theme name.
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
THEME_WORK_DIR, themeName);
return builder
.name(themeName)
.path(path)
.build();
.switchIfEmpty(Mono.error(() -> new IllegalArgumentException("No theme activated")))
.map(activatedTheme -> {
var builder = ThemeContext.builder();
var themeName =
request.getQueryParams().getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME);
if (StringUtils.isBlank(themeName)) {
themeName = activatedTheme;
}
boolean active = false;
if (StringUtils.equals(activatedTheme, themeName)) {
active = true;
}
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
THEME_WORK_DIR, themeName);
return builder.name(themeName)
.path(path)
.active(active)
.build();
});
}
public Function<ServerHttpRequest, ThemeContext> getThemeContextFunction() {
return themeContextFunction;
}
public void setThemeContextFunction(
Function<ServerHttpRequest, ThemeContext> themeContextFunction) {
this.themeContextFunction = themeContextFunction;
}
}

View File

@ -5,8 +5,6 @@ spring:
output:
ansi:
enabled: always
jpa:
show-sql: true
halo:
security:
@ -25,7 +23,7 @@ halo:
logging:
level:
run.halo.app: DEBUG
org.springframework.r2dbc: DEBUG
springdoc:
api-docs:
enabled: true

View File

@ -4,30 +4,14 @@ spring:
output:
ansi:
enabled: detect
datasource:
type: com.zaxxer.hikari.HikariDataSource
# H2 database configuration.
driver-class-name: org.h2.Driver
url: jdbc:h2:file:${halo.work-dir}/db/halo;AUTO_SERVER=TRUE
r2dbc:
url: r2dbc:h2:file:///${halo.work-dir}/db/halo-next?options=AUTO_SERVER=TRUE;MODE=MySQL
username: admin
password: 123456
# MySQL database configuration.
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://127.0.0.1:3306/halo_next?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
# username: root
# password: 123456
# PostgreSQL database configuration.
# driver-class-name: org.postgresql.Driver
# url: jdbc:postgresql://127.0.0.1:5432/halo_next?charSet=UTF8&ssl=false
# username: postgres
# password: 123456
jpa:
hibernate:
ddl-auto: update
open-in-view: false
sql:
init:
mode: always
platform: h2
halo:
security:

View File

@ -0,0 +1,7 @@
create table if not exists extensions
(
name varchar(255) not null,
data blob,
version bigint,
primary key (name)
);

View File

@ -2,10 +2,13 @@ package run.halo.app;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerTypePredicate;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Test case for api path prefix predicate.
@ -34,4 +37,14 @@ public class PathPrefixPredicateTest {
}
@Test
void test() {
Flux.fromIterable(List.of(1, 2, 3))
.flatMap(i -> {
if (i == 2) {
return Mono.empty();
}
return Mono.just(i);
}).subscribe(System.out::println);
}
}

View File

@ -22,6 +22,7 @@ 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.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
@ -54,7 +55,7 @@ class ExtensionConfigurationTest {
.build();
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole(anyString())).thenReturn(role);
when(roleService.getMonoRole(anyString())).thenReturn(Mono.just(role));
// register scheme
schemeManager.register(FakeExtension.class);
@ -62,7 +63,7 @@ class ExtensionConfigurationTest {
@AfterEach
void cleanUp(@Autowired ExtensionStoreRepository repository) {
repository.deleteAll();
repository.deleteAll().subscribe();
}
@Test

View File

@ -2,7 +2,6 @@ package run.halo.app.core.extension.endpoint;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -11,7 +10,6 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -25,8 +23,9 @@ import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.web.reactive.function.BodyInserters;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
@ -41,7 +40,7 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
class ThemeEndpointTest {
@Mock
private ExtensionClient extensionClient;
private ReactiveExtensionClient extensionClient;
@Mock
private HaloProperties haloProperties;
@ -74,18 +73,14 @@ class ThemeEndpointTest {
@Test
void install() {
when(extensionClient.fetch(eq(Theme.class), eq("default")))
.then(answer -> {
Path defaultThemeManifestPath = tmpHaloWorkDir.resolve("themes/default/theme.yaml");
when(extensionClient.create(any(Unstructured.class))).thenReturn(
Mono.fromCallable(() -> {
var defaultThemeManifestPath = tmpHaloWorkDir.resolve("themes/default/theme.yaml");
assertThat(Files.exists(defaultThemeManifestPath)).isTrue();
Unstructured unstructured =
new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath))
.load()
.get(0);
return Optional.of(
Unstructured.OBJECT_MAPPER.convertValue(unstructured, Theme.class));
});
return new YamlUnstructuredLoader(new FileSystemResource(defaultThemeManifestPath))
.load()
.get(0);
})).thenReturn(Mono.empty()).thenReturn(Mono.empty());
MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder();
multipartBodyBuilder.part("file", new FileSystemResource(defaultTheme))

View File

@ -12,7 +12,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@ -22,9 +21,11 @@ import org.springframework.beans.factory.annotation.Autowired;
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.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
@ -32,8 +33,9 @@ import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@SpringBootTest
@ -48,7 +50,7 @@ class UserEndpointTest {
RoleService roleService;
@MockBean
ExtensionClient client;
ReactiveExtensionClient client;
@MockBean
UserService userService;
@ -63,7 +65,7 @@ class UserEndpointTest {
.build();
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole("fake-super-role")).thenReturn(role);
when(roleService.getMonoRole("authenticated")).thenReturn(Mono.just(role));
}
@Nested
@ -72,10 +74,13 @@ class UserEndpointTest {
@Test
void shouldResponseErrorIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
when(client.get(User.class, "fake-user"))
.thenReturn(Mono.error(new ExtensionNotFoundException()));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().is5xxServerError();
verify(client).get(User.class, "fake-user");
}
@Test
@ -84,7 +89,7 @@ class UserEndpointTest {
metadata.setName("fake-user");
var user = new User();
user.setMetadata(metadata);
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(user));
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().isOk()
@ -135,6 +140,13 @@ class UserEndpointTest {
@DisplayName("GrantPermission")
class GrantPermissionEndpointTest {
@BeforeEach
void setUp() {
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(Flux.empty());
when(client.get(User.class, "fake-user"))
.thenReturn(Mono.error(new ExtensionNotFoundException()));
}
@Test
void shouldGetBadRequestIfRequestBodyIsEmpty() {
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
@ -149,8 +161,9 @@ class UserEndpointTest {
@Test
void shouldGetNotFoundIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(mock(Role.class)));
when(client.get(User.class, "fake-user"))
.thenReturn(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)));
when(client.get(Role.class, "fake-role")).thenReturn(Mono.just(mock(Role.class)));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
@ -158,14 +171,15 @@ class UserEndpointTest {
.exchange()
.expectStatus().isNotFound();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, never()).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).get(same(User.class), eq("fake-user"));
verify(client, never()).get(same(Role.class), eq("fake-role"));
}
@Test
void shouldGetNotFoundIfRoleNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.empty());
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(mock(User.class)));
when(client.get(Role.class, "fake-role"))
.thenReturn(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
@ -173,15 +187,15 @@ class UserEndpointTest {
.exchange()
.expectStatus().isNotFound();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client).get(User.class, "fake-user");
verify(client).get(Role.class, "fake-role");
}
@Test
void shouldCreateRoleBindingIfNotExist() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(mock(User.class)));
var role = mock(Role.class);
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(role));
when(client.get(Role.class, "fake-role")).thenReturn(Mono.just(role));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
@ -189,31 +203,31 @@ class UserEndpointTest {
.exchange()
.expectStatus().isOk();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client).get(User.class, "fake-user");
verify(client).get(Role.class, "fake-role");
verify(client).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, never()).update(isA(RoleBinding.class));
verify(client, never()).delete(isA(RoleBinding.class));
}
@Test
void shouldDeleteRoleBindingIfNotProvided() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(mock(User.class)));
var role = mock(Role.class);
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(role));
when(client.get(Role.class, "fake-role")).thenReturn(Mono.just(role));
var roleBinding = RoleBinding.create("fake-user", "non-provided-fake-role");
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(
List.of(roleBinding));
when(client.list(same(RoleBinding.class), any(), any()))
.thenReturn(Flux.fromIterable(List.of(roleBinding)));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role"))).exchange()
.expectStatus().isOk();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, times(1))
verify(client).get(User.class, "fake-user");
verify(client).get(Role.class, "fake-role");
verify(client).create(RoleBinding.create("fake-user", "fake-role"));
verify(client)
.delete(argThat(binding -> binding.getMetadata().getName()
.equals(roleBinding.getMetadata().getName())));
verify(client, never()).update(isA(RoleBinding.class));

View File

@ -10,16 +10,16 @@ import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.TestRole;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Tests for {@link DefaultRoleService}.
@ -30,7 +30,7 @@ import run.halo.app.extension.ExtensionClient;
@ExtendWith(MockitoExtension.class)
class DefaultRoleServiceTest {
@Mock
private ExtensionClient extensionClient;
private ReactiveExtensionClient extensionClient;
private DefaultRoleService roleService;
@ -40,7 +40,7 @@ class DefaultRoleServiceTest {
}
@Test
void listDependencie() {
void listDependencies() {
Role roleManage = TestRole.getRoleManage();
Map<String, String> manageAnnotations = new HashMap<>();
manageAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
@ -54,11 +54,11 @@ class DefaultRoleServiceTest {
Role roleOther = TestRole.getRoleOther();
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-manage")))
.thenReturn(Optional.of(roleManage));
.thenReturn(Mono.just(roleManage));
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-view")))
.thenReturn(Optional.of(roleView));
.thenReturn(Mono.just(roleView));
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-other")))
.thenReturn(Optional.of(roleOther));
.thenReturn(Mono.just(roleOther));
// list without cycle
List<Role> roles = roleService.listDependencies(Set.of("role-template-apple-manage"));
@ -75,7 +75,7 @@ class DefaultRoleServiceTest {
anotherAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
roleOther.getMetadata().setAnnotations(anotherAnnotations);
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-other")))
.thenReturn(Optional.of(roleOther));
.thenReturn(Mono.just(roleOther));
// correct behavior is to ignore the cycle relation
List<Role> rolesFromCycle =
roleService.listDependencies(Set.of("role-template-apple-manage"));

View File

@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@ -14,7 +13,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -23,20 +21,22 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Mock
PasswordEncoder passwordEncoder;
@ -45,55 +45,47 @@ class UserServiceImplTest {
UserServiceImpl userService;
@Test
void shouldGetEmptyUserIfUserNotFoundInExtension() {
when(client.fetch(User.class, "faker")).thenReturn(Optional.empty());
void shouldThrowExceptionIfUserNotFoundInExtension() {
when(client.get(User.class, "faker")).thenReturn(
Mono.error(new ExtensionNotFoundException()));
StepVerifier.create(userService.getUser("faker"))
.verifyComplete();
.verifyError(ExtensionNotFoundException.class);
verify(client, times(1)).fetch(eq(User.class), eq("faker"));
verify(client, times(1)).get(eq(User.class), eq("faker"));
}
@Test
void shouldGetUserIfUserFoundInExtension() {
User fakeUser = new User();
when(client.fetch(User.class, "faker")).thenReturn(Optional.of(fakeUser));
when(client.get(User.class, "faker")).thenReturn(Mono.just(fakeUser));
StepVerifier.create(userService.getUser("faker"))
.assertNext(user -> assertEquals(fakeUser, user))
.verifyComplete();
verify(client, times(1)).fetch(eq(User.class), eq("faker"));
verify(client, times(1)).get(eq(User.class), eq("faker"));
}
@Test
void shouldUpdatePasswordIfUserFoundInExtension() {
User fakeUser = new User();
var fakeUser = new User();
fakeUser.setSpec(new User.UserSpec());
when(client.fetch(User.class, "faker")).thenReturn(Optional.of(fakeUser));
when(client.get(User.class, "faker")).thenReturn(Mono.just(fakeUser));
when(client.update(eq(fakeUser))).thenReturn(Mono.just(fakeUser));
StepVerifier.create(userService.updatePassword("faker", "new-fake-password"))
.expectNext(fakeUser)
.verifyComplete();
verify(client, times(1)).fetch(eq(User.class), eq("faker"));
verify(client, times(1)).get(eq(User.class), eq("faker"));
verify(client, times(1)).update(argThat(extension -> {
var user = (User) extension;
return "new-fake-password".equals(user.getSpec().getPassword());
}));
}
@Test
void shouldNotUpdatePasswordIfUserNotFoundInExtension() {
when(client.fetch(User.class, "faker")).thenReturn(Optional.empty());
StepVerifier.create(userService.updatePassword("faker", "new-fake-password"))
.verifyComplete();
verify(client, times(1)).fetch(eq(User.class), eq("faker"));
verify(client, times(0)).update(any());
}
@Test
void shouldListRolesIfUserFoundInExtension() {
User fakeUser = new User();
@ -102,7 +94,8 @@ class UserServiceImplTest {
fakeUser.setMetadata(metadata);
fakeUser.setSpec(new User.UserSpec());
when(client.list(eq(RoleBinding.class), any(), any())).thenReturn(getRoleBindings());
when(client.list(eq(RoleBinding.class), any(), any())).thenReturn(
Flux.fromIterable(getRoleBindings()));
Role roleA = new Role();
Metadata metadataA = new Metadata();
metadataA.setName("test-A");
@ -118,9 +111,9 @@ class UserServiceImplTest {
metadataC.setName("ddd");
roleC.setMetadata(metadataC);
when(client.fetch(eq(Role.class), eq("test-A"))).thenReturn(Optional.of(roleA));
when(client.fetch(eq(Role.class), eq("test-B"))).thenReturn(Optional.of(roleB));
lenient().when(client.fetch(eq(Role.class), eq("ddd"))).thenReturn(Optional.of(roleC));
when(client.fetch(eq(Role.class), eq("test-A"))).thenReturn(Mono.just(roleA));
when(client.fetch(eq(Role.class), eq("test-B"))).thenReturn(Mono.just(roleB));
lenient().when(client.fetch(eq(Role.class), eq("ddd"))).thenReturn(Mono.just(roleC));
StepVerifier.create(userService.listRoles("faker"))
.expectNext(roleA)
@ -212,50 +205,58 @@ class UserServiceImplTest {
@Test
void shouldUpdatePasswordWithDifferentPassword() {
userService = spy(userService);
var oldUser = createUser("fake-password");
var newUser = createUser("new-password");
doReturn(
Mono.just(createUser("fake-password")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
when(client.get(User.class, "fake-user")).thenReturn(
Mono.just(oldUser));
when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser));
when(passwordEncoder.matches("new-password", "fake-password")).thenReturn(false);
when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password");
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNext(createUser("new-password"))
.expectNext(newUser)
.verifyComplete();
verify(passwordEncoder, times(1)).matches("new-password", "fake-password");
verify(passwordEncoder, times(1)).encode("new-password");
verify(userService, times(2)).getUser("fake-user");
verify(passwordEncoder).matches("new-password", "fake-password");
verify(passwordEncoder).encode("new-password");
verify(client).get(User.class, "fake-user");
verify(client).update(argThat(extension -> {
var user = (User) extension;
return "encoded-new-password".equals(user.getSpec().getPassword());
}));
}
@Test
void shouldUpdatePasswordIfNoPasswordBefore() {
userService = spy(userService);
var oldUser = createUser("");
var newUser = createUser("new-password");
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser));
when(client.update(oldUser)).thenReturn(Mono.just(newUser));
when(passwordEncoder.matches("new-password", "")).thenReturn(false);
when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password");
doReturn(
Mono.just(createUser("")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNext(createUser("new-password"))
.expectNext(newUser)
.verifyComplete();
verify(passwordEncoder, never()).matches(anyString(), anyString());
verify(passwordEncoder, times(1)).encode("new-password");
verify(userService, times(2)).getUser("fake-user");
verify(passwordEncoder).matches("new-password", "");
verify(passwordEncoder).encode("new-password");
verify(client).update(argThat(extension -> {
var user = (User) extension;
return "encoded-new-password".equals(user.getSpec().getPassword());
}));
verify(client).get(User.class, "fake-user");
}
@Test
void shouldDoNothingIfPasswordNotChanged() {
userService = spy(userService);
doReturn(
Mono.just(createUser("fake-password")),
Mono.just(createUser("new-password")))
.when(userService)
.getUser("fake-user");
var oldUser = createUser("fake-password");
var newUser = createUser("new-password");
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser));
when(passwordEncoder.matches("fake-password", "fake-password")).thenReturn(true);
StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake-password"))
@ -263,22 +264,23 @@ class UserServiceImplTest {
.verifyComplete();
verify(passwordEncoder, times(1)).matches("fake-password", "fake-password");
verify(passwordEncoder, never()).encode("fake-password");
verify(userService, times(1)).getUser("fake-user");
verify(passwordEncoder, never()).encode(any());
verify(client, never()).update(any());
verify(client).get(User.class, "fake-user");
}
@Test
void shouldDoNothingIfUserNotFound() {
userService = spy(userService);
void shouldThrowExceptionIfUserNotFound() {
when(client.get(User.class, "fake-user"))
.thenReturn(Mono.error(new ExtensionNotFoundException()));
doReturn(Mono.empty()).when(userService).getUser("fake-user");
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
.expectNextCount(0)
.verifyComplete();
.verifyError(ExtensionNotFoundException.class);
verify(passwordEncoder, never()).matches(anyString(), anyString());
verify(passwordEncoder, never()).encode(anyString());
verify(userService, times(1)).getUser(anyString());
verify(client, never()).update(any());
verify(client).get(User.class, "fake-user");
}
User createUser(String password) {

View File

@ -48,6 +48,9 @@ class DefaultExtensionClientTest {
@Mock
SchemeManager schemeManager;
@Mock
ReactiveExtensionClient reactiveClient;
@InjectMocks
DefaultExtensionClient client;
@ -361,6 +364,7 @@ class DefaultExtensionClientTest {
@BeforeEach
void setUp() {
client.watch(watcher);
verify(reactiveClient).watch(watcher);
}
@Test

View File

@ -0,0 +1,441 @@
package run.halo.app.extension;
import static java.util.Collections.emptyList;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparing;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.exception.SchemeNotFoundException;
import run.halo.app.extension.store.ExtensionStore;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
@ExtendWith(MockitoExtension.class)
class ReactiveExtensionClientTest {
static final Scheme fakeScheme = Scheme.buildFromType(FakeExtension.class);
@Mock
ReactiveExtensionStoreClient storeClient;
@Mock
ExtensionConverter converter;
@Mock
SchemeManager schemeManager;
@InjectMocks
ReactiveExtensionClientImpl client;
@BeforeEach
void setUp() {
lenient().when(schemeManager.get(eq(FakeExtension.class)))
.thenReturn(fakeScheme);
lenient().when(schemeManager.get(eq(fakeScheme.groupVersionKind()))).thenReturn(fakeScheme);
}
FakeExtension createFakeExtension(String name, Long version) {
var fake = new FakeExtension();
var metadata = new Metadata();
metadata.setName(name);
metadata.setVersion(version);
fake.setMetadata(metadata);
fake.setApiVersion("fake.halo.run/v1alpha1");
fake.setKind("Fake");
return fake;
}
ExtensionStore createExtensionStore(String name) {
return createExtensionStore(name, null);
}
ExtensionStore createExtensionStore(String name, Long version) {
var extensionStore = new ExtensionStore();
extensionStore.setName(name);
extensionStore.setVersion(version);
return extensionStore;
}
Unstructured createUnstructured() throws JsonProcessingException {
String extensionJson = """
{
"apiVersion": "fake.halo.run/v1alpha1",
"kind": "Fake",
"metadata": {
"labels": {
"category": "fake",
"default": "true"
},
"name": "fake",
"creationTimestamp": "2011-12-03T10:15:30Z",
"version": 12345
}
}
""";
return Unstructured.OBJECT_MAPPER.readValue(extensionJson, Unstructured.class);
}
@Test
void shouldThrowSchemeNotFoundExceptionWhenSchemeNotRegistered() {
class UnRegisteredExtension extends AbstractExtension {
}
when(schemeManager.get(eq(UnRegisteredExtension.class)))
.thenThrow(SchemeNotFoundException.class);
when(schemeManager.get(isA(GroupVersionKind.class)))
.thenThrow(SchemeNotFoundException.class);
assertThrows(SchemeNotFoundException.class,
() -> client.list(UnRegisteredExtension.class, null, null));
assertThrows(SchemeNotFoundException.class,
() -> client.list(UnRegisteredExtension.class, null, null, 0, 10));
assertThrows(SchemeNotFoundException.class,
() -> client.fetch(UnRegisteredExtension.class, "fake"));
assertThrows(SchemeNotFoundException.class, () ->
client.fetch(fromAPIVersionAndKind("fake.halo.run/v1alpha1", "UnRegistered"), "fake"));
assertThrows(SchemeNotFoundException.class, () -> {
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
client.create(createFakeExtension("fake", null));
});
assertThrows(SchemeNotFoundException.class, () -> {
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
client.update(createFakeExtension("fake", 1L));
});
assertThrows(SchemeNotFoundException.class, () -> {
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
client.delete(createFakeExtension("fake", 1L));
});
}
@Test
void shouldReturnEmptyExtensions() {
when(storeClient.listByNamePrefix(anyString())).thenReturn(Flux.empty());
var fakes = client.list(FakeExtension.class, null, null);
StepVerifier.create(fakes)
.verifyComplete();
}
@Test
void shouldReturnExtensionsWithFilterAndSorter() {
var fake1 = createFakeExtension("fake-01", 1L);
var fake2 = createFakeExtension("fake-02", 1L);
when(
converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn(
fake1);
when(
converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn(
fake2);
when(storeClient.listByNamePrefix(anyString())).thenReturn(
Flux.fromIterable(
List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02"))));
// without filter and sorter
var fakes = client.list(FakeExtension.class, null, null);
StepVerifier.create(fakes)
.expectNext(fake1)
.expectNext(fake2)
.verifyComplete();
// with filter
fakes = client.list(FakeExtension.class, fake -> {
String name = fake.getMetadata().getName();
return "fake-01".equals(name);
}, null);
StepVerifier.create(fakes)
.expectNext(fake1)
.verifyComplete();
// with sorter
fakes = client.list(FakeExtension.class, null,
reverseOrder(comparing(fake -> fake.getMetadata().getName())));
StepVerifier.create(fakes)
.expectNext(fake2)
.expectNext(fake1)
.verifyComplete();
}
@Test
void shouldQueryPageableAndCorrectly() {
var fake1 = createFakeExtension("fake-01", 1L);
var fake2 = createFakeExtension("fake-02", 1L);
var fake3 = createFakeExtension("fake-03", 1L);
when(
converter.convertFrom(FakeExtension.class, createExtensionStore("fake-01"))).thenReturn(
fake1);
when(
converter.convertFrom(FakeExtension.class, createExtensionStore("fake-02"))).thenReturn(
fake2);
when(
converter.convertFrom(FakeExtension.class, createExtensionStore("fake-03"))).thenReturn(
fake3);
when(storeClient.listByNamePrefix(anyString())).thenReturn(Flux.fromIterable(
List.of(createExtensionStore("fake-01"), createExtensionStore("fake-02"),
createExtensionStore("fake-03"))));
// without filter and sorter.
var fakes = client.list(FakeExtension.class, null, null, 1, 10);
StepVerifier.create(fakes)
.expectNext(new ListResult<>(1, 10, 3, List.of(fake1, fake2, fake3)))
.verifyComplete();
// out of page range
fakes = client.list(FakeExtension.class, null, null, 100, 10);
StepVerifier.create(fakes)
.expectNext(new ListResult<>(100, 10, 3, emptyList()))
.verifyComplete();
// with filter only
fakes =
client.list(FakeExtension.class, fake -> "fake-03".equals(fake.getMetadata().getName()),
null, 1, 10);
StepVerifier.create(fakes)
.expectNext(new ListResult<>(1, 10, 1, List.of(fake3)))
.verifyComplete();
// with sorter only
fakes = client.list(FakeExtension.class, null,
reverseOrder(comparing(fake -> fake.getMetadata().getName())), 1, 10);
StepVerifier.create(fakes)
.expectNext(new ListResult<>(1, 10, 3, List.of(fake3, fake2, fake1)))
.verifyComplete();
// without page
fakes = client.list(FakeExtension.class, null, null, 0, 0);
StepVerifier.create(fakes)
.expectNext(new ListResult<>(0, 0, 3, List.of(fake1, fake2, fake3)))
.verifyComplete();
}
@Test
void shouldFetchNothing() {
when(storeClient.fetchByName(any())).thenReturn(Mono.empty());
var fake = client.fetch(FakeExtension.class, "fake");
StepVerifier.create(fake)
.verifyComplete();
verify(converter, times(0)).convertFrom(any(), any());
verify(storeClient, times(1)).fetchByName(any());
}
@Test
void shouldNotFetchUnstructured() {
when(schemeManager.get(isA(GroupVersionKind.class)))
.thenReturn(fakeScheme);
when(storeClient.fetchByName(any())).thenReturn(Mono.empty());
var unstructuredFake = client.fetch(fakeScheme.groupVersionKind(), "fake");
StepVerifier.create(unstructuredFake)
.verifyComplete();
verify(converter, times(0)).convertFrom(any(), any());
verify(schemeManager, times(1)).get(isA(GroupVersionKind.class));
verify(storeClient, times(1)).fetchByName(any());
}
@Test
void shouldFetchAnExtension() {
var storeName = "/registry/fake.halo.run/fakes/fake";
when(storeClient.fetchByName(storeName)).thenReturn(
Mono.just(createExtensionStore(storeName)));
when(
converter.convertFrom(FakeExtension.class, createExtensionStore(storeName))).thenReturn(
createFakeExtension("fake", 1L));
var fake = client.fetch(FakeExtension.class, "fake");
StepVerifier.create(fake)
.expectNext(createFakeExtension("fake", 1L))
.verifyComplete();
verify(storeClient, times(1)).fetchByName(eq(storeName));
verify(converter, times(1)).convertFrom(eq(FakeExtension.class),
eq(createExtensionStore(storeName)));
}
@Test
void shouldFetchUnstructuredExtension() throws JsonProcessingException {
var storeName = "/registry/fake.halo.run/fakes/fake";
when(storeClient.fetchByName(storeName)).thenReturn(
Mono.just(createExtensionStore(storeName)));
when(schemeManager.get(isA(GroupVersionKind.class)))
.thenReturn(fakeScheme);
when(converter.convertFrom(Unstructured.class, createExtensionStore(storeName)))
.thenReturn(createUnstructured());
var fake = client.fetch(fakeScheme.groupVersionKind(), "fake");
StepVerifier.create(fake)
.expectNext(createUnstructured())
.verifyComplete();
verify(storeClient, times(1)).fetchByName(eq(storeName));
verify(schemeManager, times(1)).get(isA(GroupVersionKind.class));
verify(converter, times(1)).convertFrom(eq(Unstructured.class),
eq(createExtensionStore(storeName)));
}
@Test
void shouldCreateSuccessfully() {
var fake = createFakeExtension("fake", null);
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.create(any(), any())).thenReturn(
Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake")));
when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake);
StepVerifier.create(client.create(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any());
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@Test
void shouldCreateUsingUnstructuredSuccessfully() throws JsonProcessingException {
var fake = createUnstructured();
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.create(any(), any())).thenReturn(
Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake")));
when(converter.convertFrom(same(Unstructured.class), any())).thenReturn(fake);
StepVerifier.create(client.create(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any());
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@Test
void shouldUpdateSuccessfully() {
var fake = createFakeExtension("fake", 2L);
var storeName = "/registry/fake.halo.run/fakes/fake";
when(converter.convertTo(any())).thenReturn(
createExtensionStore(storeName, 2L));
when(storeClient.update(any(), any(), any())).thenReturn(
Mono.just(createExtensionStore(storeName, 2L)));
when(storeClient.fetchByName(storeName)).thenReturn(
Mono.just(createExtensionStore(storeName, 1L)));
when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake);
StepVerifier.create(client.update(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1))
.update(eq("/registry/fake.halo.run/fakes/fake"), eq(2L), any());
}
@Test
void shouldUpdateUnstructuredSuccessfully() throws JsonProcessingException {
var fake = createUnstructured();
var name = "/registry/fake.halo.run/fakes/fake";
when(converter.convertTo(any()))
.thenReturn(createExtensionStore(name, 12345L));
when(storeClient.update(any(), any(), any()))
.thenReturn(Mono.just(createExtensionStore(name, 12345L)));
when(storeClient.fetchByName(name))
.thenReturn(Mono.just(createExtensionStore(name, 12346L)));
when(converter.convertFrom(same(Unstructured.class), any())).thenReturn(fake);
StepVerifier.create(client.update(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1))
.update(eq("/registry/fake.halo.run/fakes/fake"), eq(12345L), any());
}
@Test
void shouldDeleteSuccessfully() {
var fake = createFakeExtension("fake", 2L);
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.update(any(), any(), any())).thenReturn(
Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake")));
when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake);
StepVerifier.create(client.delete(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(any());
verify(storeClient, times(1)).update(any(), any(), any());
verify(storeClient, never()).delete(any(), any());
}
@Nested
@DisplayName("Extension watcher test")
class WatcherTest {
@Mock
Watcher watcher;
@BeforeEach
void setUp() {
client.watch(watcher);
}
@Test
void shouldWatchOnAddSuccessfully() {
doNothing().when(watcher).onAdd(any());
shouldCreateSuccessfully();
verify(watcher, times(1)).onAdd(any());
}
@Test
void shouldWatchOnUpdateSuccessfully() {
doNothing().when(watcher).onUpdate(any(), any());
shouldUpdateSuccessfully();
verify(watcher, times(1)).onUpdate(any(), any());
}
@Test
void shouldWatchOnDeleteSuccessfully() {
doNothing().when(watcher).onDelete(any());
shouldDeleteSuccessfully();
verify(watcher, times(1)).onDelete(any());
}
}
}

View File

@ -15,8 +15,8 @@ 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.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeWatcherManager;
import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered;
@ -26,7 +26,7 @@ import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered;
class ExtensionCompositeRouterFunctionTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldRouteWhenSchemeRegistered() {

View File

@ -13,7 +13,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Objects;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@ -24,9 +23,9 @@ 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.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionConvertException;
@ -36,7 +35,7 @@ import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionCreateHandlerTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
@ -60,7 +59,7 @@ class ExtensionCreateHandlerTest {
var serverRequest = MockServerRequest.builder()
.body(Mono.just(unstructured));
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
when(client.create(any(Unstructured.class))).thenReturn(Mono.just(unstructured));
var scheme = Scheme.buildFromType(FakeExtension.class);
var getHandler = new ExtensionCreateHandler(scheme, client);
@ -70,13 +69,12 @@ class ExtensionCreateHandlerTest {
.consumeNextWith(response -> {
assertEquals(HttpStatus.CREATED, response.statusCode());
assertEquals("/apis/fake.halo.run/v1alpha1/fakes/my-fake",
response.headers().getLocation().toString());
Objects.requireNonNull(response.headers().getLocation()).toString());
assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType());
assertTrue(response instanceof EntityResponse<?>);
assertEquals(fake, ((EntityResponse<?>) response).entity());
assertEquals(unstructured, ((EntityResponse<?>) response).entity());
})
.verifyComplete();
verify(client, times(1)).fetch(eq(FakeExtension.class), eq("my-fake"));
verify(client, times(1)).create(eq(unstructured));
}

View File

@ -7,12 +7,10 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doNothing;
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;
@ -23,9 +21,9 @@ 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.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ -34,7 +32,7 @@ import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionDeleteHandlerTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
@ -59,8 +57,8 @@ class ExtensionDeleteHandlerTest {
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.body(Mono.just(unstructured));
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
doNothing().when(client).delete(any());
when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake));
when(client.delete(eq(fake))).thenReturn(Mono.just(fake));
var scheme = Scheme.buildFromType(FakeExtension.class);
var deleteHandler = new ExtensionDeleteHandler(scheme, client);
@ -74,7 +72,7 @@ class ExtensionDeleteHandlerTest {
assertEquals(fake, ((EntityResponse<?>) response).entity());
})
.verifyComplete();
verify(client, times(2)).fetch(eq(FakeExtension.class), eq("my-fake"));
verify(client, times(1)).get(eq(FakeExtension.class), eq("my-fake"));
verify(client, times(1)).delete(any());
verify(client, times(0)).update(any());
}
@ -93,7 +91,8 @@ class ExtensionDeleteHandlerTest {
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.build();
when(client.fetch(FakeExtension.class, "my-fake")).thenReturn(Optional.empty());
when(client.get(FakeExtension.class, "my-fake")).thenReturn(
Mono.error(new ExtensionNotFoundException()));
var scheme = Scheme.buildFromType(FakeExtension.class);
var deleteHandler = new ExtensionDeleteHandler(scheme, client);
@ -102,7 +101,7 @@ class ExtensionDeleteHandlerTest {
StepVerifier.create(responseMono)
.verifyError(ExtensionNotFoundException.class);
verify(client, times(1)).fetch(same(FakeExtension.class), anyString());
verify(client, times(1)).get(same(FakeExtension.class), anyString());
verify(client, times(0)).update(any());
verify(client, times(0)).delete(any());
}

View File

@ -1,12 +1,10 @@
package run.halo.app.extension.router;
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;
@ -15,9 +13,11 @@ 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 org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ -25,7 +25,7 @@ import run.halo.app.extension.exception.ExtensionNotFoundException;
class ExtensionGetHandlerTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
@ -43,7 +43,7 @@ class ExtensionGetHandlerTest {
.pathVariable("name", "my-fake")
.build();
final var fake = new FakeExtension();
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake));
var responseMono = getHandler.handle(serverRequest);
@ -64,8 +64,12 @@ class ExtensionGetHandlerTest {
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.build();
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.empty());
when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(
Mono.error(new ExtensionNotFoundException()));
assertThrows(ExtensionNotFoundException.class, () -> getHandler.handle(serverRequest));
Mono<ServerResponse> responseMono = getHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.expectError(ExtensionNotFoundException.class)
.verify();
}
}

View File

@ -16,17 +16,18 @@ 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.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
@ExtendWith(MockitoExtension.class)
class ExtensionListHandlerTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
@ -44,7 +45,7 @@ class ExtensionListHandlerTest {
final var fake = new FakeExtension();
var fakeListResult = new ListResult<>(0, 0, 1, List.of(fake));
when(client.list(same(FakeExtension.class), any(), any(), anyInt(), anyInt()))
.thenReturn(fakeListResult);
.thenReturn(Mono.just(fakeListResult));
var responseMono = listHandler.handle(serverRequest);

View File

@ -15,10 +15,9 @@ 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.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.router.ExtensionRouterFunctionFactory;
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.CreateHandler;
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.GetHandler;
import run.halo.app.extension.router.ExtensionRouterFunctionFactory.ListHandler;
@ -28,7 +27,7 @@ import run.halo.app.extension.router.ExtensionRouterFunctionFactory.UpdateHandle
class ExtensionRouterFunctionFactoryTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldCreateSuccessfully() {

View File

@ -14,7 +14,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Objects;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@ -23,21 +22,21 @@ 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 org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.FakeExtension;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ExtendWith(MockitoExtension.class)
class ExtensionUpdateHandlerTest {
@Mock
ExtensionClient client;
ReactiveExtensionClient client;
@Test
void shouldBuildPathPatternCorrectly() {
@ -62,7 +61,8 @@ class ExtensionUpdateHandlerTest {
var serverRequest = MockServerRequest.builder()
.pathVariable("name", "my-fake")
.body(Mono.just(unstructured));
when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Optional.of(fake));
// when(client.fetch(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.just(fake));
when(client.update(eq(unstructured))).thenReturn(Mono.just(unstructured));
var scheme = Scheme.buildFromType(FakeExtension.class);
var updateHandler = new ExtensionUpdateHandler(scheme, client);
@ -73,10 +73,10 @@ class ExtensionUpdateHandlerTest {
assertEquals(HttpStatus.OK, response.statusCode());
assertEquals(MediaType.APPLICATION_JSON, response.headers().getContentType());
assertTrue(response instanceof EntityResponse<?>);
assertEquals(fake, ((EntityResponse<?>) response).entity());
assertEquals(unstructured, ((EntityResponse<?>) response).entity());
})
.verifyComplete();
verify(client, times(1)).fetch(eq(FakeExtension.class), eq("my-fake"));
// verify(client, times(1)).fetch(eq(FakeExtension.class), eq("my-fake"));
verify(client, times(1)).update(eq(unstructured));
}
@ -89,7 +89,7 @@ class ExtensionUpdateHandlerTest {
var updateHandler = new ExtensionUpdateHandler(scheme, client);
var responseMono = updateHandler.handle(serverRequest);
StepVerifier.create(responseMono)
.verifyError(ExtensionConvertException.class);
.verifyError(ServerWebInputException.class);
}
@Test

View File

@ -4,17 +4,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ExtendWith(MockitoExtension.class)
class ExtensionStoreClientJPAImplTest {
@ -33,7 +34,7 @@ class ExtensionStoreClientJPAImplTest {
);
when(repository.findAllByNameStartingWith("/registry/posts"))
.thenReturn(expectedExtensions);
.thenReturn(Flux.fromIterable(expectedExtensions));
var gotExtensions = client.listByNamePrefix("/registry/posts");
assertEquals(expectedExtensions, gotExtensions);
@ -44,8 +45,8 @@ class ExtensionStoreClientJPAImplTest {
var expectedExtension =
new ExtensionStore("/registry/posts/hello-world", "this is post".getBytes(), 1L);
when(repository.findById("/registry/posts/hello-halo")).thenReturn(
Optional.of(expectedExtension));
when(repository.findById("/registry/posts/hello-halo"))
.thenReturn(Mono.just(expectedExtension));
var gotExtension = client.fetchByName("/registry/posts/hello-halo");
assertTrue(gotExtension.isPresent());
@ -57,7 +58,8 @@ class ExtensionStoreClientJPAImplTest {
var expectedExtension =
new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L);
when(repository.save(any())).thenReturn(expectedExtension);
when(repository.save(any()))
.thenReturn(Mono.just(expectedExtension));
var createdExtension =
client.create("/registry/posts/hello-halo", "hello halo".getBytes());
@ -70,7 +72,7 @@ class ExtensionStoreClientJPAImplTest {
var expectedExtension =
new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L);
when(repository.save(any())).thenReturn(expectedExtension);
when(repository.save(any())).thenReturn(Mono.just(expectedExtension));
var updatedExtension =
client.update("/registry/posts/hello-halo", 1L, "hello halo".getBytes());
@ -81,7 +83,7 @@ class ExtensionStoreClientJPAImplTest {
@Test
void shouldThrowEntityNotFoundExceptionWhenDeletingNonExistExt() {
when(repository.findById(any())).thenReturn(Optional.empty());
when(repository.findById(anyString())).thenReturn(Mono.empty());
assertThrows(EntityNotFoundException.class,
() -> client.delete("/registry/posts/hello-halo", 1L));
@ -92,8 +94,8 @@ class ExtensionStoreClientJPAImplTest {
var expectedExtension =
new ExtensionStore("/registry/posts/hello-halo", "hello halo".getBytes(), 2L);
when(repository.findById(any())).thenReturn(Optional.of(expectedExtension));
doNothing().when(repository).delete(any());
when(repository.findById(anyString())).thenReturn(Mono.just(expectedExtension));
when(repository.delete(any())).thenReturn(Mono.empty());
var deletedExtension = client.delete("/registry/posts/hello-halo", 2L);

View File

@ -12,7 +12,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
@ -25,8 +24,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.util.FileSystemUtils;
import run.halo.app.extension.ExtensionClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
@ -41,7 +42,7 @@ import run.halo.app.infra.utils.JsonUtils;
class ExtensionResourceInitializerTest {
@Mock
private ExtensionClient extensionClient;
private ReactiveExtensionClient extensionClient;
@Mock
private HaloProperties haloProperties;
@Mock
@ -119,12 +120,16 @@ class ExtensionResourceInitializerTest {
@Test
void onApplicationEvent() throws JSONException {
when(haloProperties.isRequiredExtensionDisabled()).thenReturn(true);
ArgumentCaptor<Unstructured> argumentCaptor = ArgumentCaptor.forClass(Unstructured.class);
var argumentCaptor = ArgumentCaptor.forClass(Unstructured.class);
when(extensionClient.fetch(any(GroupVersionKind.class), any()))
.thenReturn(Optional.empty());
.thenReturn(Mono.empty());
when(extensionClient.create(any())).thenReturn(Mono.empty());
var initializeMono = extensionResourceInitializer.initialize(applicationReadyEvent);
StepVerifier.create(initializeMono)
.verifyComplete();
extensionResourceInitializer.onApplicationEvent(applicationReadyEvent);
verify(extensionClient, times(3)).create(argumentCaptor.capture());

View File

@ -1,6 +1,7 @@
package run.halo.app.plugin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -62,17 +63,20 @@ class PluginCompositeRouterFunctionTest {
handlerFunction = request -> ServerResponse.ok().build();
routerFunction = request -> Mono.just(handlerFunction);
ObjectProvider objectProvider = mock(ObjectProvider.class);
var objectProvider = mock(ObjectProvider.class);
when(objectProvider.orderedStream()).thenReturn(Stream.of(routerFunction));
when(pluginApplicationContext.getBeanProvider(RouterFunction.class))
.thenReturn(objectProvider);
when(reverseProxyRouterFunctionFactory.create(any())).thenReturn(Mono.empty());
}
@Test
void route() {
// trigger haloPluginStartedEvent
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
StepVerifier.create(compositeRouterFunction.onPluginStarted(
new HaloPluginStartedEvent(this, pluginWrapper)))
.verifyComplete();
RouterFunctionMapping mapping = new RouterFunctionMapping(compositeRouterFunction);
mapping.setMessageReaders(this.codecConfigurer.getReaders());
@ -90,14 +94,18 @@ class PluginCompositeRouterFunctionTest {
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isNull();
// trigger haloPluginStartedEvent
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
StepVerifier.create(compositeRouterFunction.onPluginStarted(
new HaloPluginStartedEvent(this, pluginWrapper)))
.verifyComplete();
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isEqualTo(routerFunction);
}
@Test
void onPluginStopped() {
// trigger haloPluginStartedEvent
compositeRouterFunction.onPluginStarted(new HaloPluginStartedEvent(this, pluginWrapper));
StepVerifier.create(compositeRouterFunction.onPluginStarted(
new HaloPluginStartedEvent(this, pluginWrapper)))
.verifyComplete();
assertThat(compositeRouterFunction.getRouterFunction("fakeA")).isEqualTo(routerFunction);
// trigger HaloPluginStoppedEvent

View File

@ -1,6 +1,5 @@
package run.halo.app.plugin.resources;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ -12,11 +11,11 @@ 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.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginApplicationContext;
import run.halo.app.plugin.PluginConst;
@ -31,7 +30,7 @@ import run.halo.app.plugin.PluginConst;
class ReverseProxyRouterFunctionFactoryTest {
@Mock
private ExtensionClient extensionClient;
private ReactiveExtensionClient extensionClient;
@Mock
private PluginApplicationContext pluginApplicationContext;
@ -50,14 +49,15 @@ class ReverseProxyRouterFunctionFactoryTest {
when(pluginApplicationContext.getPluginId()).thenReturn("fakeA");
when(extensionClient.list(eq(ReverseProxy.class), any(), any())).thenReturn(
List.of(reverseProxy));
Flux.just(reverseProxy));
}
@Test
void create() {
RouterFunction<ServerResponse> routerFunction =
reverseProxyRouterFunctionFactory.create(pluginApplicationContext);
assertThat(routerFunction).isNotNull();
var routerFunction = reverseProxyRouterFunctionFactory.create(pluginApplicationContext);
StepVerifier.create(routerFunction)
.expectNextCount(1)
.verifyComplete();
}

View File

@ -43,8 +43,10 @@ class DefaultUserDetailServiceTest {
void shouldUpdatePasswordSuccessfully() {
var fakeUser = createFakeUserDetails();
var user = new run.halo.app.core.extension.User();
when(userService.updatePassword("faker", "new-fake-password")).thenReturn(
Mono.just("").then()
Mono.just(user)
);
var userDetailsMono = userDetailService.updatePassword(fakeUser, "new-fake-password");

View File

@ -15,7 +15,7 @@ import org.springframework.test.web.reactive.server.WebTestClient;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
@SpringBootTest(properties = {"halo.security.initializer.disabled=false",
"halo.security.initializer.super-admin-username=fake-admin",
@ -24,9 +24,8 @@ import run.halo.app.extension.ExtensionClient;
@AutoConfigureTestDatabase
class SuperAdminInitializerTest {
@Autowired
@SpyBean
ExtensionClient client;
ReactiveExtensionClient client;
@Autowired
WebTestClient webClient;

View File

@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
@ -14,6 +15,9 @@ import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.Metadata;
import run.halo.app.security.LoginUtils;
@SpringBootTest
@ -26,6 +30,9 @@ class JwtAuthenticationTest {
@MockBean
ReactiveUserDetailsService userDetailsService;
@MockBean
RoleService roleService;
@Test
void accessProtectedApiWithoutToken() {
webClient.get().uri("/api/v1/test/hello").exchange().expectStatus().isUnauthorized();
@ -40,6 +47,19 @@ class JwtAuthenticationTest {
.roles("USER")
.build()
));
var role = new Role();
var metadata = new Metadata();
metadata.setName("USER");
role.setMetadata(metadata);
role.setRules(List.of(new Role.PolicyRule.Builder()
.apiGroups("")
.resources("test")
.resourceNames("hello")
.build()));
when(roleService.getMonoRole("authenticated")).thenReturn(Mono.empty());
when(roleService.getMonoRole("USER")).thenReturn(Mono.just(role));
final var token = LoginUtils.login(webClient, "username", "password").block();
webClient.get().uri("/api/v1/test/hello")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)

View File

@ -1,5 +1,6 @@
package run.halo.app.security.authorization;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -31,6 +32,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.Role.PolicyRule;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.security.LoginUtils;
@SpringBootTest
@ -51,12 +53,15 @@ class AuthorizationTest {
void accessProtectedApiWithoutSufficientRole() {
when(userDetailsService.findByUsername(eq("user"))).thenReturn(
Mono.just(User.withDefaultPasswordEncoder().username("user").password("password")
// .roles("role-template-view-posts", "role-template-manage-posts")
.roles("invalid-role").build()));
when(roleService.getMonoRole(any())).thenReturn(Mono.empty());
var token = LoginUtils.login(webClient, "user", "password").block();
webClient.get().uri("/apis/fake.halo.run/v1/posts")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).exchange().expectStatus()
.isForbidden();
verify(roleService, times(1)).getMonoRole("authenticated");
verify(roleService, times(1)).getMonoRole("invalid-role");
}
@Test
@ -70,7 +75,9 @@ class AuthorizationTest {
new PolicyRule.Builder().apiGroups("fake.halo.run").verbs("list").resources("posts")
.build()));
when(roleService.getRole(eq("post.read"))).thenReturn(role);
when(roleService.getMonoRole("post.read")).thenReturn(Mono.just(role));
when(roleService.getMonoRole("authenticated")).thenReturn(
Mono.error(ExtensionNotFoundException::new));
var token = LoginUtils.login(webClient, "user", "password").block();
webClient.get().uri("/apis/fake.halo.run/v1/posts")
@ -83,7 +90,8 @@ class AuthorizationTest {
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).exchange()
.expectStatus().isForbidden();
verify(roleService, times(2)).getRole("post.read");
verify(roleService, times(2)).getMonoRole("authenticated");
verify(roleService, times(2)).getMonoRole("post.read");
}
@TestConfiguration

View File

@ -1,6 +1,8 @@
package run.halo.app.theme.dialect;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME;
import java.io.FileNotFoundException;
@ -19,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
@ -29,6 +32,7 @@ import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.extras.java8time.expression.Temporals;
import reactor.core.publisher.Mono;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
@ -43,7 +47,8 @@ import run.halo.app.theme.ThemeResolver;
class ThemeJava8TimeDialectIntegrationTest {
private static final Instant INSTANT = Instant.now();
@Autowired
// @Autowired
@SpyBean
private ThemeResolver themeResolver;
private URL defaultThemeUrl;
@ -57,17 +62,15 @@ class ThemeJava8TimeDialectIntegrationTest {
@BeforeEach
void setUp() throws FileNotFoundException {
themeContextFunction = themeResolver.getThemeContextFunction();
themeResolver.setThemeContextFunction(request -> createDefaultContext());
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
when(themeResolver.getTheme(any(ServerHttpRequest.class))).thenReturn(
Mono.just(createDefaultContext()));
defaultTimeZone = TimeZone.getDefault();
}
@AfterEach
void tearDown() {
TimeZone.setDefault(defaultTimeZone);
this.themeResolver.setThemeContextFunction(themeContextFunction);
}
@Test

View File

@ -3,14 +3,14 @@ package run.halo.app.theme.message;
import java.io.FileNotFoundException;
import java.net.URL;
import java.nio.file.Paths;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
@ -20,6 +20,7 @@ import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
@ -33,34 +34,27 @@ import run.halo.app.theme.ThemeResolver;
@AutoConfigureWebTestClient
public class ThemeMessageResolverIntegrationTest {
@Autowired
@SpyBean
private ThemeResolver themeResolver;
private URL defaultThemeUrl;
private URL otherThemeUrl;
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
@Autowired
private WebTestClient webTestClient;
@BeforeEach
void setUp() throws FileNotFoundException {
themeContextFunction = themeResolver.getThemeContextFunction();
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");
}
@AfterEach
void tearDown() {
this.themeResolver.setThemeContextFunction(themeContextFunction);
Mockito.when(themeResolver.getTheme(Mockito.any(ServerHttpRequest.class)))
.thenReturn(Mono.just(createDefaultContext()));
}
@Test
void messageResolverWhenDefaultTheme() {
themeResolver.setThemeContextFunction(request -> createDefaultContext());
webTestClient.get()
.uri("/?language=zh")
.exchange()
@ -85,7 +79,6 @@ public class ThemeMessageResolverIntegrationTest {
@Test
void messageResolverForEnLanguageWhenDefaultTheme() {
themeResolver.setThemeContextFunction(request -> createDefaultContext());
webTestClient.get()
.uri("/?language=en")
.exchange()
@ -110,7 +103,6 @@ public class ThemeMessageResolverIntegrationTest {
@Test
void shouldUseDefaultWhenLanguageNotSupport() {
themeResolver.setThemeContextFunction(request -> createDefaultContext());
webTestClient.get()
.uri("/index?language=foo")
.exchange()
@ -135,7 +127,6 @@ public class ThemeMessageResolverIntegrationTest {
@Test
void switchTheme() {
themeResolver.setThemeContextFunction(request -> createDefaultContext());
webTestClient.get()
.uri("/index?language=zh")
.exchange()
@ -158,7 +149,8 @@ public class ThemeMessageResolverIntegrationTest {
""");
// For other theme
themeResolver.setThemeContextFunction(request -> createOtherContext());
Mockito.when(themeResolver.getTheme(Mockito.any(ServerHttpRequest.class)))
.thenReturn(Mono.just(createOtherContext()));
webTestClient.get()
.uri("/index?language=zh")
.exchange()

View File

@ -4,18 +4,13 @@ spring:
output:
ansi:
enabled: detect
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:halo
username: admin
password: 123456
jpa:
hibernate:
ddl-auto: update
open-in-view: false
show-sql: true
r2dbc:
name: halo-test
generate-unique-name: true
sql:
init:
mode: always
platform: h2
halo:
work-dir: ${user.home}/halo-next-test
@ -36,3 +31,4 @@ springdoc:
logging:
level:
run.halo.app: debug
org.springframework.r2dbc: DEBUG