mirror of https://github.com/halo-dev/halo
feat: add role information for user-related custom endpoint (#3372)
#### What type of PR is this? /kind feature /kind api-change /area core /milestone 2.3.x #### What this PR does / why we need it: 获取用户信息的 API 响应体包含关联角色信息 - 新增 API `/apis/api.console.halo.run/v1alpha1/users/{name}` - 修改了 API 的返回值类型 `/apis/api.console.halo.run/v1alpha1/users/-` 由于 API 响应体结构的改变,需要 Console 适配 #### Which issue(s) this PR fixes: Fixes #3342 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 获取用户信息的 API 响应体包含关联角色信息 ```pull/3363/head
parent
9fff768134
commit
c8f3229cd6
|
@ -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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<DetailedUser> toDetailedUser(User user) {
|
||||
Set<String> roleNames = roleNames(user);
|
||||
return roleService.list(roleNames)
|
||||
.collectList()
|
||||
.map(roles -> new DetailedUser(user, roles))
|
||||
.defaultIfEmpty(new DetailedUser(user, List.of()));
|
||||
}
|
||||
|
||||
Set<String> roleNames(User user) {
|
||||
Assert.notNull(user, "User must not be null");
|
||||
Map<String, String> 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<Role> roles) {
|
||||
|
||||
}
|
||||
|
||||
@NonNull
|
||||
Mono<ServerResponse> 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<Role> roles) {
|
||||
}
|
||||
|
||||
Mono<ServerResponse> 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<ListResult<ListedUser>> toListedUser(ListResult<User> listResult) {
|
||||
return Flux.fromStream(listResult.get())
|
||||
.flatMap(user -> {
|
||||
Set<String> 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()));
|
||||
}
|
||||
|
||||
<T> ListResult<T> convertFrom(ListResult<?> listResult, List<T> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Role> list(Set<String> roleNames) {
|
||||
return Flux.fromIterable(ObjectUtils.defaultIfNull(roleNames, Set.of()))
|
||||
.flatMap(roleName -> extensionClient.fetch(Role.class, roleName));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private List<String> stringToList(String str) {
|
||||
if (StringUtils.isBlank(str)) {
|
||||
|
|
|
@ -24,4 +24,6 @@ public interface RoleService {
|
|||
Flux<RoleRef> listRoleRefs(Subject subject);
|
||||
|
||||
List<Role> listDependencies(Set<String> names);
|
||||
|
||||
Flux<Role> list(Set<String> roleNames);
|
||||
}
|
||||
|
|
|
@ -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<User> getUser(String username) {
|
||||
return client.get(User.class, username);
|
||||
return client.get(User.class, username)
|
||||
.onErrorMap(ExtensionNotFoundException.class,
|
||||
e -> new UserNotFoundException(username));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<String, String> 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")));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
Loading…
Reference in New Issue