aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.gradle5
-rw-r--r--server/sonar-webserver-common/build.gradle29
-rw-r--r--server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java408
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java25
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java (renamed from server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java)2
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java (renamed from server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java)2
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java30
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java27
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java125
-rw-r--r--server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java163
-rw-r--r--server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java (renamed from server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java)13
-rw-r--r--server/sonar-webserver-webapi-v2/build.gradle8
-rw-r--r--server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java28
-rw-r--r--server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java6
-rw-r--r--server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java6
-rw-r--r--server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java8
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java1
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java22
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java54
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java90
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java56
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java111
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java51
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java61
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java27
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java23
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java37
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java15
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java15
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java5
-rw-r--r--server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java2
-rw-r--r--server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java44
-rw-r--r--server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java205
-rw-r--r--server/sonar-webserver-webapi/build.gradle1
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java4
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java1
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java4
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java4
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java2
-rw-r--r--server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java13
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java1
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java2
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java294
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java83
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java3
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java1
-rw-r--r--settings.gradle1
68 files changed, 1980 insertions, 327 deletions
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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UserSearchResult> 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<UsersSearchRequest.Builder, UsersSearchRequest.Builder> query, String userLogin, boolean isExpectedToBeThere) {
+
+ UsersSearchRequest.Builder builder = getBuilderWithDefaultsPageSize();
+ builder = query.apply(builder);
+
+ SearchResults<UserSearchResult> 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<T>(List<T> 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
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
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> {
+
+ T toUsersForResponse(List<UserSearchResult> 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<String> avatar, Collection<String> 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<UserSearchResult> findUsers(UsersSearchRequest request) {
+ UserQuery userQuery = buildUserQuery(request);
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery);
+
+ List<UserSearchResult> 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<UserSearchResult> performSearch(DbSession dbSession, UserQuery userQuery, int pageIndex, int pageSize) {
+ List<UserDto> userDtos = findUsersAndSortByLogin(dbSession, userQuery, pageIndex, pageSize);
+ List<String> logins = userDtos.stream().map(UserDto::getLogin).toList();
+ Multimap<String, String> groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins);
+ Map<String, Integer> tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, userDtos);
+ Map<String, Boolean> 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<String> groups, int tokenCount, boolean managed, UserDto userDto) {
+ return new UserSearchResult(
+ userDto,
+ managed,
+ findAvatar(userDto),
+ groups,
+ tokenCount
+ );
+ }
+
+ private List<UserDto> 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<String> findAvatar(UserDto userDto) {
+ return Optional.ofNullable(userDto.getEmail()).map(email -> avatarResolver.create(userDto));
+ }
+
+ private static Set<String> getUserUuids(List<UserDto> 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<OffsetDateTime> getLastConnectionDateFrom() {
+ return Optional.ofNullable(lastConnectionDateFrom);
+ }
+
+ public Optional<OffsetDateTime> getLastConnectionDateTo() {
+ return Optional.ofNullable(lastConnectionDateTo);
+ }
+
+ public Optional<OffsetDateTime> getSonarLintLastConnectionDateFrom() {
+ return Optional.ofNullable(sonarLintLastConnectionDateFrom);
+ }
+
+ public Optional<OffsetDateTime> 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
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<UserSearchResult> 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<UsersSearchRestResponse> {
+
+ private final UserSession userSession;
+
+ public UsersSearchRestResponseGenerator(UserSession userSession) {
+ this.userSession = userSession;
+ }
+
+ @Override
+ public UsersSearchRestResponse toUsersForResponse(List<UserSearchResult> userSearchResults, Paging paging) {
+ List<RestUser> usersForResponse = toUsersForResponse(userSearchResults);
+ PageRestResponse pageRestResponse = new PageRestResponse(paging.pageIndex(), paging.pageSize(), paging.total());
+ return new UsersSearchRestResponse(usersForResponse, pageRestResponse);
+ }
+
+ private List<RestUser> toUsersForResponse(List<UserSearchResult> 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<RestUser> 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<Object> handleIllegalStateException(IllegalStateException illegalStateException) {
- return new ResponseEntity<>(illegalStateException.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
+ protected ResponseEntity<RestError> handleIllegalStateException(IllegalStateException illegalStateException) {
+ return new ResponseEntity<>(new RestError(illegalStateException.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
}
- @ExceptionHandler(ForbiddenException.class)
- @ResponseStatus(HttpStatus.FORBIDDEN)
- protected ResponseEntity<Object> handleForbiddenException(ForbiddenException forbiddenException) {
- return handleServerException(forbiddenException);
+ @ExceptionHandler(BindException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ protected ResponseEntity<RestError> 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<Object> 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<Object> 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<RestError> 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<UserDto> users = findUsersAndSortByLogin(request, dbSession, userQuery);
- int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery);
-
- List<String> logins = users.stream().map(UserDto::getLogin).toList();
- Multimap<String, String> groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins);
- Map<String, Integer> tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, users);
- Map<String, Boolean> 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<String> getUserUuids(List<UserDto> 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<UserDto> 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<UserSearchResult> userSearchResults = userService.findUsers(request);
+ Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal(userSearchResults.total());
- private SearchWsResponse buildResponse(List<UserDto> users, Multimap<String, String> groupsByLogin, Map<String, Integer> tokenCountsByLogin,
- Map<String, Boolean> 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<String> 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<OffsetDateTime> getLastConnectionDateFrom() {
- return Optional.ofNullable(lastConnectionDateFrom);
- }
-
- public Optional<OffsetDateTime> getLastConnectionDateTo() {
- return Optional.ofNullable(lastConnectionDateTo);
- }
-
- public Optional<OffsetDateTime> getSonarLintLastConnectionDateFrom() {
- return Optional.ofNullable(sonarLintLastConnectionDateFrom);
- }
-
- public Optional<OffsetDateTime> 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<Users.SearchWsResponse> {
+
+ private final UserSession userSession;
+
+ public SearchWsReponseGenerator(UserSession userSession) {
+ this.userSession = userSession;
+ }
+
+ @Override
+ public Users.SearchWsResponse toUsersForResponse(List<UserSearchResult> 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'