diff --git a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index 880e99374..543bdbaec 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -1,5 +1,6 @@ package run.halo.app.core.extension.endpoint; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static java.util.Comparator.comparing; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; @@ -9,7 +10,6 @@ import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersF import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; import com.fasterxml.jackson.core.type.TypeReference; -import io.micrometer.common.util.StringUtils; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -24,6 +24,8 @@ import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; @@ -32,6 +34,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; @@ -41,25 +44,23 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; 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.Comparators; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.router.IListRequest; -import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.infra.utils.JsonUtils; @Component +@RequiredArgsConstructor public class UserEndpoint implements CustomEndpoint { private static final String SELF_USER = "-"; private final ReactiveExtensionClient client; private final UserService userService; - - public UserEndpoint(ReactiveExtensionClient client, UserService userService) { - this.client = client; - this.userService = userService; - } + private final RoleService roleService; @Override public RouterFunction endpoint() { @@ -68,7 +69,18 @@ public class UserEndpoint implements CustomEndpoint { .GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail") .description("Get current user detail") .tag(tag) - .response(responseBuilder().implementation(User.class))) + .response(responseBuilder().implementation(DetailedUser.class))) + .GET("/users/{name}", this::getUserByName, + builder -> builder.operationId("GetUserDetail") + .description("Get user detail by name") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("User name") + .required(true) + ) + .response(responseBuilder().implementation(DetailedUser.class))) .PUT("/users/-", this::updateProfile, builder -> builder.operationId("UpdateCurrentUser") .description("Update current user profile, but password.") @@ -113,12 +125,22 @@ public class UserEndpoint implements CustomEndpoint { builder.operationId("ListUsers") .tag(tag) .description("List users") - .response(responseBuilder().implementation(generateGenericClass(User.class))); + .response(responseBuilder() + .implementation(generateGenericClass(ListedUser.class))); buildParametersFromType(builder, ListRequest.class); }) .build(); } + private Mono getUserByName(ServerRequest request) { + final var name = request.pathVariable("name"); + return userService.getUser(name) + .flatMap(this::toDetailedUser) + .flatMap(user -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(user)); + } + private Mono updateProfile(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) @@ -173,15 +195,38 @@ public class UserEndpoint implements CustomEndpoint { return ReactiveSecurityContextHolder.getContext() .flatMap(ctx -> { var name = ctx.getAuthentication().getName(); - return client.get(User.class, name) - .onErrorMap(ExtensionNotFoundException.class, - e -> new UserNotFoundException(name)); + return userService.getUser(name); }) + .flatMap(this::toDetailedUser) .flatMap(user -> ServerResponse.ok() .contentType(MediaType.APPLICATION_JSON) .bodyValue(user)); } + private Mono toDetailedUser(User user) { + Set roleNames = roleNames(user); + return roleService.list(roleNames) + .collectList() + .map(roles -> new DetailedUser(user, roles)) + .defaultIfEmpty(new DetailedUser(user, List.of())); + } + + Set roleNames(User user) { + Assert.notNull(user, "User must not be null"); + Map annotations = ExtensionUtil.nullSafeAnnotations(user); + String roleNamesJson = annotations.get(User.ROLE_NAMES_ANNO); + if (StringUtils.isBlank(roleNamesJson)) { + return Set.of(); + } + return JsonUtils.jsonToObject(roleNamesJson, new TypeReference<>() { + }); + } + + record DetailedUser(@Schema(requiredMode = REQUIRED) User user, + @Schema(requiredMode = REQUIRED) List roles) { + + } + @NonNull Mono grantPermission(ServerRequest request) { var username = request.pathVariable("name"); @@ -332,6 +377,10 @@ public class UserEndpoint implements CustomEndpoint { } } + record ListedUser(@Schema(requiredMode = REQUIRED) User user, + @Schema(requiredMode = REQUIRED) List roles) { + } + Mono list(ServerRequest request) { return Mono.just(request) .map(UserEndpoint.ListRequest::new) @@ -344,6 +393,28 @@ public class UserEndpoint implements CustomEndpoint { listRequest.getPage(), listRequest.getSize()); }) + .flatMap(this::toListedUser) .flatMap(listResult -> ServerResponse.ok().bodyValue(listResult)); } + + private Mono> toListedUser(ListResult listResult) { + return Flux.fromStream(listResult.get()) + .flatMap(user -> { + Set roleNames = roleNames(user); + return roleService.list(roleNames) + .collectList() + .map(roles -> new ListedUser(user, roles)) + .defaultIfEmpty(new ListedUser(user, List.of())); + }) + .collectList() + .map(items -> convertFrom(listResult, items)) + .defaultIfEmpty(convertFrom(listResult, List.of())); + } + + ListResult convertFrom(ListResult listResult, List items) { + Assert.notNull(listResult, "listResult must not be null"); + Assert.notNull(items, "items must not be null"); + return new ListResult<>(listResult.getPage(), listResult.getSize(), + listResult.getTotal(), items); + } } diff --git a/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java index 3060b673f..1e7453009 100644 --- a/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; @@ -88,6 +89,12 @@ public class DefaultRoleService implements RoleService { return result; } + @Override + public Flux list(Set roleNames) { + return Flux.fromIterable(ObjectUtils.defaultIfNull(roleNames, Set.of())) + .flatMap(roleName -> extensionClient.fetch(Role.class, roleName)); + } + @NonNull private List stringToList(String str) { if (StringUtils.isBlank(str)) { diff --git a/src/main/java/run/halo/app/core/extension/service/RoleService.java b/src/main/java/run/halo/app/core/extension/service/RoleService.java index 6a28bb10f..52d0a8538 100644 --- a/src/main/java/run/halo/app/core/extension/service/RoleService.java +++ b/src/main/java/run/halo/app/core/extension/service/RoleService.java @@ -24,4 +24,6 @@ public interface RoleService { Flux listRoleRefs(Subject subject); List listDependencies(Set names); + + Flux list(Set roleNames); } diff --git a/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java index 6905ecc7c..6589808b0 100644 --- a/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java +++ b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java @@ -16,6 +16,8 @@ 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.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.exception.UserNotFoundException; @Service public class UserServiceImpl implements UserService { @@ -32,7 +34,9 @@ public class UserServiceImpl implements UserService { @Override public Mono getUser(String username) { - return client.get(User.class, username); + return client.get(User.class, username) + .onErrorMap(ExtensionNotFoundException.class, + e -> new UserNotFoundException(username)); } @Override diff --git a/src/main/resources/extensions/role-template-user.yaml b/src/main/resources/extensions/role-template-user.yaml index 57d40d8b0..8fdedff3f 100644 --- a/src/main/resources/extensions/role-template-user.yaml +++ b/src/main/resources/extensions/role-template-user.yaml @@ -31,6 +31,9 @@ rules: - apiGroups: [ "" ] resources: [ "users" ] verbs: [ "get", "list" ] + - apiGroups: [ "api.console.halo.run" ] + resources: [ "users" ] + verbs: [ "get", "list" ] --- apiVersion: v1alpha1 kind: "Role" diff --git a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 1f15530d5..5d2f2979d 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -2,6 +2,7 @@ package run.halo.app.core.extension.endpoint; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; @@ -16,6 +17,7 @@ import static run.halo.app.extension.GroupVersionKind.fromExtension; import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -26,7 +28,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; @@ -50,7 +51,7 @@ class UserEndpointTest { WebTestClient webClient; - @MockBean + @Mock RoleService roleService; @Mock @@ -104,6 +105,7 @@ class UserEndpointTest { createUser("fake-user-3") ); var expectResult = new ListResult<>(users); + when(roleService.list(anySet())).thenReturn(Flux.empty()); when(client.list(same(User.class), any(), any(), anyInt(), anyInt())) .thenReturn(Mono.just(expectResult)); @@ -131,6 +133,7 @@ class UserEndpointTest { var expectResult = new ListResult<>(users); when(client.list(same(User.class), any(), any(), anyInt(), anyInt())) .thenReturn(Mono.just(expectResult)); + when(roleService.list(anySet())).thenReturn(Flux.empty()); bindToRouterFunction(endpoint.endpoint()) .build() @@ -190,6 +193,7 @@ class UserEndpointTest { var expectResult = new ListResult<>(users); when(client.list(same(User.class), any(), any(), anyInt(), anyInt())) .thenReturn(Mono.just(expectResult)); + when(roleService.list(anySet())).thenReturn(Flux.empty()); bindToRouterFunction(endpoint.endpoint()) .build() @@ -215,6 +219,7 @@ class UserEndpointTest { var expectResult = new ListResult<>(List.of(expectUser)); when(client.list(same(User.class), any(), any(), anyInt(), anyInt())) .thenReturn(Mono.just(expectResult)); + when(roleService.list(anySet())).thenReturn(Flux.empty()); bindToRouterFunction(endpoint.endpoint()) .build() @@ -272,14 +277,14 @@ class UserEndpointTest { @Test void shouldResponseErrorIfUserNotFound() { - when(client.get(User.class, "fake-user")) + when(userService.getUser("fake-user")) .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); webClient.get().uri("/users/-") .exchange() .expectStatus().isNotFound(); - verify(client).get(User.class, "fake-user"); + verify(userService).getUser(eq("fake-user")); } @Test @@ -288,13 +293,22 @@ class UserEndpointTest { metadata.setName("fake-user"); var user = new User(); user.setMetadata(metadata); - when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user)); + Map annotations = + Map.of(User.ROLE_NAMES_ANNO, JsonUtils.objectToJson(Set.of("role-A"))); + user.getMetadata().setAnnotations(annotations); + when(userService.getUser("fake-user")).thenReturn(Mono.just(user)); + Role role = new Role(); + role.setMetadata(new Metadata()); + role.getMetadata().setName("role-A"); + role.setRules(List.of()); + when(roleService.list(anySet())).thenReturn(Flux.just(role)); webClient.get().uri("/users/-") .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) - .expectBody(User.class) - .isEqualTo(user); + .expectBody(UserEndpoint.DetailedUser.class) + .isEqualTo(new UserEndpoint.DetailedUser(user, List.of(role))); + verify(roleService).list(eq(Set.of("role-A"))); } } diff --git a/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java index a07c1cef5..e143a6bbb 100644 --- a/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java +++ b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java @@ -35,6 +35,7 @@ import run.halo.app.core.extension.User; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.infra.utils.JsonUtils; @ExtendWith(MockitoExtension.class) @@ -51,11 +52,10 @@ class UserServiceImplTest { @Test void shouldThrowExceptionIfUserNotFoundInExtension() { - when(client.get(User.class, "faker")).thenReturn( + when(client.get(eq(User.class), eq("faker"))).thenReturn( Mono.error(new ExtensionNotFoundException(fromExtension(User.class), "faker"))); - StepVerifier.create(userService.getUser("faker")) - .verifyError(ExtensionNotFoundException.class); + .verifyError(UserNotFoundException.class); verify(client, times(1)).get(eq(User.class), eq("faker")); } @@ -275,12 +275,12 @@ class UserServiceImplTest { @Test void shouldThrowExceptionIfUserNotFound() { - when(client.get(User.class, "fake-user")) + when(client.get(eq(User.class), eq("fake-user"))) .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) - .verifyError(ExtensionNotFoundException.class); + .verifyError(UserNotFoundException.class); verify(passwordEncoder, never()).matches(anyString(), anyString()); verify(passwordEncoder, never()).encode(anyString());