]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19963 Add GET /api/v2/users endpoint
authorAurelien Poscia <aurelien.poscia@sonarsource.com>
Wed, 19 Jul 2023 09:38:55 +0000 (11:38 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 26 Jul 2023 20:03:24 +0000 (20:03 +0000)
71 files changed:
build.gradle
server/sonar-webserver-common/build.gradle [new file with mode: 0644]
server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java [new file with mode: 0644]
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java [new file with mode: 0644]
server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/build.gradle
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java [new file with mode: 0644]
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/build.gradle
server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java [deleted file]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java [deleted file]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java [deleted file]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java
settings.gradle

index ad7c22cfd4d65af692cfe763739df29a62848356..9fb58f044eef0be78f881dd57d28aae5e2967b98 100644 (file)
@@ -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 (file)
index 0000000..21daee7
--- /dev/null
@@ -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 (file)
index 0000000..769af7d
--- /dev/null
@@ -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 (file)
index 0000000..7d27bc1
--- /dev/null
@@ -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-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java
new file mode 100644 (file)
index 0000000..c238f17
--- /dev/null
@@ -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.avatar;
+
+import org.sonar.db.user.UserDto;
+
+public interface AvatarResolver {
+
+  /**
+   * Creates an avatar ID to load a user's avatar (ex: Gravatar identified by an email hash)
+   */
+  String create(UserDto user);
+}
diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java
new file mode 100644 (file)
index 0000000..505abfc
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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.avatar;
+
+import com.google.common.hash.Hashing;
+import org.sonar.db.user.UserDto;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Locale.ENGLISH;
+import static java.util.Objects.requireNonNull;
+
+public class AvatarResolverImpl implements AvatarResolver {
+
+  @Override
+  public String create(UserDto user) {
+    UserDto userDto = requireNonNull(user, "User cannot be null");
+    return hash(requireNonNull(emptyToNull(userDto.getEmail()), "Email cannot be null"));
+  }
+
+  private static String hash(String text) {
+    return Hashing.md5().hashString(text.toLowerCase(ENGLISH), UTF_8).toString();
+  }
+}
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 (file)
index 0000000..81025de
--- /dev/null
@@ -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 (file)
index 0000000..0b980d4
--- /dev/null
@@ -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 (file)
index 0000000..9c5bc4c
--- /dev/null
@@ -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 (file)
index 0000000..90d27e5
--- /dev/null
@@ -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-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java
new file mode 100644 (file)
index 0000000..2a8341e
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * 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.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;
+
+public class AvatarResolverImplTest {
+
+  private AvatarResolverImpl underTest = new AvatarResolverImpl();
+
+  @Test
+  public void create() {
+    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(UserTesting.newUserDto("john", "John", "john@doo.com"))).isEqualTo(underTest.create(UserTesting.newUserDto("john", "John", "John@Doo.com")));
+  }
+
+  @Test
+  public void fail_with_NP_when_user_is_null() {
+    assertThatThrownBy(() -> underTest.create(null))
+      .isInstanceOf(NullPointerException.class)
+      .hasMessage("User cannot be null");
+  }
+
+  @Test
+  public void fail_with_NP_when_email_is_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(UserTesting.newUserDto("john", "John", "")))
+      .isInstanceOf(NullPointerException.class)
+      .hasMessage("Email cannot be null");
+  }
+}
index c206e6616a270d2a0856371d667e33b0994582e3..42a332351139298b1e3f3759df678d217571913b 100644 (file)
@@ -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 (file)
index 0000000..9073d3b
--- /dev/null
@@ -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 {
+
+}
index a5871772bf079168e99be4d52fcbdc44f0f1993c..12bfee0eb87c5cac3fd79fda3cb806f7b5c96290 100644 (file)
@@ -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);
+  }
 }
index eb7eed3fe3297f7f20db13bdca4e082d070aa817..3e7734453355737cf6d384fd4ef4b8080323fdd6 100644 (file)
@@ -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\"}"));
   }
 }
index c216fd43c2f91ba18dbc8895b20385b7aab91b80..9fb213f90901ace99ba1f7f7c85ac1002995ac67 100644 (file)
@@ -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\"}"));
   }
 }
