From b0ab2c391e4259d9b90423b42b39a5e51f4f6008 Mon Sep 17 00:00:00 2001 From: Aurelien Poscia Date: Wed, 19 Jul 2023 11:38:55 +0200 Subject: [PATCH] SONAR-19963 Add GET /api/v2/users endpoint --- build.gradle | 5 +- server/sonar-webserver-common/build.gradle | 29 ++ .../common/user/service/UserServiceIT.java | 408 ++++++++++++++++++ .../sonar/server/common/SearchResults.java | 25 ++ .../server/common/avatar}/AvatarResolver.java | 2 +- .../common/avatar}/AvatarResolverImpl.java | 2 +- .../user/UsersSearchResponseGenerator.java | 30 ++ .../common/user/service/UserSearchResult.java | 27 ++ .../common/user/service/UserService.java | 125 ++++++ .../user/service/UsersSearchRequest.java | 163 +++++++ .../avatar}/AvatarResolverImplTest.java | 13 +- server/sonar-webserver-webapi-v2/build.gradle | 8 +- .../controller/DefaultUserControllerTest.java | 28 ++ .../v2/config/MockConfigForControllers.java | 6 + .../DefaultLivenessControllerIT.java | 6 +- .../v2/controller/HealthControllerIT.java | 8 +- .../org/sonar/server/v2/WebApiEndpoints.java | 1 + .../sonar/server/v2/api/model/RestError.java | 22 + .../sonar/server/v2/api/model/RestPage.java | 54 +++ .../server/v2/api/model/package-info.java | 23 + .../v2/api/response/PageRestResponse.java | 23 + .../server/v2/api/response/package-info.java | 23 + .../controller/DefaultUserController.java | 90 ++++ .../api/user/controller/UserController.java | 56 +++ .../v2/api/user/controller/package-info.java | 23 + .../UsersSearchRestResponseGenerator.java | 111 +++++ .../v2/api/user/converter/package-info.java | 23 + .../server/v2/api/user/model/RestUser.java | 51 +++ .../v2/api/user/model/package-info.java | 23 + .../user/request/UsersSearchRestRequest.java | 61 +++ .../v2/api/user/request/package-info.java | 23 + .../response/UsersSearchRestResponse.java | 27 ++ .../v2/api/user/response/package-info.java | 23 + .../RestResponseEntityExceptionHandler.java | 37 +- .../server/v2/config/CommonWebConfig.java | 15 +- .../v2/config/PlatformLevel4WebConfig.java | 15 + .../controller/DefautLivenessController.java | 5 +- .../v2/controller/LivenessController.java | 2 + .../server/v2/api/model/RestPageTest.java | 44 ++ .../UsersSearchRestResponseGeneratorTest.java | 205 +++++++++ server/sonar-webserver-webapi/build.gradle | 1 + .../sonar/server/hotspot/ws/ShowActionIT.java | 4 +- .../server/issue/IssueChangeWSSupportIT.java | 1 + .../server/issue/ws/ChangelogActionIT.java | 2 +- .../sonar/server/issue/ws/ListActionIT.java | 2 +- .../issue/ws/SearchActionComponentsIT.java | 2 +- .../server/issue/ws/SearchActionFacetsIT.java | 2 +- .../sonar/server/issue/ws/SearchActionIT.java | 2 +- .../server/permission/ws/UsersActionIT.java | 2 +- .../ws/template/TemplateUsersActionIT.java | 2 +- .../qualitygate/ws/SearchUsersActionIT.java | 4 +- .../ws/SearchUsersActionIT.java | 4 +- .../user/ws/CurrentActionHomepageIT.java | 2 +- .../sonar/server/user/ws/CurrentActionIT.java | 2 +- .../sonar/server/user/ws/SearchActionIT.java | 13 +- .../server/issue/IssueChangeWSSupport.java | 1 + .../sonar/server/issue/ws/IssueWsModule.java | 2 +- .../issue/ws/UserResponseFormatter.java | 2 +- .../server/permission/ws/UsersAction.java | 2 +- .../ws/template/TemplateUsersAction.java | 2 +- .../qualitygate/ws/SearchUsersAction.java | 2 +- .../qualityprofile/ws/SearchUsersAction.java | 2 +- .../sonar/server/user/ws/CurrentAction.java | 2 +- .../sonar/server/user/ws/SearchAction.java | 294 ++----------- .../user/ws/SearchWsReponseGenerator.java | 83 ++++ .../sonar/server/user/ws/UsersWsModule.java | 3 + .../server/issue/FakeAvatarResolver.java | 1 + settings.gradle | 1 + 68 files changed, 1980 insertions(+), 327 deletions(-) create mode 100644 server/sonar-webserver-common/build.gradle create mode 100644 server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java create mode 100644 server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java rename server/{sonar-webserver-webapi/src/main/java/org/sonar/server/issue => sonar-webserver-common/src/main/java/org/sonar/server/common/avatar}/AvatarResolver.java (96%) rename server/{sonar-webserver-webapi/src/main/java/org/sonar/server/issue => sonar-webserver-common/src/main/java/org/sonar/server/common/avatar}/AvatarResolverImpl.java (97%) create mode 100644 server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java create mode 100644 server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java create mode 100644 server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java create mode 100644 server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java rename server/{sonar-webserver-webapi/src/test/java/org/sonar/server/issue => sonar-webserver-common/src/test/java/org/sonar/server/common/avatar}/AvatarResolverImplTest.java (76%) create mode 100644 server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java create mode 100644 server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java create mode 100644 server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java create mode 100644 server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java diff --git a/build.gradle b/build.gradle index ad7c22cfd4d..9fb58f044ee 100644 --- a/build.gradle +++ b/build.gradle @@ -409,9 +409,12 @@ subprojects { dependency ("org.springframework:spring-webmvc:${springVersion}") { exclude 'commons-logging:commons-logging' } - dependency 'org.springdoc:springdoc-openapi-ui:1.7.0' + dependency 'org.springdoc:springdoc-openapi-webmvc-core:1.7.0' dependency 'org.subethamail:subethasmtp:3.1.7' dependency 'org.yaml:snakeyaml:2.0' + dependency 'org.hibernate:hibernate-validator:6.2.5.Final' + dependency 'javax.el:javax.el-api:3.0.0' + dependency 'org.glassfish:javax.el:3.0.0' // please keep this list alphabetically ordered } diff --git a/server/sonar-webserver-common/build.gradle b/server/sonar-webserver-common/build.gradle new file mode 100644 index 00000000000..21daee76c7a --- /dev/null +++ b/server/sonar-webserver-common/build.gradle @@ -0,0 +1,29 @@ +sonar { + properties { + property 'sonar.projectName', "${projectTitle} :: WebServer :: Common" + } +} + +dependencies { + // please keep the list grouped by configuration and ordered by name + api 'com.google.guava:guava' + + api project(':server:sonar-db-dao') + api project(':server:sonar-webserver-ws') + + compileOnlyApi 'com.google.code.findbugs:jsr305' + compileOnlyApi 'javax.servlet:javax.servlet-api' + + testImplementation 'org.apache.logging.log4j:log4j-api' + testImplementation 'org.apache.logging.log4j:log4j-core' + testImplementation 'com.google.code.findbugs:jsr305' + testImplementation 'com.tngtech.java:junit-dataprovider' + + testImplementation 'junit:junit' + testImplementation 'org.assertj:assertj-core' + testImplementation 'org.mockito:mockito-core' + + testImplementation project(':sonar-testing-harness') + testImplementation testFixtures(project(':server:sonar-db-dao')) + +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java new file mode 100644 index 00000000000..769af7dcb7c --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java @@ -0,0 +1,408 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.user.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.IntStream; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.utils.DateUtils; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbTester; +import org.sonar.db.scim.ScimUserDao; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.avatar.AvatarResolverImpl; +import org.sonar.server.management.ManagedInstanceService; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class UserServiceIT { + + private static final UsersSearchRequest SEARCH_REQUEST = getBuilderWithDefaultsPageSize().build(); + @Rule + public DbTester db = DbTester.create(); + + private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class); + + private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService); + + @Test + public void search_for_all_active_users() { + UserDto user1 = db.users().insertUser(); + UserDto user2 = db.users().insertUser(); + UserDto user3 = db.users().insertUser(u -> u.setActive(false)); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), r -> r.userDto().getName()) + .containsExactlyInAnyOrder( + tuple(user1.getLogin(), user1.getName()), + tuple(user2.getLogin(), user2.getName())); + } + + @Test + public void search_deactivated_users() { + UserDto user1 = db.users().insertUser(u -> u.setActive(false)); + UserDto user2 = db.users().insertUser(u -> u.setActive(true)); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setDeactivated(true).build()); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), r -> r.userDto().getName()) + .containsExactlyInAnyOrder( + tuple(user1.getLogin(), user1.getName())); + } + + @Test + public void search_with_query() { + UserDto user = db.users().insertUser(u -> u + .setLogin("user-%_%-login") + .setName("user-name") + .setEmail("user@mail.com") + .setLocal(true) + .setScmAccounts(singletonList("user1"))); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user-%_%-").build()); + assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin) + .containsExactly(user.getLogin()); + + users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user@MAIL.com").build()); + assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin) + .containsExactly(user.getLogin()); + + users = userService.findUsers(getBuilderWithDefaultsPageSize().setQuery("user-name").build()); + assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin) + .containsExactly(user.getLogin()); + } + + @Test + public void return_avatar() { + UserDto user = db.users().insertUser(u -> u.setEmail("john@doe.com")); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::avatar) + .containsExactlyInAnyOrder( + tuple(user.getLogin(), Optional.of("6a6c19fea4a3676970167ce51f39e6ee"))); + + } + + @Test + public void return_isManagedFlag() { + UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com")); + UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com")); + mockUsersAsManaged(managedUser.getUuid()); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::managed) + .containsExactlyInAnyOrder( + tuple(managedUser.getLogin(), true), + tuple(nonManagedUser.getLogin(), false) + ); + + } + + @Test + public void search_whenFilteringByManagedAndInstanceManaged_returnsCorrectResults() { + UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com")); + UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com")); + db.users().enableScimForUser(managedUser); + mockUsersAsManaged(managedUser.getUuid()); + mockInstanceExternallyManagedAndFilterForManagedUsers(); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setManaged(true).build()); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::managed) + .containsExactlyInAnyOrder( + tuple(managedUser.getLogin(), true) + ); + + } + + @Test + public void search_whenFilteringByNonManagedAndInstanceManaged_returnsCorrectResults() { + UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com")); + UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com")); + db.users().enableScimForUser(managedUser); + mockUsersAsManaged(managedUser.getUuid()); + mockInstanceExternallyManagedAndFilterForManagedUsers(); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setManaged(false).build()); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::managed) + .containsExactlyInAnyOrder( + tuple(nonManagedUser.getLogin(), false) + ); + } + + private void mockInstanceExternallyManagedAndFilterForManagedUsers() { + when(managedInstanceService.isInstanceExternallyManaged()).thenReturn(true); + when(managedInstanceService.getManagedUsersSqlFilter(anyBoolean())) + .thenAnswer(invocation -> { + Boolean managed = invocation.getArgument(0, Boolean.class); + return new ScimUserDao(mock(UuidFactory.class)).getManagedUserSqlFilter(managed); + }); + } + + @Test + public void return_scm_accounts() { + UserDto user = db.users().insertUser(u -> u.setScmAccounts(asList("john1", "john2"))); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getSortedScmAccounts()) + .containsExactlyInAnyOrder(tuple(user.getLogin(), asList("john1", "john2"))); + } + + @Test + public void return_tokens_count_when_system_administer() { + UserDto user = db.users().insertUser(); + db.users().insertToken(user); + db.users().insertToken(user); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::tokensCount) + .containsExactlyInAnyOrder(tuple(user.getLogin(), 2)); + } + + @Test + public void return_user_not_having_email() { + UserDto user = db.users().insertUser(u -> u.setEmail(null)); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getEmail()) + .containsExactlyInAnyOrder(tuple(user.getLogin(), null)); + } + + @Test + public void return_groups() { + UserDto user = db.users().insertUser(); + GroupDto group1 = db.users().insertGroup("group1"); + GroupDto group2 = db.users().insertGroup("group2"); + GroupDto group3 = db.users().insertGroup("group3"); + db.users().insertMember(group1, user); + db.users().insertMember(group2, user); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), UserSearchResult::groups) + .containsExactlyInAnyOrder(tuple(user.getLogin(), asList(group1.getName(), group2.getName()))); + } + + @Test + public void return_external_information() { + UserDto user = db.users().insertUser(); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting( + r -> r.userDto().getLogin(), + userSearchResult -> userSearchResult.userDto().getExternalLogin(), + userSearchResult -> userSearchResult.userDto().getExternalIdentityProvider() + ) + .containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin(), user.getExternalIdentityProvider())); + } + + @Test + public void return_last_connection_date() { + UserDto userWithLastConnectionDate = db.users().insertUser(); + db.users().updateLastConnectionDate(userWithLastConnectionDate, 10_000_000_000L); + UserDto userWithoutLastConnectionDate = db.users().insertUser(); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getLastConnectionDate()) + .containsExactlyInAnyOrder( + tuple(userWithLastConnectionDate.getLogin(), 10_000_000_000L), + tuple(userWithoutLastConnectionDate.getLogin(), null)); + } + + @Test + public void return_all_fields_for_logged_user() { + UserDto user = db.users().insertUser(u -> u.setEmail("aa@bb.com")); + db.users().updateLastConnectionDate(user, 10_000_000_000L); + db.users().insertToken(user); + db.users().insertToken(user); + GroupDto group = db.users().insertGroup(); + db.users().insertMember(group, user); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()) + .extracting(UserSearchResult::userDto) + .extracting(UserDto::getLogin, UserDto::getName, UserDto::getEmail, UserDto::getExternalLogin, UserDto::getExternalIdentityProvider, + userDto -> !userDto.getSortedScmAccounts().isEmpty(), UserDto::getLastConnectionDate) + .containsExactlyInAnyOrder( + tuple(user.getLogin(), user.getName(), user.getEmail(), user.getExternalLogin(), user.getExternalIdentityProvider(), true, 10_000_000_000L)); + + assertThat(users.searchResults()) + .extracting(UserSearchResult::avatar, UserSearchResult::tokensCount, userSearchResult -> userSearchResult.groups().size()) + .containsExactly(tuple(Optional.of("5dcdf28d944831f2fb87d48b81500c66"), 2, 1)); + + } + + @Test + public void search_whenNoPagingInformationProvided_setsDefaultValues() { + IntStream.rangeClosed(0, 9).forEach(i -> db.users().insertUser(u -> u.setLogin("user-" + i).setName("User " + i))); + + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.total()).isEqualTo(10); + } + + @Test + public void search_with_paging() { + IntStream.rangeClosed(0, 9).forEach(i -> db.users().insertUser(u -> u.setLogin("user-" + i).setName("User " + i))); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(5).build()); + + assertThat(users.searchResults()) + .extracting(u -> u.userDto().getLogin()) + .containsExactly("user-0", "user-1", "user-2", "user-3", "user-4"); + assertThat(users.total()).isEqualTo(10); + + users = userService.findUsers(UsersSearchRequest.builder().setPage(2).setPageSize(5).build()); + + assertThat(users.searchResults()) + .extracting(u -> u.userDto().getLogin()) + .containsExactly("user-5", "user-6", "user-7", "user-8", "user-9"); + assertThat(users.total()).isEqualTo(10); + + } + + @Test + public void return_empty_result_when_no_user() { + SearchResults users = userService.findUsers(SEARCH_REQUEST); + + assertThat(users.searchResults()).isEmpty(); + assertThat(users.total()).isZero(); + } + + @Test + public void search_whenFilteringConnectionDate_shouldApplyFilter() { + final Instant lastConnection = Instant.now(); + UserDto user = db.users().insertUser(u -> u + .setLogin("user-%_%-login") + .setName("user-name") + .setEmail("user@mail.com") + .setLocal(true) + .setScmAccounts(singletonList("user1"))); + user = db.users().updateLastConnectionDate(user, lastConnection.toEpochMilli()); + user = db.users().updateSonarLintLastConnectionDate(user, lastConnection.toEpochMilli()); + + SearchResults users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user-%_%-").build()); + + assertThat(users.searchResults()) + .extracting(r -> r.userDto().getLogin()) + .containsExactlyInAnyOrder(user.getLogin()); + + assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true); + assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true); + + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true); + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true); + + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true); + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true); + } + + @Test + public void search_whenNoLastConnection_shouldReturnForBeforeOnly() { + final Instant lastConnection = Instant.now(); + UserDto user = db.users().insertUser(u -> u + .setLogin("user-%_%-login") + .setName("user-name") + .setEmail("user@mail.com") + .setLocal(true) + .setScmAccounts(singletonList("user1"))); + + assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true); + + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), false); + assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true); + + } + + private void assertUserWithFilter(Function query, String userLogin, boolean isExpectedToBeThere) { + + UsersSearchRequest.Builder builder = getBuilderWithDefaultsPageSize(); + builder = query.apply(builder); + + SearchResults users = userService.findUsers(builder.setQuery("user-%_%-").build()); + + var assertion = assertThat(users.searchResults()); + if (isExpectedToBeThere) { + assertion + .extracting(r -> r.userDto().getLogin()) + .containsExactlyInAnyOrder(userLogin); + } else { + assertion.isEmpty(); + } + } + + private void mockUsersAsManaged(String... userUuids) { + when(managedInstanceService.getUserUuidToManaged(any(), any())).thenAnswer(invocation -> + { + Set allUsersUuids = invocation.getArgument(1, Set.class); + return allUsersUuids.stream() + .map(userUuid -> (String) userUuid) + .collect(toMap(identity(), userUuid -> Set.of(userUuids).contains(userUuid))); + } + ); + } + + private static UsersSearchRequest.Builder getBuilderWithDefaultsPageSize() { + return UsersSearchRequest.builder().setPage(1).setPageSize(50); + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java new file mode 100644 index 00000000000..7d27bc1bede --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java @@ -0,0 +1,25 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common; + +import java.util.List; + +public record SearchResults(List searchResults, int total) { +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java similarity index 96% rename from server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java rename to server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java index 821fbb9cd8c..c238f17f20a 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.server.issue; +package org.sonar.server.common.avatar; import org.sonar.db.user.UserDto; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java similarity index 97% rename from server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java rename to server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java index 092efd2267a..505abfca8e8 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.server.issue; +package org.sonar.server.common.avatar; import com.google.common.hash.Hashing; import org.sonar.db.user.UserDto; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java new file mode 100644 index 00000000000..81025de5e19 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.user; + +import java.util.List; +import org.sonar.api.utils.Paging; +import org.sonar.server.common.user.service.UserSearchResult; + +public interface UsersSearchResponseGenerator { + + T toUsersForResponse(List userSearchResults, Paging paging); + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java new file mode 100644 index 00000000000..0b980d4b590 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.user.service; + +import java.util.Collection; +import java.util.Optional; +import org.sonar.db.user.UserDto; + +public record UserSearchResult(UserDto userDto, boolean managed, Optional avatar, Collection groups, int tokensCount) { +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java new file mode 100644 index 00000000000..9c5bc4cbe4d --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java @@ -0,0 +1,125 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.user.service; + +import com.google.common.collect.Multimap; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserQuery; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.avatar.AvatarResolver; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.management.ManagedInstanceService; + +import static java.util.Comparator.comparing; + +public class UserService { + + private final DbClient dbClient; + private final AvatarResolver avatarResolver; + private final ManagedInstanceService managedInstanceService; + + public UserService(DbClient dbClient, AvatarResolver avatarResolver, ManagedInstanceService managedInstanceService) { + this.dbClient = dbClient; + this.avatarResolver = avatarResolver; + this.managedInstanceService = managedInstanceService; + } + + public SearchResults findUsers(UsersSearchRequest request) { + UserQuery userQuery = buildUserQuery(request); + try (DbSession dbSession = dbClient.openSession(false)) { + int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery); + + List searchResults = performSearch(dbSession, userQuery, request.getPage(), request.getPageSize()); + return new SearchResults<>(searchResults, totalUsers); + } + } + + private UserQuery buildUserQuery(UsersSearchRequest request) { + UserQuery.UserQueryBuilder builder = UserQuery.builder(); + request.getLastConnectionDateFrom().ifPresent(builder::lastConnectionDateFrom); + request.getLastConnectionDateTo().ifPresent(builder::lastConnectionDateTo); + request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom); + request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo); + + if (managedInstanceService.isInstanceExternallyManaged()) { + String managedInstanceSql = Optional.ofNullable(request.isManaged()) + .map(managedInstanceService::getManagedUsersSqlFilter) + .orElse(null); + builder.isManagedClause(managedInstanceSql); + } else if (request.isManaged() != null) { + throw BadRequestException.create("The 'managed' parameter is only available for managed instances."); + } + + return builder + .isActive(!request.isDeactivated()) + .searchText(request.getQuery()) + .build(); + } + + private List performSearch(DbSession dbSession, UserQuery userQuery, int pageIndex, int pageSize) { + List userDtos = findUsersAndSortByLogin(dbSession, userQuery, pageIndex, pageSize); + List logins = userDtos.stream().map(UserDto::getLogin).toList(); + Multimap groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins); + Map tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, userDtos); + Map userUuidToIsManaged = managedInstanceService.getUserUuidToManaged(dbSession, getUserUuids(userDtos)); + return userDtos.stream() + .map(userDto -> toUserSearchResult( + groupsByLogin.get(userDto.getLogin()), + tokenCountsByLogin.getOrDefault(userDto.getUuid(), 0), + userUuidToIsManaged.getOrDefault(userDto.getUuid(), false), + userDto + ) + ).toList(); + } + + private UserSearchResult toUserSearchResult(Collection groups, int tokenCount, boolean managed, UserDto userDto) { + return new UserSearchResult( + userDto, + managed, + findAvatar(userDto), + groups, + tokenCount + ); + } + + private List findUsersAndSortByLogin(DbSession dbSession, UserQuery userQuery, int page, int pageSize) { + return dbClient.userDao().selectUsers(dbSession, userQuery, page, pageSize) + .stream() + .sorted(comparing(UserDto::getLogin)) + .toList(); + } + + private Optional findAvatar(UserDto userDto) { + return Optional.ofNullable(userDto.getEmail()).map(email -> avatarResolver.create(userDto)); + } + + private static Set getUserUuids(List users) { + return users.stream().map(UserDto::getUuid).collect(Collectors.toSet()); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java new file mode 100644 index 00000000000..90d27e56a61 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java @@ -0,0 +1,163 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.common.user.service; + +import java.time.OffsetDateTime; +import java.util.Optional; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.MessageException; +import org.sonar.server.exceptions.ServerException; + +public class UsersSearchRequest { + private final Integer page; + private final Integer pageSize; + private final String query; + private final boolean deactivated; + private final Boolean managed; + private final OffsetDateTime lastConnectionDateFrom; + private final OffsetDateTime lastConnectionDateTo; + private final OffsetDateTime sonarLintLastConnectionDateFrom; + private final OffsetDateTime sonarLintLastConnectionDateTo; + + private UsersSearchRequest(Builder builder) { + this.page = builder.page; + this.pageSize = builder.pageSize; + this.query = builder.query; + this.deactivated = builder.deactivated; + this.managed = builder.managed; + try { + this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null); + this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null); + this.sonarLintLastConnectionDateFrom = Optional.ofNullable(builder.sonarLintLastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null); + this.sonarLintLastConnectionDateTo = Optional.ofNullable(builder.sonarLintLastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null); + } catch (MessageException me) { + throw new ServerException(400, me.getMessage()); + } + } + + public Integer getPage() { + return page; + } + + public Integer getPageSize() { + return pageSize; + } + + @CheckForNull + public String getQuery() { + return query; + } + + public boolean isDeactivated() { + return deactivated; + } + + @CheckForNull + public Boolean isManaged() { + return managed; + } + + public Optional getLastConnectionDateFrom() { + return Optional.ofNullable(lastConnectionDateFrom); + } + + public Optional getLastConnectionDateTo() { + return Optional.ofNullable(lastConnectionDateTo); + } + + public Optional getSonarLintLastConnectionDateFrom() { + return Optional.ofNullable(sonarLintLastConnectionDateFrom); + } + + public Optional getSonarLintLastConnectionDateTo() { + return Optional.ofNullable(sonarLintLastConnectionDateTo); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Integer page; + private Integer pageSize; + private String query; + private boolean deactivated; + private Boolean managed; + private String lastConnectionDateFrom; + private String lastConnectionDateTo; + private String sonarLintLastConnectionDateFrom; + private String sonarLintLastConnectionDateTo; + + private Builder() { + // enforce factory method use + } + + public Builder setPage(Integer page) { + this.page = page; + return this; + } + + public Builder setPageSize(Integer pageSize) { + this.pageSize = pageSize; + return this; + } + + public Builder setQuery(@Nullable String query) { + this.query = query; + return this; + } + + public Builder setDeactivated(boolean deactivated) { + this.deactivated = deactivated; + return this; + } + + public Builder setManaged(@Nullable Boolean managed) { + this.managed = managed; + return this; + } + + public Builder setLastConnectionDateFrom(@Nullable String lastConnectionDateFrom) { + this.lastConnectionDateFrom = lastConnectionDateFrom; + return this; + } + + public Builder setLastConnectionDateTo(@Nullable String lastConnectionDateTo) { + this.lastConnectionDateTo = lastConnectionDateTo; + return this; + } + + public Builder setSonarLintLastConnectionDateFrom(@Nullable String sonarLintLastConnectionDateFrom) { + this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom; + return this; + } + + public Builder setSonarLintLastConnectionDateTo(@Nullable String sonarLintLastConnectionDateTo) { + this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo; + return this; + } + + public UsersSearchRequest build() { + return new UsersSearchRequest(this); + } + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java similarity index 76% rename from server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java rename to server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java index db33224399a..2a8341e07cc 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java @@ -17,29 +17,28 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -package org.sonar.server.issue; +package org.sonar.server.common.avatar; import org.junit.Test; +import org.sonar.db.user.UserTesting; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.sonar.db.user.UserTesting.newUserDto; public class AvatarResolverImplTest { - private AvatarResolverImpl underTest = new AvatarResolverImpl(); @Test public void create() { - String avatar = underTest.create(newUserDto("john", "John", "john@doo.com")); + String avatar = underTest.create(UserTesting.newUserDto("john", "John", "john@doo.com")); assertThat(avatar).isEqualTo("9297bfb538f650da6143b604e82a355d"); } @Test public void create_is_case_insensitive() { - assertThat(underTest.create(newUserDto("john", "John", "john@doo.com"))).isEqualTo(underTest.create(newUserDto("john", "John", "John@Doo.com"))); + assertThat(underTest.create(UserTesting.newUserDto("john", "John", "john@doo.com"))).isEqualTo(underTest.create(UserTesting.newUserDto("john", "John", "John@Doo.com"))); } @Test @@ -51,14 +50,14 @@ public class AvatarResolverImplTest { @Test public void fail_with_NP_when_email_is_null() { - assertThatThrownBy(() -> underTest.create(newUserDto("john", "John", null))) + assertThatThrownBy(() -> underTest.create(UserTesting.newUserDto("john", "John", null))) .isInstanceOf(NullPointerException.class) .hasMessage("Email cannot be null"); } @Test public void fail_when_email_is_empty() { - assertThatThrownBy(() -> underTest.create(newUserDto("john", "John", ""))) + assertThatThrownBy(() -> underTest.create(UserTesting.newUserDto("john", "John", ""))) .isInstanceOf(NullPointerException.class) .hasMessage("Email cannot be null"); } diff --git a/server/sonar-webserver-webapi-v2/build.gradle b/server/sonar-webserver-webapi-v2/build.gradle index c206e6616a2..42a33235113 100644 --- a/server/sonar-webserver-webapi-v2/build.gradle +++ b/server/sonar-webserver-webapi-v2/build.gradle @@ -6,11 +6,15 @@ sonarqube { dependencies { // please keep the list grouped by configuration and ordered by name - api 'org.springdoc:springdoc-openapi-ui' + api 'org.springdoc:springdoc-openapi-webmvc-core' api 'org.springframework:spring-webmvc' + api 'org.hibernate:hibernate-validator' + api 'javax.el:javax.el-api' + api 'org.glassfish:javax.el' api project(':server:sonar-db-dao') - // We are not suppose to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring. + api project(':server:sonar-webserver-common') + // We are not supposed to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring. api project(':server:sonar-webserver-webapi') testImplementation 'org.mockito:mockito-core' diff --git a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java new file mode 100644 index 00000000000..9073d3b25b8 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java @@ -0,0 +1,28 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DefaultUserControllerTest { + +} diff --git a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java index a5871772bf0..12bfee0eb87 100644 --- a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java +++ b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java @@ -20,6 +20,7 @@ package org.sonar.server.v2.config; import org.sonar.db.DbClient; +import org.sonar.server.common.user.service.UserService; import org.sonar.server.health.CeStatusNodeCheck; import org.sonar.server.health.DbConnectionNodeCheck; import org.sonar.server.health.EsStatusNodeCheck; @@ -98,4 +99,9 @@ public class MockConfigForControllers { ManagedInstanceChecker managedInstanceChecker() { return mock(ManagedInstanceChecker.class); } + + @Bean + UserService userService() { + return mock(UserService.class); + } } diff --git a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java index eb7eed3fe32..3e773445335 100644 --- a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java +++ b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java @@ -73,7 +73,7 @@ public class DefaultLivenessControllerIT extends ControllerIT { mockMvc.perform(get(LIVENESS_ENDPOINT)) .andExpectAll( status().isForbidden(), - content().string("Insufficient privileges")); + content().json("{\"message\":\"Insufficient privileges\"}")); } @Test @@ -84,7 +84,7 @@ public class DefaultLivenessControllerIT extends ControllerIT { mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, INVALID_PASSCODE)) .andExpectAll( status().isForbidden(), - content().string("Insufficient privileges")); + content().json("{\"message\":\"Insufficient privileges\"}")); } @Test @@ -95,6 +95,6 @@ public class DefaultLivenessControllerIT extends ControllerIT { mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, VALID_PASSCODE)) .andExpectAll( status().isInternalServerError(), - content().string("Liveness check failed")); + content().json("{\"message\":\"Liveness check failed\"}")); } } diff --git a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java index c216fd43c2f..9fb213f9090 100644 --- a/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java +++ b/server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java @@ -101,7 +101,7 @@ public class HealthControllerIT extends ControllerIT { mockMvc.perform(get(HEALTH_ENDPOINT)) .andExpectAll( status().isForbidden(), - content().string("Insufficient privileges")); + content().json("{\"message\":\"Insufficient privileges\"}")); } @Test @@ -112,7 +112,7 @@ public class HealthControllerIT extends ControllerIT { mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, INVALID_PASSCODE)) .andExpectAll( status().isForbidden(), - content().string("Insufficient privileges")); + content().json("{\"message\":\"Insufficient privileges\"}")); } @Test @@ -122,7 +122,7 @@ public class HealthControllerIT extends ControllerIT { mockMvc.perform(get(HEALTH_ENDPOINT)) .andExpectAll( status().isUnauthorized(), - content().string("UnauthorizedException")); + content().json("{\"message\":\"UnauthorizedException\"}")); } @Test @@ -133,6 +133,6 @@ public class HealthControllerIT extends ControllerIT { mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, VALID_PASSCODE)) .andExpectAll( status().is(HTTP_NOT_IMPLEMENTED), - content().string("Unsupported in cluster mode")); + content().json("{\"message\":\"Unsupported in cluster mode\"}")); } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java index 89e3c797a55..4870e06292f 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java @@ -25,6 +25,7 @@ public class WebApiEndpoints { public static final String LIVENESS_ENDPOINT = SYSTEM_ENDPOINTS + "/liveness"; public static final String HEALTH_ENDPOINT = SYSTEM_ENDPOINTS + "/health"; + public static final String USER_ENDPOINT = "/users"; private WebApiEndpoints() { } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java new file mode 100644 index 00000000000..534467a85cd --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java @@ -0,0 +1,22 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.model; + +public record RestError(String message) {} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java new file mode 100644 index 00000000000..ec374ec5f36 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.model; + +import com.google.common.annotations.VisibleForTesting; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.Positive; +import org.jetbrains.annotations.Nullable; + +public record RestPage( + @Min(1) + @Max(500) + @Parameter( + description = "Number of results per page", + schema = @Schema(defaultValue = DEFAULT_PAGE_SIZE, implementation = Integer.class)) + Integer pageSize, + @Positive + @Parameter( + description = "1-based page number", + schema = @Schema(defaultValue = DEFAULT_PAGE_INDEX, implementation = Integer.class)) + Integer pageIndex +) { + + @VisibleForTesting + static final String DEFAULT_PAGE_SIZE = "50"; + @VisibleForTesting + static final String DEFAULT_PAGE_INDEX = "1"; + + public RestPage(@Nullable Integer pageSize, @Nullable Integer pageIndex) { + this.pageSize = pageSize == null ? Integer.valueOf(DEFAULT_PAGE_SIZE) : pageSize; + this.pageIndex = pageIndex == null ? Integer.valueOf(DEFAULT_PAGE_INDEX) : pageIndex; + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java new file mode 100644 index 00000000000..381c1e0b60f --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.model; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java new file mode 100644 index 00000000000..13b5f050668 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.response; + +public record PageRestResponse(int pageIndex, int pageSize, int total) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java new file mode 100644 index 00000000000..42ee281a5fc --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java new file mode 100644 index 00000000000..c095a720cd1 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java @@ -0,0 +1,90 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.controller; + +import java.util.Optional; +import javax.annotation.Nullable; +import org.sonar.api.utils.Paging; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.common.user.service.UserService; +import org.sonar.server.common.user.service.UsersSearchRequest; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.model.RestPage; +import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator; +import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; +import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; + +import static org.sonar.api.utils.Paging.forPageIndex; + +public class DefaultUserController implements UserController { + private final UsersSearchRestResponseGenerator usersSearchResponseGenerator; + private final UserService userService; + private final UserSession userSession; + + public DefaultUserController(UserSession userSession, UserService userService, UsersSearchRestResponseGenerator usersSearchResponseGenerator) { + this.userSession = userSession; + this.usersSearchResponseGenerator = usersSearchResponseGenerator; + this.userService = userService; + } + + @Override + public UsersSearchRestResponse search(UsersSearchRestRequest usersSearchRestRequest, RestPage page) { + throwIfAdminOnlyParametersAreUsed(usersSearchRestRequest); + + SearchResults userSearchResults = userService.findUsers(toUserSearchRequest(usersSearchRestRequest, page)); + Paging paging = forPageIndex(page.pageIndex()).withPageSize(page.pageSize()).andTotal(userSearchResults.total()); + + return usersSearchResponseGenerator.toUsersForResponse(userSearchResults.searchResults(), paging); + } + + private void throwIfAdminOnlyParametersAreUsed(UsersSearchRestRequest usersSearchRestRequest) { + if (!userSession.isSystemAdministrator()) { + throwIfValuePresent("sonarLintLastConnectionDateFrom", usersSearchRestRequest.sonarLintLastConnectionDateFrom()); + throwIfValuePresent("sonarLintLastConnectionDateTo", usersSearchRestRequest.sonarLintLastConnectionDateTo()); + throwIfValuePresent("sonarQubeLastConnectionDateFrom", usersSearchRestRequest.sonarQubeLastConnectionDateFrom()); + throwIfValuePresent("sonarQubeLastConnectionDateTo", usersSearchRestRequest.sonarQubeLastConnectionDateTo()); + } + } + + private static void throwIfValuePresent(String parameter, @Nullable Object value) { + Optional.ofNullable(value).ifPresent(v -> throwForbiddenFor(parameter)); + } + + private static void throwForbiddenFor(String parameterName) { + throw new ForbiddenException("parameter " + parameterName + " requires Administer System permission."); + } + + private static UsersSearchRequest toUserSearchRequest(UsersSearchRestRequest usersSearchRestRequest, RestPage page) { + return UsersSearchRequest.builder() + .setDeactivated(Optional.ofNullable(usersSearchRestRequest.active()).map(active -> !active).orElse(false)) + .setManaged(usersSearchRestRequest.managed()) + .setQuery(usersSearchRestRequest.q()) + .setLastConnectionDateFrom(usersSearchRestRequest.sonarQubeLastConnectionDateFrom()) + .setLastConnectionDateTo(usersSearchRestRequest.sonarQubeLastConnectionDateTo()) + .setSonarLintLastConnectionDateFrom(usersSearchRestRequest.sonarLintLastConnectionDateFrom()) + .setSonarLintLastConnectionDateTo(usersSearchRestRequest.sonarLintLastConnectionDateTo()) + .setPage(page.pageIndex()) + .setPageSize(page.pageSize()) + .build(); + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java new file mode 100644 index 00000000000..03181b3438a --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.controller; + +import io.swagger.v3.oas.annotations.Operation; +import javax.validation.Valid; +import org.sonar.server.v2.api.model.RestPage; +import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; +import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import static org.sonar.server.v2.WebApiEndpoints.USER_ENDPOINT; + +@RequestMapping(USER_ENDPOINT) +@RestController +public interface UserController { + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Users search", description = """ + Get a list of users. By default, only active users are returned. + The following fields are only returned when user has Administer System permission or for logged-in in user : + 'email' + 'externalIdentity' + 'externalProvider' + 'groups' + 'lastConnectionDate' + 'sonarLintLastConnectionDate' + 'tokensCount' + Field 'sonarqubeLastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour. + """) + UsersSearchRestResponse search(@ParameterObject UsersSearchRestRequest usersSearchRestRequest, @Valid @ParameterObject RestPage restPage); +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java new file mode 100644 index 00000000000..2aa60ec12dc --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.user.controller; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java new file mode 100644 index 00000000000..6ad0fad3341 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java @@ -0,0 +1,111 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.converter; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.jetbrains.annotations.Nullable; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.Paging; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.user.UsersSearchResponseGenerator; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.response.PageRestResponse; +import org.sonar.server.v2.api.user.model.RestUser; +import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; + +public class UsersSearchRestResponseGenerator implements UsersSearchResponseGenerator { + + private final UserSession userSession; + + public UsersSearchRestResponseGenerator(UserSession userSession) { + this.userSession = userSession; + } + + @Override + public UsersSearchRestResponse toUsersForResponse(List userSearchResults, Paging paging) { + List usersForResponse = toUsersForResponse(userSearchResults); + PageRestResponse pageRestResponse = new PageRestResponse(paging.pageIndex(), paging.pageSize(), paging.total()); + return new UsersSearchRestResponse(usersForResponse, pageRestResponse); + } + + private List toUsersForResponse(List userSearchResults) { + return userSearchResults.stream() + .map(this::toUser) + .toList(); + } + + private RestUser toUser(UserSearchResult userSearchResult) { + UserDto userDto = userSearchResult.userDto(); + + String login = userDto.getLogin(); + String name = userDto.getName(); + String avatar = null; + Boolean active = null; + Boolean local = null; + String email = null; + String externalIdentityProvider = null; + String externalLogin = null; + Boolean managed = null; + String sqLastConnectionDate = null; + String slLastConnectionDate = null; + Integer groupSize = null; + Integer tokensCount = null; + + if (userSession.isLoggedIn()) { + avatar = userSearchResult.avatar().orElse(null); + active = userDto.isActive(); + local = userDto.isLocal(); + email = userDto.getEmail(); + externalIdentityProvider = userDto.getExternalIdentityProvider(); + } + if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), userDto.getUuid())) { + externalLogin = userDto.getExternalLogin(); + managed = userSearchResult.managed(); + sqLastConnectionDate = toDateTime(userDto.getLastConnectionDate()); + slLastConnectionDate = toDateTime(userDto.getLastSonarlintConnectionDate()); + groupSize = userSearchResult.groups().size(); + tokensCount = userSearchResult.tokensCount(); + } + + return new RestUser( + login, + login, + name, + email, + active, + local, + managed, + externalLogin, + externalIdentityProvider, + avatar, + sqLastConnectionDate, + slLastConnectionDate, + groupSize, + tokensCount + ); + } + + private static String toDateTime(@Nullable Long dateTimeMs) { + return Optional.ofNullable(dateTimeMs).map(DateUtils::formatDateTime).orElse(null); + } +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java new file mode 100644 index 00000000000..a44fcd944bf --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.user.converter; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java new file mode 100644 index 00000000000..f3f7d0f2408 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.model; + +import javax.annotation.Nullable; + +public record RestUser( + String id, + String login, + String name, + @Nullable + String email, + @Nullable + Boolean active, + @Nullable + Boolean local, + @Nullable + Boolean managed, + @Nullable + String externalLogin, + @Nullable + String externalProvider, + @Nullable + String avatar, + @Nullable + String sonarQubeLastConnectionDate, + @Nullable + String sonarLintLastConnectionDate, + @Nullable + Integer groupsCount, + @Nullable + Integer tokensCount +) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java new file mode 100644 index 00000000000..713126161c3 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.user.model; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java new file mode 100644 index 00000000000..318424c8a85 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.request; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import javax.annotation.Nullable; + +public record UsersSearchRestRequest( + @Parameter( + description = "Return active/inactive users", + schema = @Schema(defaultValue = "true", implementation = Boolean.class)) + Boolean active, + @Nullable + @Parameter(description = "Return managed or non-managed users. Only available for managed instances, throws for non-managed instances") + Boolean managed, + @Nullable + @Parameter(description = "Filter on login, name and email.\n" + + "This parameter can either perform an exact match, or a partial match (contains), it is case insensitive.") + String q, + @Nullable + @Parameter(description = "Filter the users based on the last connection date field. Only users who interacted with this instance at or after the date will be returned. " + + "The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)", + example = "2020-01-01T00:00:00+0100") + String sonarQubeLastConnectionDateFrom, + @Nullable + @Parameter(description = "Filter the users based on the last connection date field. Only users that never connected or who interacted with this instance at " + + "or before the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)", + example = "2020-01-01T00:00:00+0100") + String sonarQubeLastConnectionDateTo, + @Nullable + @Parameter(description = "Filter the users based on the sonar lint last connection date field Only users who interacted with this instance using SonarLint at or after " + + "the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)", + example = "2020-01-01T00:00:00+0100") + String sonarLintLastConnectionDateFrom, + @Nullable + @Parameter(description = "Filter the users based on the sonar lint last connection date field. Only users that never connected or who interacted with this instance " + + "using SonarLint at or before the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)", + example = "2020-01-01T00:00:00+0100") + String sonarLintLastConnectionDateTo + +) { + +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java new file mode 100644 index 00000000000..cdde9822316 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.user.request; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java new file mode 100644 index 00000000000..38e5a2dd897 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.response; + +import java.util.List; +import org.sonar.server.v2.api.response.PageRestResponse; +import org.sonar.server.v2.api.user.model.RestUser; + +public record UsersSearchRestResponse(List users, PageRestResponse pageRestResponse) { +} diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java new file mode 100644 index 00000000000..d6b945f1666 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.server.v2.api.user.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java index d62cc5c6996..baccbbbc764 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java @@ -20,11 +20,16 @@ package org.sonar.server.v2.common; import java.util.Optional; +import java.util.stream.Collectors; +import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.ServerException; import org.sonar.server.exceptions.UnauthorizedException; +import org.sonar.server.v2.api.model.RestError; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -34,25 +39,29 @@ public class RestResponseEntityExceptionHandler { @ExceptionHandler(IllegalStateException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - protected ResponseEntity handleIllegalStateException(IllegalStateException illegalStateException) { - return new ResponseEntity<>(illegalStateException.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + protected ResponseEntity handleIllegalStateException(IllegalStateException illegalStateException) { + return new ResponseEntity<>(new RestError(illegalStateException.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); } - @ExceptionHandler(ForbiddenException.class) - @ResponseStatus(HttpStatus.FORBIDDEN) - protected ResponseEntity handleForbiddenException(ForbiddenException forbiddenException) { - return handleServerException(forbiddenException); + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ResponseEntity handleBindException(BindException bindException) { + String validationErrors = bindException.getFieldErrors().stream() + .map(RestResponseEntityExceptionHandler::handleFieldError) + .collect(Collectors.joining()); + return new ResponseEntity<>(new RestError(validationErrors), HttpStatus.BAD_REQUEST); } - @ExceptionHandler(UnauthorizedException.class) - @ResponseStatus(HttpStatus.UNAUTHORIZED) - protected ResponseEntity handleUnauthorizedException(UnauthorizedException unauthorizedException) { - return handleServerException(unauthorizedException); + private static String handleFieldError(FieldError fieldError) { + String fieldName = fieldError.getField(); + String rejectedValueAsString = Optional.ofNullable(fieldError.getRejectedValue()).map(Object::toString).orElse("{}"); + String defaultMessage = fieldError.getDefaultMessage(); + return String.format("Value %s for field %s was rejected. Error: %s", rejectedValueAsString, fieldName, defaultMessage); } - - @ExceptionHandler(ServerException.class) - protected ResponseEntity handleServerException(ServerException serverException) { - return new ResponseEntity<>(serverException.getMessage(), Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR)); + @ExceptionHandler({ServerException.class, ForbiddenException.class, UnauthorizedException.class, BadRequestException.class}) + protected ResponseEntity handleServerException(ServerException serverException) { + return new ResponseEntity<>(new RestError(serverException.getMessage()), + Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR)); } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java index 34c77693a30..f8715068a36 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java @@ -22,10 +22,14 @@ package org.sonar.server.v2.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import org.sonar.server.v2.common.RestResponseEntityExceptionHandler; +import org.springdoc.core.SpringDocConfigProperties; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; +import org.springframework.http.MediaType; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @Configuration @@ -33,6 +37,10 @@ import org.springframework.web.servlet.config.annotation.EnableWebMvc; @ComponentScan(basePackages = {"org.springdoc"}) @PropertySource("classpath:springdoc.properties") public class CommonWebConfig { + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } @Bean public RestResponseEntityExceptionHandler restResponseEntityExceptionHandler() { @@ -45,9 +53,14 @@ public class CommonWebConfig { .info( new Info() .title("SonarQube Web API") - .version("0.0.1 alpha") + .version("1.0.0 beta") .description("Documentation of SonarQube Web API") ); } + @Bean + public BeanFactoryPostProcessor beanFactoryPostProcessor1(SpringDocConfigProperties springDocConfigProperties) { + return beanFactory -> springDocConfigProperties.setDefaultProducesMediaType(MediaType.APPLICATION_JSON_VALUE); + } + } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java index 60b82f70862..d6c133308df 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java @@ -20,6 +20,7 @@ package org.sonar.server.v2.config; import javax.annotation.Nullable; +import org.sonar.server.common.user.service.UserService; import org.sonar.server.health.CeStatusNodeCheck; import org.sonar.server.health.DbConnectionNodeCheck; import org.sonar.server.health.EsStatusNodeCheck; @@ -30,6 +31,9 @@ import org.sonar.server.platform.ws.LivenessChecker; import org.sonar.server.platform.ws.LivenessCheckerImpl; import org.sonar.server.user.SystemPasscode; import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.user.controller.DefaultUserController; +import org.sonar.server.v2.api.user.controller.UserController; +import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator; import org.sonar.server.v2.controller.DefautLivenessController; import org.sonar.server.v2.controller.HealthController; import org.sonar.server.v2.controller.LivenessController; @@ -57,4 +61,15 @@ public class PlatformLevel4WebConfig { UserSession userSession) { return new HealthController(healthChecker, systemPasscode, nodeInformation, userSession); } + + @Bean + public UsersSearchRestResponseGenerator usersSearchResponseGenerator(UserSession userSession) { + return new UsersSearchRestResponseGenerator(userSession); + } + + @Bean + public UserController userController(UserSession userSession, UsersSearchRestResponseGenerator usersSearchResponseGenerator, UserService userService) { + return new DefaultUserController(userSession, userService, usersSearchResponseGenerator); + } + } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java index 8641f7f8811..d609c9e1110 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java @@ -20,15 +20,16 @@ package org.sonar.server.v2.controller; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.platform.ws.LivenessChecker; import org.sonar.server.user.SystemPasscode; import org.sonar.server.user.UserSession; -import org.springframework.web.bind.annotation.RestController; -@RestController public class DefautLivenessController implements LivenessController { + private static final Logger LOGGER = LoggerFactory.getLogger(DefautLivenessController.class); private final LivenessChecker livenessChecker; private final UserSession userSession; private final SystemPasscode systemPasscode; diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java index 3b4daf00620..9433a6e1b48 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java @@ -28,10 +28,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; import static org.sonar.server.v2.WebApiEndpoints.LIVENESS_ENDPOINT; @RequestMapping(LIVENESS_ENDPOINT) +@RestController public interface LivenessController { @GetMapping diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java new file mode 100644 index 00000000000..533b7714a12 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java @@ -0,0 +1,44 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.model; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class RestPageTest { + + @Test + public void constructor_whenNoValueProvided_setsDefaultValues() { + RestPage restPage = new RestPage(null, null); + assertThat(restPage.pageIndex()).asString().isEqualTo(RestPage.DEFAULT_PAGE_INDEX); + assertThat(restPage.pageSize()).asString().isEqualTo(RestPage.DEFAULT_PAGE_SIZE); + } + + @Test + public void constructor_whenValuesProvided_useThem() { + RestPage restPage = new RestPage(10, 100); + assertThat(restPage.pageIndex()).isEqualTo(100); + assertThat(restPage.pageSize()).isEqualTo(10); + } + +} diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java new file mode 100644 index 00000000000..41bc8a916d3 --- /dev/null +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java @@ -0,0 +1,205 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.v2.api.user.converter; + +import java.util.List; +import java.util.Optional; +import org.jetbrains.annotations.Nullable; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.Paging; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.user.UserSession; +import org.sonar.server.v2.api.response.PageRestResponse; +import org.sonar.server.v2.api.user.model.RestUser; +import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class UsersSearchRestResponseGeneratorTest { + + @Mock + private UserSession userSession; + + @InjectMocks + private UsersSearchRestResponseGenerator usersSearchRestResponseGenerator; + + @Test + public void toUsersForResponse_whenNoResults_mapsCorrectly() { + Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3); + + UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(), paging); + + assertThat(usersForResponse.users()).isEmpty(); + assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse()); + } + + @Test + public void toUsersForResponse_whenAdmin_mapsAllFields() { + when(userSession.isLoggedIn()).thenReturn(true); + when(userSession.isSystemAdministrator()).thenReturn(true); + + Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3); + + UserSearchResult userSearchResult1 = mockSearchResult(1, true); + UserSearchResult userSearchResult2 = mockSearchResult(2, false); + + UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging); + + RestUser expectUser1 = buildExpectedResponseForAdmin(userSearchResult1); + RestUser expectUser2 = buildExpectedResponseForAdmin(userSearchResult2); + assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2); + assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse()); + } + + private static RestUser buildExpectedResponseForAdmin(UserSearchResult userSearchResult) { + UserDto userDto = userSearchResult.userDto(); + return new RestUser( + userDto.getLogin(), + userDto.getLogin(), + userDto.getName(), + userDto.getEmail(), + userDto.isActive(), + userDto.isLocal(), + userSearchResult.managed(), + userDto.getExternalLogin(), + userDto.getExternalIdentityProvider(), + userSearchResult.avatar().orElse(null), + toDateTime(userDto.getLastConnectionDate()), + toDateTime(userDto.getLastSonarlintConnectionDate()), + userSearchResult.groups().size(), + userSearchResult.tokensCount() + ); + } + + @Test + public void toUsersForResponse_whenNonAdmin_mapsNonAdminFields() { + when(userSession.isLoggedIn()).thenReturn(true); + + Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3); + + UserSearchResult userSearchResult1 = mockSearchResult(1, true); + UserSearchResult userSearchResult2 = mockSearchResult(2, false); + + UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging); + + RestUser expectUser1 = buildExpectedResponseForUser(userSearchResult1); + RestUser expectUser2 = buildExpectedResponseForUser(userSearchResult2); + assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2); + assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse()); + } + + private static RestUser buildExpectedResponseForUser(UserSearchResult userSearchResult) { + UserDto userDto = userSearchResult.userDto(); + return new RestUser( + userDto.getLogin(), + userDto.getLogin(), + userDto.getName(), + userDto.getEmail(), + userDto.isActive(), + userDto.isLocal(), + null, + null, + userDto.getExternalIdentityProvider(), + userSearchResult.avatar().orElse(null), + null, + null, + null, + null + ); + } + + @Test + public void toUsersForResponse_whenAnonymous_returnsOnlyNameAndLogin() { + Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3); + + UserSearchResult userSearchResult1 = mockSearchResult(1, true); + UserSearchResult userSearchResult2 = mockSearchResult(2, false); + + UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging); + + RestUser expectUser1 = buildExpectedResponseForAnonymous(userSearchResult1); + RestUser expectUser2 = buildExpectedResponseForAnonymous(userSearchResult2); + assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2); + assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse()); + } + + private static RestUser buildExpectedResponseForAnonymous(UserSearchResult userSearchResult) { + UserDto userDto = userSearchResult.userDto(); + return new RestUser( + userDto.getLogin(), + userDto.getLogin(), + userDto.getName(), + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + } + + private static String toDateTime(@Nullable Long dateTimeMs) { + return Optional.ofNullable(dateTimeMs).map(DateUtils::formatDateTime).orElse(null); + } + + private static UserSearchResult mockSearchResult(int i, boolean booleanFlagsValue) { + UserSearchResult userSearchResult = mock(UserSearchResult.class, RETURNS_DEEP_STUBS); + UserDto user1 = new UserDto() + .setUuid("uuid_" + i) + .setLogin("login_" + i) + .setName("name_" + i) + .setEmail("email@" + i) + .setExternalId("externalId" + i) + .setExternalLogin("externalLogin" + 1) + .setExternalIdentityProvider("exernalIdp_" + i) + .setLastConnectionDate(100L + i) + .setLastSonarlintConnectionDate(200L + i) + .setLocal(booleanFlagsValue) + .setActive(booleanFlagsValue); + + when(userSearchResult.userDto()).thenReturn(user1); + when(userSearchResult.managed()).thenReturn(booleanFlagsValue); + when(userSearchResult.tokensCount()).thenReturn(i); + when(userSearchResult.groups().size()).thenReturn(i * 100); + return userSearchResult; + } + + private static void assertPaginationInformationAreCorrect(Paging paging, PageRestResponse pageRestResponse) { + assertThat(pageRestResponse.pageIndex()).isEqualTo(paging.pageIndex()); + assertThat(pageRestResponse.pageSize()).isEqualTo(paging.pageSize()); + assertThat(pageRestResponse.total()).isEqualTo(paging.total()); + } + +} diff --git a/server/sonar-webserver-webapi/build.gradle b/server/sonar-webserver-webapi/build.gradle index 1b9a88044ab..c0962619871 100644 --- a/server/sonar-webserver-webapi/build.gradle +++ b/server/sonar-webserver-webapi/build.gradle @@ -18,6 +18,7 @@ dependencies { api project(':server:sonar-db-dao') api project(':server:sonar-process') api project(':server:sonar-webserver-auth') + api project(':server:sonar-webserver-common') api project(':server:sonar-webserver-es') api project(':server:sonar-webserver-ws') api project(':server:sonar-webserver-pushapi') diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java index bf7e5ee10c9..7e3f9b98f84 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java @@ -67,8 +67,8 @@ import org.sonar.db.user.UserTesting; import org.sonar.server.es.EsTester; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; -import org.sonar.server.issue.AvatarResolver; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueChangeWSSupport; import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext; import org.sonar.server.issue.IssueChangeWSSupport.Load; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java index 1213d30f0f2..ee605e68316 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java @@ -48,6 +48,7 @@ import org.sonar.db.issue.IssueDto; import org.sonar.db.issue.IssueTesting; import org.sonar.db.user.UserDto; import org.sonar.markdown.Markdown; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext; import org.sonar.server.issue.IssueChangeWSSupport.Load; import org.sonar.server.tester.UserSessionRule; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java index 17c9d0c4207..9b539ae0d35 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java @@ -36,7 +36,7 @@ import org.sonar.db.user.UserDto; import org.sonar.db.user.UserTesting; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueChangeWSSupport; import org.sonar.server.issue.IssueFinder; import org.sonar.server.tester.UserSessionRule; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java index 31e86637251..e9859cf6097 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java @@ -45,10 +45,10 @@ import org.sonar.db.metric.MetricDto; import org.sonar.db.protobuf.DbIssues; import org.sonar.db.rule.RuleDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.component.ComponentFinder; import org.sonar.server.component.TestComponentFinder; import org.sonar.server.exceptions.ForbiddenException; -import org.sonar.server.issue.AvatarResolverImpl; import org.sonar.server.issue.IssueFieldsSetter; import org.sonar.server.issue.NewCodePeriodResolver; import org.sonar.server.issue.TextRangeResponseFormatter; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java index 0354b324d4c..2c0b9e5ecb9 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java @@ -38,7 +38,7 @@ import org.sonar.db.issue.IssueDto; import org.sonar.db.project.ProjectDto; import org.sonar.db.rule.RuleDto; import org.sonar.server.es.EsTester; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueFieldsSetter; import org.sonar.server.issue.TextRangeResponseFormatter; import org.sonar.server.issue.TransitionService; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java index e7dc989e17b..8e59599e085 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java @@ -37,7 +37,7 @@ import org.sonar.db.component.ComponentDto; import org.sonar.db.rule.RuleDto; import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.TextRangeResponseFormatter; import org.sonar.server.issue.TransitionService; import org.sonar.server.issue.index.IssueIndex; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java index 17e7a9a1343..ad598866ccb 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java @@ -62,7 +62,7 @@ import org.sonar.db.rule.RuleTesting; import org.sonar.db.user.UserDto; import org.sonar.server.es.EsTester; import org.sonar.server.es.SearchOptions; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueFieldsSetter; import org.sonar.server.issue.TextRangeResponseFormatter; import org.sonar.server.issue.TransitionService; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java index d01a4ca298d..4b8645f3231 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java @@ -35,7 +35,7 @@ import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.exceptions.UnauthorizedException; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.permission.PermissionService; import org.sonar.server.permission.PermissionServiceImpl; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java index 7125bc4fcbe..46ea4eecc86 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java @@ -35,7 +35,7 @@ import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.exceptions.UnauthorizedException; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.permission.PermissionService; import org.sonar.server.permission.PermissionServiceImpl; import org.sonar.server.permission.RequestValidator; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java index b42f0f73398..acdacb8231c 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java @@ -28,8 +28,8 @@ import org.sonar.db.user.UserDto; import org.sonar.server.component.TestComponentFinder; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; -import org.sonar.server.issue.AvatarResolver; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.FakeAvatarResolver; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.ws.TestRequest; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java index 3b7a7820097..8b34bb0a94b 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java @@ -29,8 +29,8 @@ import org.sonar.db.qualityprofile.QProfileDto; import org.sonar.db.user.UserDto; import org.sonar.server.exceptions.ForbiddenException; import org.sonar.server.exceptions.NotFoundException; -import org.sonar.server.issue.AvatarResolver; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.FakeAvatarResolver; import org.sonar.server.language.LanguageTesting; import org.sonar.server.tester.UserSessionRule; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java index 261b65a08a5..a03588bc608 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java @@ -39,7 +39,7 @@ import org.sonar.db.DbTester; import org.sonar.db.component.ComponentDto; import org.sonar.db.component.ProjectData; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.permission.PermissionService; import org.sonar.server.permission.PermissionServiceImpl; import org.sonar.server.tester.UserSessionRule; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java index 0d1d89ef5b1..0bbf9174777 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java @@ -34,7 +34,7 @@ import org.sonar.db.DbTester; import org.sonar.db.component.ComponentDto; import org.sonar.db.property.PropertyDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.permission.PermissionService; import org.sonar.server.permission.PermissionServiceImpl; import org.sonar.server.tester.UserSessionRule; diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java index 4a4c315715d..f75975c1c66 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java @@ -36,9 +36,10 @@ import org.sonar.db.DbTester; import org.sonar.db.scim.ScimUserDao; import org.sonar.db.user.GroupDto; import org.sonar.db.user.UserDto; +import org.sonar.server.common.avatar.AvatarResolverImpl; +import org.sonar.server.common.user.service.UserService; import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ServerException; -import org.sonar.server.issue.AvatarResolverImpl; import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.ws.TestRequest; @@ -71,8 +72,13 @@ public class SearchActionIT { @Rule public DbTester db = DbTester.create(); - private ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class); - private WsActionTester ws = new WsActionTester(new SearchAction(userSession, db.getDbClient(), new AvatarResolverImpl(), managedInstanceService)); + private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class); + + private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService); + + private final SearchWsReponseGenerator searchWsReponseGenerator = new SearchWsReponseGenerator(userSession); + + private final WsActionTester ws = new WsActionTester(new SearchAction(userSession, userService, searchWsReponseGenerator)); @Test public void search_for_all_active_users() { @@ -529,7 +535,6 @@ public class SearchActionIT { assertUserWithFilter(SearchAction.SONAR_LINT_LAST_CONNECTION_DATE_TO, lastConnection, user.getLogin(), true); } - @Test public void search_whenNoLastConnection_shouldReturnForBeforeOnly() { userSession.logIn().setSystemAdministrator(); diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java index 3d98042ed15..5f780c51017 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java @@ -47,6 +47,7 @@ import org.sonar.db.issue.IssueChangeDto; import org.sonar.db.issue.IssueDto; import org.sonar.db.user.UserDto; import org.sonar.markdown.Markdown; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonar.server.user.UserSession; import org.sonarqube.ws.Common; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java index b6aeea29807..4351b1edeca 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java @@ -20,7 +20,7 @@ package org.sonar.server.issue.ws; import org.sonar.core.platform.Module; -import org.sonar.server.issue.AvatarResolverImpl; +import org.sonar.server.common.avatar.AvatarResolverImpl; import org.sonar.server.issue.IssueChangeWSSupport; import org.sonar.server.issue.IssueFieldsSetter; import org.sonar.server.issue.IssueFinder; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java index 704204eb05b..007d53020fa 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java @@ -20,7 +20,7 @@ package org.sonar.server.issue.ws; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonarqube.ws.Common; import static com.google.common.base.Strings.emptyToNull; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java index 23f1ae3bee4..236702a069e 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java @@ -38,7 +38,7 @@ import org.sonar.db.entity.EntityDto; import org.sonar.db.permission.PermissionQuery; import org.sonar.db.permission.UserPermissionDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.permission.RequestValidator; import org.sonar.server.user.UserSession; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java index 940d2bae275..e5cb0bd10eb 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java @@ -34,7 +34,7 @@ import org.sonar.db.permission.PermissionQuery; import org.sonar.db.permission.template.PermissionTemplateDto; import org.sonar.db.permission.template.PermissionTemplateUserDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonar.server.permission.RequestValidator; import org.sonar.server.permission.ws.PermissionWsSupport; import org.sonar.server.permission.ws.PermissionsWsAction; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java index 79605cf7c36..36dff950e72 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java @@ -33,7 +33,7 @@ import org.sonar.db.qualitygate.QualityGateDto; import org.sonar.db.user.SearchPermissionQuery; import org.sonar.db.user.SearchUserMembershipDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonarqube.ws.Common; import org.sonarqube.ws.Qualitygates.SearchUsersResponse; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java index d2525aef6cf..630f347221f 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java @@ -37,7 +37,7 @@ import org.sonar.db.qualityprofile.QProfileDto; import org.sonar.db.qualityprofile.SearchQualityProfilePermissionQuery; import org.sonar.db.user.SearchUserMembershipDto; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonarqube.ws.Common; import org.sonarqube.ws.Qualityprofiles.SearchUsersResponse; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java index 4362402baa0..dae5f958d05 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java @@ -36,7 +36,7 @@ import org.sonar.db.permission.GlobalPermission; import org.sonar.db.project.ProjectDto; import org.sonar.db.property.PropertyQuery; import org.sonar.db.user.UserDto; -import org.sonar.server.issue.AvatarResolver; +import org.sonar.server.common.avatar.AvatarResolver; import org.sonar.server.permission.PermissionService; import org.sonar.server.user.UserSession; import org.sonarqube.ws.Users.CurrentWsResponse; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java index 4a5e2adc909..8864fb41673 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java @@ -19,74 +19,47 @@ */ package org.sonar.server.user.ws; -import com.google.common.collect.Multimap; -import java.time.OffsetDateTime; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Objects; import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import javax.annotation.CheckForNull; -import javax.annotation.Nullable; import org.sonar.api.server.ws.Change; import org.sonar.api.server.ws.Request; import org.sonar.api.server.ws.Response; import org.sonar.api.server.ws.WebService; -import org.sonar.api.utils.DateUtils; -import org.sonar.api.utils.MessageException; import org.sonar.api.utils.Paging; -import org.sonar.db.DbClient; -import org.sonar.db.DbSession; -import org.sonar.db.user.UserDto; -import org.sonar.db.user.UserQuery; +import org.sonar.server.common.SearchResults; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.common.user.service.UserService; +import org.sonar.server.common.user.service.UsersSearchRequest; import org.sonar.server.es.SearchOptions; -import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.ServerException; -import org.sonar.server.issue.AvatarResolver; -import org.sonar.server.management.ManagedInstanceService; import org.sonar.server.user.UserSession; import org.sonarqube.ws.Users; -import org.sonarqube.ws.Users.SearchWsResponse; -import static com.google.common.base.MoreObjects.firstNonNull; import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Strings.emptyToNull; -import static java.lang.Boolean.TRUE; -import static java.util.Comparator.comparing; -import static java.util.Optional.ofNullable; import static org.sonar.api.server.ws.WebService.Param.PAGE; import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE; import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY; import static org.sonar.api.utils.Paging.forPageIndex; import static org.sonar.server.ws.WsUtils.writeProtobuf; -import static org.sonarqube.ws.Users.SearchWsResponse.Groups; -import static org.sonarqube.ws.Users.SearchWsResponse.ScmAccounts; -import static org.sonarqube.ws.Users.SearchWsResponse.User; -import static org.sonarqube.ws.Users.SearchWsResponse.newBuilder; public class SearchAction implements UsersWsAction { private static final String DEACTIVATED_PARAM = "deactivated"; private static final String MANAGED_PARAM = "managed"; - private static final int MAX_PAGE_SIZE = 500; static final String LAST_CONNECTION_DATE_FROM = "lastConnectedAfter"; static final String LAST_CONNECTION_DATE_TO = "lastConnectedBefore"; static final String SONAR_LINT_LAST_CONNECTION_DATE_FROM = "slLastConnectedAfter"; static final String SONAR_LINT_LAST_CONNECTION_DATE_TO = "slLastConnectedBefore"; private final UserSession userSession; - private final DbClient dbClient; - private final AvatarResolver avatarResolver; - private final ManagedInstanceService managedInstanceService; - public SearchAction(UserSession userSession, DbClient dbClient, AvatarResolver avatarResolver, - ManagedInstanceService managedInstanceService) { + private final UserService userService; + private final SearchWsReponseGenerator searchWsReponseGenerator; + + public SearchAction(UserSession userSession, + UserService userService, SearchWsReponseGenerator searchWsReponseGenerator) { this.userSession = userSession; - this.dbClient = dbClient; - this.avatarResolver = avatarResolver; - this.managedInstanceService = managedInstanceService; + this.userService = userService; + this.searchWsReponseGenerator = searchWsReponseGenerator; } @Override @@ -182,113 +155,31 @@ public class SearchAction implements UsersWsAction { @Override public void handle(Request request, Response response) throws Exception { + throwIfAdminOnlyParametersAreUsed(request); Users.SearchWsResponse wsResponse = doHandle(toSearchRequest(request)); writeProtobuf(wsResponse, request, response); } - private Users.SearchWsResponse doHandle(SearchRequest request) { - UserQuery userQuery = buildUserQuery(request); - try (DbSession dbSession = dbClient.openSession(false)) { - List users = findUsersAndSortByLogin(request, dbSession, userQuery); - int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery); - - List logins = users.stream().map(UserDto::getLogin).toList(); - Multimap groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins); - Map tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, users); - Map userUuidToIsManaged = managedInstanceService.getUserUuidToManaged(dbSession, getUserUuids(users)); - Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal(totalUsers); - return buildResponse(users, groupsByLogin, tokenCountsByLogin, userUuidToIsManaged, paging); - } - } - - private static Set getUserUuids(List users) { - return users.stream().map(UserDto::getUuid).collect(Collectors.toSet()); - } - - private UserQuery buildUserQuery(SearchRequest request) { - UserQuery.UserQueryBuilder builder = UserQuery.builder(); - if(!userSession.isSystemAdministrator()) { - request.getLastConnectionDateFrom().ifPresent(v -> throwForbiddenFor(LAST_CONNECTION_DATE_FROM)); - request.getLastConnectionDateTo().ifPresent(v -> throwForbiddenFor(LAST_CONNECTION_DATE_TO)); - request.getSonarLintLastConnectionDateFrom().ifPresent(v -> throwForbiddenFor(SONAR_LINT_LAST_CONNECTION_DATE_FROM)); - request.getSonarLintLastConnectionDateTo().ifPresent(v -> throwForbiddenFor(SONAR_LINT_LAST_CONNECTION_DATE_TO)); - } - request.getLastConnectionDateFrom().ifPresent(builder::lastConnectionDateFrom); - request.getLastConnectionDateTo().ifPresent(builder::lastConnectionDateTo); - request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom); - request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo); - - if (managedInstanceService.isInstanceExternallyManaged()) { - String managedInstanceSql = Optional.ofNullable(request.isManaged()) - .map(managedInstanceService::getManagedUsersSqlFilter) - .orElse(null); - builder.isManagedClause(managedInstanceSql); - } else if (request.isManaged() != null) { - throw BadRequestException.create("The 'managed' parameter is only available for managed instances."); + private void throwIfAdminOnlyParametersAreUsed(Request request) { + if (!userSession.isSystemAdministrator()) { + throwIfParameterValuePresent(request, LAST_CONNECTION_DATE_FROM); + throwIfParameterValuePresent(request, LAST_CONNECTION_DATE_TO); + throwIfParameterValuePresent(request, SONAR_LINT_LAST_CONNECTION_DATE_FROM); + throwIfParameterValuePresent(request, SONAR_LINT_LAST_CONNECTION_DATE_TO); } - - return builder - .isActive(!request.isDeactivated()) - .searchText(request.getQuery()) - .build(); } - private static void throwForbiddenFor(String parameterName) { - throw new ServerException(403, "parameter " + parameterName + " requires Administer System permission."); - } - - private List findUsersAndSortByLogin(SearchRequest request, DbSession dbSession, UserQuery userQuery) { - return dbClient.userDao().selectUsers(dbSession, userQuery, request.getPage(), request.getPageSize()) - .stream() - .sorted(comparing(UserDto::getLogin)) - .toList(); - } + private Users.SearchWsResponse doHandle(UsersSearchRequest request) { + SearchResults userSearchResults = userService.findUsers(request); + Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal(userSearchResults.total()); - private SearchWsResponse buildResponse(List users, Multimap groupsByLogin, Map tokenCountsByLogin, - Map userUuidToIsManaged, Paging paging) { - SearchWsResponse.Builder responseBuilder = newBuilder(); - users.forEach(user -> responseBuilder.addUsers( - towsUser(user, firstNonNull(tokenCountsByLogin.get(user.getUuid()), 0), groupsByLogin.get(user.getLogin()), userUuidToIsManaged.get(user.getUuid())) - )); - responseBuilder.getPagingBuilder() - .setPageIndex(paging.pageIndex()) - .setPageSize(paging.pageSize()) - .setTotal(paging.total()) - .build(); - return responseBuilder.build(); + return searchWsReponseGenerator.toUsersForResponse(userSearchResults.searchResults(), paging); } - private User towsUser(UserDto user, @Nullable Integer tokensCount, Collection groups, Boolean managed) { - User.Builder userBuilder = User.newBuilder().setLogin(user.getLogin()); - ofNullable(user.getName()).ifPresent(userBuilder::setName); - if (userSession.isLoggedIn()) { - ofNullable(emptyToNull(user.getEmail())).ifPresent(u -> userBuilder.setAvatar(avatarResolver.create(user))); - userBuilder.setActive(user.isActive()); - userBuilder.setLocal(user.isLocal()); - ofNullable(user.getExternalIdentityProvider()).ifPresent(userBuilder::setExternalProvider); - if (!user.getSortedScmAccounts().isEmpty()) { - userBuilder.setScmAccounts(ScmAccounts.newBuilder().addAllScmAccounts(user.getSortedScmAccounts())); - } - } - if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), user.getUuid())) { - ofNullable(user.getEmail()).ifPresent(userBuilder::setEmail); - if (!groups.isEmpty()) { - userBuilder.setGroups(Groups.newBuilder().addAllGroups(groups)); - } - ofNullable(user.getExternalLogin()).ifPresent(userBuilder::setExternalIdentity); - ofNullable(tokensCount).ifPresent(userBuilder::setTokensCount); - ofNullable(user.getLastConnectionDate()).map(DateUtils::formatDateTime).ifPresent(userBuilder::setLastConnectionDate); - ofNullable(user.getLastSonarlintConnectionDate()) - .map(DateUtils::formatDateTime).ifPresent(userBuilder::setSonarLintLastConnectionDate); - userBuilder.setManaged(TRUE.equals(managed)); - } - return userBuilder.build(); - } - - private static SearchRequest toSearchRequest(Request request) { + private UsersSearchRequest toSearchRequest(Request request) { int pageSize = request.mandatoryParamAsInt(PAGE_SIZE); checkArgument(pageSize <= MAX_PAGE_SIZE, "The '%s' parameter must be less than %s", PAGE_SIZE, MAX_PAGE_SIZE); - return SearchRequest.builder() + return UsersSearchRequest.builder() .setQuery(request.param(TEXT_QUERY)) .setDeactivated(request.mandatoryParamAsBoolean(DEACTIVATED_PARAM)) .setManaged(request.paramAsBoolean(MANAGED_PARAM)) @@ -301,139 +192,12 @@ public class SearchAction implements UsersWsAction { .build(); } - private static class SearchRequest { - private final Integer page; - private final Integer pageSize; - private final String query; - private final boolean deactivated; - private final Boolean managed; - private final OffsetDateTime lastConnectionDateFrom; - private final OffsetDateTime lastConnectionDateTo; - private final OffsetDateTime sonarLintLastConnectionDateFrom; - private final OffsetDateTime sonarLintLastConnectionDateTo; - - private SearchRequest(Builder builder) { - this.page = builder.page; - this.pageSize = builder.pageSize; - this.query = builder.query; - this.deactivated = builder.deactivated; - this.managed = builder.managed; - try { - this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null); - this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null); - this.sonarLintLastConnectionDateFrom = Optional.ofNullable(builder.sonarLintLastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null); - this.sonarLintLastConnectionDateTo = Optional.ofNullable(builder.sonarLintLastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null); - } catch (MessageException me) { - throw new ServerException(400, me.getMessage()); - } - } - - public Integer getPage() { - return page; - } - - public Integer getPageSize() { - return pageSize; - } - - @CheckForNull - public String getQuery() { - return query; - } - - public boolean isDeactivated() { - return deactivated; - } - - @CheckForNull - private Boolean isManaged() { - return managed; - } - - public Optional getLastConnectionDateFrom() { - return Optional.ofNullable(lastConnectionDateFrom); - } - - public Optional getLastConnectionDateTo() { - return Optional.ofNullable(lastConnectionDateTo); - } - - public Optional getSonarLintLastConnectionDateFrom() { - return Optional.ofNullable(sonarLintLastConnectionDateFrom); - } - - public Optional getSonarLintLastConnectionDateTo() { - return Optional.ofNullable(sonarLintLastConnectionDateTo); - } - - public static Builder builder() { - return new Builder(); - } + private static void throwIfParameterValuePresent(Request request, String parameter) { + Optional.ofNullable(request.param(parameter)).ifPresent(v -> throwForbiddenFor(parameter)); } - private static class Builder { - private Integer page; - private Integer pageSize; - private String query; - private boolean deactivated; - private Boolean managed; - private String lastConnectionDateFrom; - private String lastConnectionDateTo; - private String sonarLintLastConnectionDateFrom; - private String sonarLintLastConnectionDateTo; - - - private Builder() { - // enforce factory method use - } - - public Builder setPage(Integer page) { - this.page = page; - return this; - } - - public Builder setPageSize(Integer pageSize) { - this.pageSize = pageSize; - return this; - } - - public Builder setQuery(@Nullable String query) { - this.query = query; - return this; - } - - public Builder setDeactivated(boolean deactivated) { - this.deactivated = deactivated; - return this; - } - - public Builder setManaged(@Nullable Boolean managed) { - this.managed = managed; - return this; - } - - public Builder setLastConnectionDateFrom(@Nullable String lastConnectionDateFrom) { - this.lastConnectionDateFrom = lastConnectionDateFrom; - return this; - } - - public Builder setLastConnectionDateTo(@Nullable String lastConnectionDateTo) { - this.lastConnectionDateTo = lastConnectionDateTo; - return this; - } - - public Builder setSonarLintLastConnectionDateFrom(@Nullable String sonarLintLastConnectionDateFrom) { - this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom; - return this; - } - - public Builder setSonarLintLastConnectionDateTo(@Nullable String sonarLintLastConnectionDateTo) { - this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo; - return this; - } - - public SearchRequest build() { - return new SearchRequest(this); - } + private static void throwForbiddenFor(String parameterName) { + throw new ServerException(403, "parameter " + parameterName + " requires Administer System permission."); } + } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java new file mode 100644 index 00000000000..f8437217973 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java @@ -0,0 +1,83 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.user.ws; + +import java.util.List; +import java.util.Objects; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.Paging; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.user.UsersSearchResponseGenerator; +import org.sonar.server.common.user.service.UserSearchResult; +import org.sonar.server.user.UserSession; +import org.sonarqube.ws.Users; + +import static java.lang.Boolean.TRUE; +import static java.util.Optional.ofNullable; +import static org.sonarqube.ws.Users.SearchWsResponse.newBuilder; + +public class SearchWsReponseGenerator implements UsersSearchResponseGenerator { + + private final UserSession userSession; + + public SearchWsReponseGenerator(UserSession userSession) { + this.userSession = userSession; + } + + @Override + public Users.SearchWsResponse toUsersForResponse(List userSearchResults, Paging paging) { + Users.SearchWsResponse.Builder responseBuilder = newBuilder(); + userSearchResults.forEach(user -> responseBuilder.addUsers(toSearchResponsUser(user))); + responseBuilder.getPagingBuilder() + .setPageIndex(paging.pageIndex()) + .setPageSize(paging.pageSize()) + .setTotal(paging.total()) + .build(); + return responseBuilder.build(); + } + + private Users.SearchWsResponse.User toSearchResponsUser(UserSearchResult userSearchResult) { + UserDto userDto = userSearchResult.userDto(); + Users.SearchWsResponse.User.Builder userBuilder = Users.SearchWsResponse.User.newBuilder().setLogin(userDto.getLogin()); + ofNullable(userDto.getName()).ifPresent(userBuilder::setName); + if (userSession.isLoggedIn()) { + userSearchResult.avatar().ifPresent(userBuilder::setAvatar); + userBuilder.setActive(userDto.isActive()); + userBuilder.setLocal(userDto.isLocal()); + ofNullable(userDto.getExternalIdentityProvider()).ifPresent(userBuilder::setExternalProvider); + if (!userDto.getSortedScmAccounts().isEmpty()) { + userBuilder.setScmAccounts(Users.SearchWsResponse.ScmAccounts.newBuilder().addAllScmAccounts(userDto.getSortedScmAccounts())); + } + } + if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), userDto.getUuid())) { + ofNullable(userDto.getEmail()).ifPresent(userBuilder::setEmail); + if (!userSearchResult.groups().isEmpty()) { + userBuilder.setGroups(Users.SearchWsResponse.Groups.newBuilder().addAllGroups(userSearchResult.groups())); + } + ofNullable(userDto.getExternalLogin()).ifPresent(userBuilder::setExternalIdentity); + userBuilder.setTokensCount(userSearchResult.tokensCount()); + ofNullable(userDto.getLastConnectionDate()).map(DateUtils::formatDateTime).ifPresent(userBuilder::setLastConnectionDate); + ofNullable(userDto.getLastSonarlintConnectionDate()) + .map(DateUtils::formatDateTime).ifPresent(userBuilder::setSonarLintLastConnectionDate); + userBuilder.setManaged(TRUE.equals(userSearchResult.managed())); + } + return userBuilder.build(); + } +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java index eae85baf6bb..b625c8c3744 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java @@ -20,6 +20,7 @@ package org.sonar.server.user.ws; import org.sonar.core.platform.Module; +import org.sonar.server.common.user.service.UserService; public class UsersWsModule extends Module { @@ -36,6 +37,8 @@ public class UsersWsModule extends Module { ChangePasswordAction.class, CurrentAction.class, SearchAction.class, + UserService.class, + SearchWsReponseGenerator.class, GroupsAction.class, IdentityProvidersAction.class, UserJsonWriter.class, diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java index 2d5ec787679..159128e900c 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java @@ -20,6 +20,7 @@ package org.sonar.server.issue; import org.sonar.db.user.UserDto; +import org.sonar.server.common.avatar.AvatarResolver; public class FakeAvatarResolver implements AvatarResolver { diff --git a/settings.gradle b/settings.gradle index ff17baed98e..912592cff2b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,6 +36,7 @@ include 'server:sonar-web:design-system' include 'server:sonar-webserver' include 'server:sonar-webserver-api' include 'server:sonar-webserver-auth' +include 'server:sonar-webserver-common' include 'server:sonar-webserver-core' include 'server:sonar-webserver-es' include 'server:sonar-webserver-webapi' -- 2.39.5