Skip to content

Commit

Permalink
feat: add role information for user-related custom endpoint (#3372)
Browse files Browse the repository at this point in the history
#### 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 响应体包含关联角色信息
```
  • Loading branch information
guqing authored Feb 24, 2023
1 parent 9fff768 commit c8f3229
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,7 +10,6 @@
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;
Expand All @@ -24,6 +24,8 @@
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;
Expand All @@ -32,6 +34,7 @@
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;
Expand All @@ -41,25 +44,23 @@
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() {
Expand All @@ -68,7 +69,18 @@ public RouterFunction<ServerResponse> endpoint() {
.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.")
Expand Down Expand Up @@ -113,12 +125,22 @@ public RouterFunction<ServerResponse> endpoint() {
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)
Expand Down Expand Up @@ -173,15 +195,38 @@ Mono<ServerResponse> me(ServerRequest request) {
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");
Expand Down Expand Up @@ -332,6 +377,10 @@ public Comparator<User> toComparator() {
}
}

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)
Expand All @@ -344,6 +393,28 @@ Mono<ServerResponse> list(ServerRequest request) {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -88,6 +89,12 @@ public List<Role> listDependencies(Set<String> names) {
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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public interface RoleService {
Flux<RoleRef> listRoleRefs(Subject subject);

List<Role> listDependencies(Set<String> names);

Flux<Role> list(Set<String> roleNames);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
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 {
Expand All @@ -32,7 +34,9 @@ public UserServiceImpl(ReactiveExtensionClient client, PasswordEncoder passwordE

@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
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/extensions/role-template-user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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;
Expand All @@ -16,6 +17,7 @@
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;
Expand All @@ -26,7 +28,6 @@
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;
Expand All @@ -50,7 +51,7 @@ class UserEndpointTest {

WebTestClient webClient;

@MockBean
@Mock
RoleService roleService;

@Mock
Expand Down Expand Up @@ -104,6 +105,7 @@ void shouldListUsersWhenUserPresent() {
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));

Expand Down Expand Up @@ -131,6 +133,7 @@ void shouldFilterUsersWhenKeywordProvided() {
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()
Expand Down Expand Up @@ -190,6 +193,7 @@ void shouldFilterUsersWhenRoleProvided() {
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()
Expand All @@ -215,6 +219,7 @@ void shouldSortUsersWhenCreationTimestampSet() {
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()
Expand Down Expand Up @@ -272,14 +277,14 @@ class GetUserDetailTest {

@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
Expand All @@ -288,13 +293,22 @@ void shouldGetCurrentUserDetail() {
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")));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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)
Expand All @@ -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"));
}
Expand Down Expand Up @@ -275,12 +275,12 @@ void shouldDoNothingIfPasswordNotChanged() {

@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());
Expand Down

0 comments on commit c8f3229

Please sign in to comment.