index 89e3c797a55c91a020d2bbc5c673d38e365e6d96..4870e06292ff1574eb76c71a14b49fd4beb68bd0 100644 (file)
@@ -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 (file)
index 0000000..534467a
--- /dev/null
@@ -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 (file)
index 0000000..ec374ec
--- /dev/null
@@ -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 (file)
index 0000000..381c1e0
--- /dev/null
@@ -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 (file)
index 0000000..13b5f05
--- /dev/null
@@ -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 (file)
index 0000000..42ee281
--- /dev/null
@@ -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 (file)
index 0000000..c095a72
--- /dev/null
@@ -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 (file)
index 0000000..03181b3
--- /dev/null
@@ -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 (file)
index 0000000..2aa60ec
--- /dev/null
@@ -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 (file)
index 0000000..6ad0fad
--- /dev/null
@@ -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 (file)
index 0000000..a44fcd9
--- /dev/null
@@ -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 (file)
index 0000000..f3f7d0f
--- /dev/null
@@ -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 (file)
index 0000000..7131261
--- /dev/null
@@ -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 (file)
index 0000000..318424c
--- /dev/null
@@ -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 (file)
index 0000000..cdde982
--- /dev/null
@@ -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 (file)
index 0000000..38e5a2d
--- /dev/null
@@ -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 (file)
index 0000000..d6b945f
--- /dev/null
@@ -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;
index d62cc5c6996ca92686b1b5404600d579d91d3af9..baccbbbc764803d8eb992d535ca8fb525044f4e6 100644 (file)
 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));
   }
 }
index 34c77693a300d0815a7f16776a239325e3cb0293..f8715068a3679f83751409daf109e9e40ca783b5 100644 (file)
@@ -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);
+  }
+
 }
index 60b82f70862b6faeefd543b52452df884d58ca3b..d6c133308df0b99cec66782f2ae61d81a870d8bf 100644 (file)
@@ -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);
+  }
+
 }
index 8641f7f881134fbb7c376ad91681de6ee6373b52..d609c9e1110f9850f20996862539297bb3effbd3 100644 (file)
 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;
index 3b4daf0062012650bbee455cafc6d4d9670abc6c..9433a6e1b48da4404a70fca8bc1f87474275a605 100644 (file)
@@ -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 (file)
index 0000000..533b771
--- /dev/null
@@ -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 (file)
index 0000000..41bc8a9
--- /dev/null
@@ -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());
+  }
+
+}
index 1b9a88044ab29aa70507413878329bf74e08487c..c09626198717d123786048597eddffa2351534f0 100644 (file)
@@ -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')
index bf7e5ee10c953923dc32094e236ce9afaac73786..7e3f9b98f8481c844caf07cc527aca600c4cc1fc 100644 (file)
@@ -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;
index 1213d30f0f2b5e9ad14ec0359e5d066661402c86..ee605e68316201dc44ba45f9c9077a5f1aeda6c2 100644 (file)
@@ -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;
index 17c9d0c420797e84a3f7c8e0134cec42f980eb25..9b539ae0d35c36e13217992348006678ce47f59a 100644 (file)
@@ -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;
index 31e866372516113ee8c853e22290be8f6fbef201..e9859cf60975afb57a805fa32ee83eb1ef7ade00 100644 (file)
@@ -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;
index 0354b324d4c799a9b6db2a3f8d97e5a5ae252e9d..2c0b9e5ecb995ce40d2bd4cf396683a8ad4bdb64 100644 (file)
@@ -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;
index e7dc989e17bf2e5540eff6fbd0686051a29061f4..8e59599e0853533f87b5f324246192c470eea81c 100644 (file)
@@ -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;
index 17e7a9a1343326757b1eddef094aa3d4ff4f7be9..ad598866ccbc7518fe16f4601b51060056be44be 100644 (file)
@@ -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;
index d01a4ca298d1f17f2767419cd193bea7a3f613a6..4b8645f3231a3b6d2db9869eef6bb3e4e28c9610 100644 (file)
@@ -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;
index 7125bc4fcbe7cf79c69b17808b3553814b0dc739..46ea4eecc861efffd4ff3549b020195f1084fc7c 100644 (file)
@@ -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;
index b42f0f7339856504de3b36af726d987bd29fd384..acdacb8231c50c747a74b3219f2b52407f323fec 100644 (file)
@@ -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;
index 3b7a78200973efe44d4a2be59356d46e68ce1606..8b34bb0a94b1a1514f2c831857b1b09e0ab72599 100644 (file)
@@ -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;
index 261b65a08a516946ea009a96cfe8ce0aa3bfa06b..a03588bc60890130b496cbda052b5cda0515a8ee 100644 (file)
@@ -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;
index 0d1d89ef5b10a44d53031233481bc8a24b860501..0bbf9174777c37edea536024b54575ef019e437d 100644 (file)
@@ -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;
index 4a4c315715d284b3573a2230a0e1932856d141ef..f75975c1c66bdffc90a9fad71622224f4f5ca047 100644 (file)
@@ -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/AvatarResolver.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java
deleted file mode 100644 (file)
index 821fbb9..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * 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.issue;
-
-import org.sonar.db.user.UserDto;
-
-public interface AvatarResolver {
-
-  /**
-   * Creates an avatar ID to load a user's avatar (ex: Gravatar identified by an email hash)
-   */
-  String create(UserDto user);
-}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java
deleted file mode 100644 (file)
index 092efd2..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * 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.issue;
-
-import com.google.common.hash.Hashing;
-import org.sonar.db.user.UserDto;
-
-import static com.google.common.base.Strings.emptyToNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Locale.ENGLISH;
-import static java.util.Objects.requireNonNull;
-
-public class AvatarResolverImpl implements AvatarResolver {
-
-  @Override
-  public String create(UserDto user) {
-    UserDto userDto = requireNonNull(user, "User cannot be null");
-    return hash(requireNonNull(emptyToNull(userDto.getEmail()), "Email cannot be null"));
-  }
-
-  private static String hash(String text) {
-    return Hashing.md5().hashString(text.toLowerCase(ENGLISH), UTF_8).toString();
-  }
-}
index 3d98042ed1593df360d840f77eb91305e9176b1f..5f780c51017bafa12a7179771f4a46a2ab9fa9df 100644 (file)
@@ -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;
 
