Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add role information for user-related custom endpoint #3372

Merged
merged 9 commits into from
Feb 24, 2023
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