index b6aeea2980795ce9cd01d21fce763dc695ff9d9a..4351b1edeca28b6967c847a700a21147c1b492cf 100644 (file)
@@ -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;
index 704204eb05b0a7cd079a27dcab70ba5d965b0873..007d53020fa4ba3c47c5f91d4fcb29352d8ce0d5 100644 (file)
@@ -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;
index 23f1ae3bee420123f7d3a9929265f0744ee63a5f..236702a069e6d0689ff1abd3fc3b0143230370c3 100644 (file)
@@ -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;
index 940d2bae2751880dcdcbdf6a962f164581726e7d..e5cb0bd10eb3308cc928e01174a588c0ab69326d 100644 (file)
@@ -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;
index 79605cf7c36a95a7014c8f17116b23fde2e0243f..36dff950e725c17886c4046ede0b3d43f5e1e5fe 100644 (file)
@@ -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;
 
index d2525aef6cfa1327a467a71d08960e8904cd5ce2..630f347221fb97ceab2f1aee2dedae04bf2d9959 100644 (file)
@@ -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;
 
index 4362402baa0996f2e37d456eea77dde648ff8318..dae5f958d051b9c1a15064e3699a006352aaf463 100644 (file)
@@ -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;
index 4a5e2adc909bb1c25acefc3a1e61eb80cdfd0195..8864fb41673fadcfed7ec33d735164b833b56d14 100644 (file)
  */
 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 (file)
index 0000000..f843721
--- /dev/null
@@ -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();
+  }
+}
index eae85baf6bbdb94c114efc5998bbfe95afbc9246..b625c8c3744b266f3866f967cc2512f6a5b620b7 100644 (file)
@@ -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/AvatarResolverImplTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java
deleted file mode 100644 (file)
index db33224..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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.issue;
-
-import org.junit.Test;
-
-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"));
-
-    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")));
-  }
-
-  @Test
-  public void fail_with_NP_when_user_is_null() {
-    assertThatThrownBy(() -> underTest.create(null))
-      .isInstanceOf(NullPointerException.class)
-      .hasMessage("User cannot be null");
-  }
-
-  @Test
-  public void fail_with_NP_when_email_is_null() {
-    assertThatThrownBy(() -> underTest.create(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", "")))
-      .isInstanceOf(NullPointerException.class)
-      .hasMessage("Email cannot be null");
-  }
-}
index 2d5ec787679f810fa03a5211ffd4eddba48492a9..159128e900c576deec73c69b202d54aa6b85a005 100644 (file)
@@ -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 {
 
index ff17baed98e94d3260038c4b20a1f663afc89f0a..912592cff2b52fdc72a091605971a6c406ab0cd3 100644 (file)
@@ -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'