]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12717 add WS api/workflows/search
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Thu, 28 Nov 2019 08:28:36 +0000 (09:28 +0100)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 Jan 2020 19:46:24 +0000 (20:46 +0100)
returns unresolved hotspots of a project, with components and rules details

server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWs.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsModule.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/SearchAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/package-info.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsModuleTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/SearchActionTest.java [new file with mode: 0644]
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
sonar-ws/src/main/protobuf/ws-hotspots.proto [new file with mode: 0644]

diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWs.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWs.java
new file mode 100644 (file)
index 0000000..8ed50bf
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import org.sonar.api.server.ws.WebService;
+
+public class HotspotsWs implements WebService {
+
+  private final HotspotsWsAction[] actions;
+
+  public HotspotsWs(HotspotsWsAction... actions) {
+    this.actions = actions;
+  }
+
+  @Override
+  public void define(Context context) {
+    NewController controller = context.createController("api/hotspots");
+    controller.setDescription("Read and update Security Hotspots.");
+    controller.setSince("8.1");
+    for (HotspotsWsAction action : actions) {
+      action.define(controller);
+    }
+    controller.done();
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsAction.java
new file mode 100644 (file)
index 0000000..f5527b4
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import org.sonar.server.ws.WsAction;
+
+public interface HotspotsWsAction extends WsAction {
+  // marker interface
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/HotspotsWsModule.java
new file mode 100644 (file)
index 0000000..e04dd6d
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import org.sonar.core.platform.Module;
+
+public class HotspotsWsModule extends Module {
+  @Override
+  protected void configureModule() {
+    add(
+      SearchAction.class,
+      HotspotsWs.class
+    );
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/SearchAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/SearchAction.java
new file mode 100644 (file)
index 0000000..a2dd104
--- /dev/null
@@ -0,0 +1,297 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.search.SearchHit;
+import org.sonar.api.resources.Language;
+import org.sonar.api.resources.Languages;
+import org.sonar.api.resources.Qualifiers;
+import org.sonar.api.resources.Scopes;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.rules.RuleType;
+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.web.UserRole;
+import org.sonar.core.util.stream.MoreCollectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.server.es.SearchOptions;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueQuery;
+import org.sonar.server.organization.DefaultOrganizationProvider;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.Hotspots;
+
+import static com.google.common.base.Strings.nullToEmpty;
+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.utils.DateUtils.formatDateTime;
+import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
+import static org.sonar.server.ws.KeyExamples.KEY_PROJECT_EXAMPLE_001;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class SearchAction implements HotspotsWsAction {
+  private static final String PARAM_PROJECT_KEY = "projectKey";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final IssueIndex issueIndex;
+  private final DefaultOrganizationProvider defaultOrganizationProvider;
+  private final Languages languages;
+
+  public SearchAction(DbClient dbClient, UserSession userSession, IssueIndex issueIndex, DefaultOrganizationProvider defaultOrganizationProvider, Languages languages) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.issueIndex = issueIndex;
+    this.defaultOrganizationProvider = defaultOrganizationProvider;
+    this.languages = languages;
+  }
+
+  @Override
+  public void define(WebService.NewController controller) {
+
+    WebService.NewAction action = controller
+      .createAction("search")
+      .setHandler(this)
+      .setDescription("Search for Security Hotpots.")
+      .setSince("8.1")
+      .setInternal(true);
+
+    action.addPagingParams(100);
+    action.createParam(PARAM_PROJECT_KEY)
+      .setDescription("Key of the project")
+      .setExampleValue(KEY_PROJECT_EXAMPLE_001)
+      .setRequired(true);
+    // FIXME add response example and test it
+    // action.setResponseExample()
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      String projectKey = request.mandatoryParam(PARAM_PROJECT_KEY);
+      ComponentDto project = dbClient.componentDao().selectByKey(dbSession, projectKey)
+        .filter(t -> Scopes.PROJECT.equals(t.scope()) && Qualifiers.PROJECT.equals(t.qualifier()))
+        .filter(ComponentDto::isEnabled)
+        .filter(t -> t.getMainBranchProjectUuid() == null)
+        .orElseThrow(() -> new NotFoundException(String.format("Project '%s' not found", projectKey)));
+      userSession.checkComponentPermission(UserRole.USER, project);
+
+      List<IssueDto> orderedIssues = searchHotspots(request, dbSession, project);
+      SearchResponseData searchResponseData = new SearchResponseData(orderedIssues);
+      loadComponents(dbSession, searchResponseData);
+      loadRules(dbSession, searchResponseData);
+      writeProtobuf(formatResponse(searchResponseData), request, response);
+    }
+  }
+
+  private List<IssueDto> searchHotspots(Request request, DbSession dbSession, ComponentDto project) {
+    List<String> issueKeys = searchHotspots(request, project);
+
+    List<IssueDto> unorderedIssues = dbClient.issueDao().selectByKeys(dbSession, issueKeys);
+    Map<String, IssueDto> unorderedIssuesMap = unorderedIssues
+      .stream()
+      .collect(uniqueIndex(IssueDto::getKey, unorderedIssues.size()));
+
+    return issueKeys.stream()
+      .map(unorderedIssuesMap::get)
+      .filter(Objects::nonNull)
+      .collect(Collectors.toList());
+  }
+
+  private List<String> searchHotspots(Request request, ComponentDto project) {
+    IssueQuery.Builder builder = IssueQuery.builder()
+      .projectUuids(Collections.singletonList(project.uuid()))
+      .organizationUuid(project.getOrganizationUuid())
+      .types(Collections.singleton(RuleType.SECURITY_HOTSPOT.name()))
+      .resolved(false);
+    IssueQuery query = builder.build();
+    SearchOptions searchOptions = new SearchOptions()
+      .setPage(request.mandatoryParamAsInt(PAGE), request.mandatoryParamAsInt(PAGE_SIZE));
+    SearchResponse result = issueIndex.search(query, searchOptions);
+    return Arrays.stream(result.getHits().getHits())
+      .map(SearchHit::getId)
+      .collect(MoreCollectors.toList(result.getHits().getHits().length));
+  }
+
+  private void loadComponents(DbSession dbSession, SearchResponseData searchResponseData) {
+    Set<String> componentKeys = searchResponseData.getOrderedHotspots()
+      .stream()
+      .flatMap(hotspot -> Stream.of(hotspot.getComponentKey(), hotspot.getProjectKey()))
+      .collect(Collectors.toSet());
+    if (!componentKeys.isEmpty()) {
+      searchResponseData.addComponents(dbClient.componentDao().selectByDbKeys(dbSession, componentKeys));
+    }
+  }
+
+  private void loadRules(DbSession dbSession, SearchResponseData searchResponseData) {
+    Set<RuleKey> ruleKeys = searchResponseData.getOrderedHotspots()
+      .stream()
+      .map(IssueDto::getRuleKey)
+      .collect(Collectors.toSet());
+    if (!ruleKeys.isEmpty()) {
+      searchResponseData.addRules(dbClient.ruleDao().selectDefinitionByKeys(dbSession, ruleKeys));
+    }
+  }
+
+  private Hotspots.SearchWsResponse formatResponse(SearchResponseData searchResponseData) {
+    Hotspots.SearchWsResponse.Builder responseBuilder = Hotspots.SearchWsResponse.newBuilder();
+    if (!searchResponseData.isEmpty()) {
+      formatHotspots(searchResponseData, responseBuilder);
+      formatComponents(searchResponseData, responseBuilder);
+      formatRules(searchResponseData, responseBuilder);
+    }
+    return responseBuilder.build();
+  }
+
+  private static void formatHotspots(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+    List<IssueDto> orderedHotspots = searchResponseData.getOrderedHotspots();
+    if (orderedHotspots.isEmpty()) {
+      return;
+    }
+
+    Hotspots.Hotspot.Builder builder = Hotspots.Hotspot.newBuilder();
+    for (IssueDto hotspot : orderedHotspots) {
+      builder
+        .clear()
+        .setKey(hotspot.getKey())
+        .setComponent(hotspot.getComponentKey())
+        .setProject(hotspot.getProjectKey())
+        .setRule(hotspot.getRuleKey().toString());
+      ofNullable(hotspot.getStatus()).ifPresent(builder::setStatus);
+//    FIXME resolution field will be added later
+//      ofNullable(hotspot.getResolution()).ifPresent(builder::setResolution);
+      ofNullable(hotspot.getLine()).ifPresent(builder::setLine);
+      builder.setMessage(nullToEmpty(hotspot.getMessage()));
+      ofNullable(hotspot.getAssigneeUuid()).ifPresent(builder::setAssignee);
+      // FIXME Filter author only if user is member of the organization (as done in issues/search WS)
+//      if (data.getUserOrganizationUuids().contains(component.getOrganizationUuid())) {
+      builder.setAuthor(nullToEmpty(hotspot.getAuthorLogin()));
+//      }
+      builder.setCreationDate(formatDateTime(hotspot.getIssueCreationDate()));
+      builder.setUpdateDate(formatDateTime(hotspot.getIssueUpdateDate()));
+
+      responseBuilder.addHotspots(builder.build());
+    }
+  }
+
+  private void formatComponents(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+    Set<ComponentDto> components = searchResponseData.getComponents();
+    if (components.isEmpty()) {
+      return;
+    }
+
+    Hotspots.Component.Builder builder = Hotspots.Component.newBuilder();
+    for (ComponentDto component : components) {
+      builder
+        .clear()
+        .setOrganization(defaultOrganizationProvider.get().getKey())
+        .setKey(component.getKey())
+        .setQualifier(component.qualifier())
+        .setName(component.name())
+        .setLongName(component.longName());
+      ofNullable(component.path()).ifPresent(builder::setPath);
+
+      responseBuilder.addComponents(builder.build());
+    }
+  }
+
+  private void formatRules(SearchResponseData searchResponseData, Hotspots.SearchWsResponse.Builder responseBuilder) {
+    Set<RuleDefinitionDto> rules = searchResponseData.getRules();
+    if (rules.isEmpty()) {
+      return;
+    }
+
+    Common.Rules.Builder rulesBuilder = Common.Rules.newBuilder();
+    Common.Rule.Builder ruleBuilder = Common.Rule.newBuilder();
+    for (RuleDefinitionDto rule : rules) {
+      ruleBuilder
+        .clear()
+        .setKey(rule.getKey().toString())
+        .setName(nullToEmpty(rule.getName()))
+        .setStatus(Common.RuleStatus.valueOf(rule.getStatus().name()));
+
+      String language = rule.getLanguage();
+      if (language == null) {
+        ruleBuilder.setLang("");
+      } else {
+        ruleBuilder.setLang(language);
+        Language lang = languages.get(language);
+        if (lang != null) {
+          ruleBuilder.setLangName(lang.getName());
+        }
+      }
+      rulesBuilder.addRules(ruleBuilder.build());
+    }
+    responseBuilder.setRules(rulesBuilder.build());
+  }
+
+  private static final class SearchResponseData {
+    private final List<IssueDto> orderedHotspots;
+    private final Set<ComponentDto> components = new HashSet<>();
+    private final Set<RuleDefinitionDto> rules = new HashSet<>();
+
+    private SearchResponseData(List<IssueDto> orderedHotspots) {
+      this.orderedHotspots = orderedHotspots;
+    }
+
+    boolean isEmpty() {
+      return orderedHotspots.isEmpty();
+    }
+
+    List<IssueDto> getOrderedHotspots() {
+      return orderedHotspots;
+    }
+
+    void addComponents(Collection<ComponentDto> components) {
+      this.components.addAll(components);
+    }
+
+    Set<ComponentDto> getComponents() {
+      return components;
+    }
+
+    void addRules(Collection<RuleDefinitionDto> rules) {
+      this.rules.addAll(rules);
+    }
+
+    Set<RuleDefinitionDto> getRules() {
+      return rules;
+    }
+  }
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/package-info.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/package-info.java
new file mode 100644 (file)
index 0000000..ecfd1a1
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsModuleTest.java
new file mode 100644 (file)
index 0000000..197753a
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import org.junit.Test;
+import org.sonar.core.platform.ComponentContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER;
+
+public class HotspotsWsModuleTest {
+  @Test
+  public void verify_count_of_added_components() {
+    ComponentContainer container = new ComponentContainer();
+    new HotspotsWsModule().configure(container);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 2);
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/HotspotsWsTest.java
new file mode 100644 (file)
index 0000000..f0e7390
--- /dev/null
@@ -0,0 +1,63 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import java.util.Arrays;
+import java.util.Random;
+import java.util.stream.IntStream;
+import org.junit.Test;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HotspotsWsTest {
+
+  @Test
+  public void define_controller() {
+    String[] actionKeys = IntStream.range(0, 1 + new Random().nextInt(12))
+      .mapToObj(i -> i + randomAlphanumeric(10))
+      .toArray(String[]::new);
+    HotspotsWsAction[] actions = Arrays.stream(actionKeys)
+      .map(actionKey -> new HotspotsWsAction() {
+        @Override
+        public void define(WebService.NewController context) {
+          context.createAction(actionKey).setHandler(this);
+        }
+
+        @Override
+        public void handle(Request request, Response response) {
+
+        }
+      })
+      .toArray(HotspotsWsAction[]::new);
+    WebService.Context context = new WebService.Context();
+    new HotspotsWs(actions).define(context);
+    WebService.Controller controller = context.controller("api/hotspots");
+
+    assertThat(controller).isNotNull();
+    assertThat(controller.description()).isNotEmpty();
+    assertThat(controller.since()).isEqualTo("8.1");
+    assertThat(controller.actions()).extracting(WebService.Action::key).containsOnly(actionKeys);
+  }
+
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/SearchActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/SearchActionTest.java
new file mode 100644 (file)
index 0000000..8273a49
--- /dev/null
@@ -0,0 +1,494 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.hotspot.ws;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Random;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.issue.Issue;
+import org.sonar.api.resources.AbstractLanguage;
+import org.sonar.api.resources.Languages;
+import org.sonar.api.rule.RuleKey;
+import org.sonar.api.rules.RuleType;
+import org.sonar.api.utils.System2;
+import org.sonar.api.web.UserRole;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.ComponentTesting;
+import org.sonar.db.issue.IssueDto;
+import org.sonar.db.rule.RuleDefinitionDto;
+import org.sonar.db.rule.RuleTesting;
+import org.sonar.server.es.EsTester;
+import org.sonar.server.es.StartupIndexer;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.issue.index.IssueIndex;
+import org.sonar.server.issue.index.IssueIndexer;
+import org.sonar.server.issue.index.IssueIteratorFactory;
+import org.sonar.server.organization.TestDefaultOrganizationProvider;
+import org.sonar.server.permission.index.PermissionIndexer;
+import org.sonar.server.permission.index.WebAuthorizationTypeSupport;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.Common.RuleStatus;
+import org.sonarqube.ws.Hotspots;
+import org.sonarqube.ws.Hotspots.Component;
+import org.sonarqube.ws.Hotspots.SearchWsResponse;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.rules.RuleType.SECURITY_HOTSPOT;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.core.util.stream.MoreCollectors.uniqueIndex;
+import static org.sonar.db.component.ComponentTesting.newDirectory;
+import static org.sonar.db.component.ComponentTesting.newFileDto;
+
+public class SearchActionTest {
+  private static final Random RANDOM = new Random();
+
+  @Rule
+  public DbTester dbTester = DbTester.create(System2.INSTANCE);
+  @Rule
+  public EsTester es = EsTester.create();
+  @Rule
+  public UserSessionRule userSessionRule = UserSessionRule.standalone();
+
+  private DbClient dbClient = dbTester.getDbClient();
+  private TestDefaultOrganizationProvider defaultOrganizationProvider = TestDefaultOrganizationProvider.from(dbTester);
+
+  private IssueIndex issueIndex = new IssueIndex(es.client(), System2.INSTANCE, userSessionRule, new WebAuthorizationTypeSupport(userSessionRule));
+  private IssueIndexer issueIndexer = new IssueIndexer(es.client(), dbClient, new IssueIteratorFactory(dbClient));
+  private StartupIndexer permissionIndexer = new PermissionIndexer(dbClient, es.client(), issueIndexer);
+
+  private Languages languages = mock(Languages.class);
+  private SearchAction underTest = new SearchAction(dbClient, userSessionRule, issueIndex, defaultOrganizationProvider, languages);
+  private WsActionTester actionTester = new WsActionTester(underTest);
+
+  @Test
+  public void ws_is_internal() {
+    assertThat(actionTester.getDef().isInternal()).isTrue();
+  }
+
+  @Test
+  public void fails_with_IAE_if_parameter_projectKey_is_missing() {
+    TestRequest request = actionTester.newRequest();
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("The 'projectKey' parameter is missing");
+  }
+
+  @Test
+  public void fails_with_NotFoundException_if_project_does_not_exist() {
+    String key = randomAlphabetic(12);
+    TestRequest request = actionTester.newRequest()
+      .setParam("projectKey", key);
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(NotFoundException.class)
+      .hasMessage("Project '%s' not found", key);
+  }
+
+  @Test
+  public void fails_with_NotFoundException_if_project_is_not_a_project() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    ComponentDto directory = dbTester.components().insertComponent(ComponentTesting.newDirectory(project, "foo"));
+    ComponentDto file = dbTester.components().insertComponent(ComponentTesting.newFileDto(project));
+    ComponentDto portfolio = dbTester.components().insertPrivatePortfolio(dbTester.getDefaultOrganization());
+    ComponentDto application = dbTester.components().insertPrivateApplication(dbTester.getDefaultOrganization());
+    TestRequest request = actionTester.newRequest();
+
+    for (ComponentDto component : Arrays.asList(directory, file, portfolio, application)) {
+      request.setParam("projectKey", component.getKey());
+
+      assertThatThrownBy(request::execute)
+        .isInstanceOf(NotFoundException.class)
+        .hasMessage("Project '%s' not found", component.getKey());
+    }
+  }
+
+  @Test
+  public void fails_with_ForbiddenException_if_project_is_private_and_not_allowed() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    userSessionRule.registerComponents(project);
+    TestRequest request = actionTester.newRequest()
+      .setParam("projectKey", project.getKey());
+
+    assertThatThrownBy(request::execute)
+      .isInstanceOf(ForbiddenException.class)
+      .hasMessage("Insufficient privileges");
+  }
+
+  @Test
+  public void succeeds_on_public_project() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList()).isEmpty();
+    assertThat(response.getComponentsList()).isEmpty();
+    assertThat(response.getRules().getRulesList()).isEmpty();
+  }
+
+  @Test
+  public void succeeds_on_private_project_with_permission() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    userSessionRule.registerComponents(project);
+    userSessionRule.logIn().addProjectPermission(UserRole.USER, project);
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList()).isEmpty();
+    assertThat(response.getComponentsList()).isEmpty();
+    assertThat(response.getRules().getRulesList()).isEmpty();
+  }
+
+  @Test
+  public void returns_no_hotspot_component_nor_rule_when_project_has_no_hotspot() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    Arrays.stream(RuleType.values())
+      .filter(t -> t != SECURITY_HOTSPOT)
+      .forEach(ruleType -> {
+        RuleDefinitionDto rule = newRule(ruleType);
+        dbTester.issues().insert(rule, project, file, t -> t.setType(ruleType));
+      });
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList()).isEmpty();
+    assertThat(response.getComponentsList()).isEmpty();
+    assertThat(response.getRules().getRulesList()).isEmpty();
+  }
+
+  @Test
+  public void returns_hotspot_component_and_rule_when_project_has_hotspots() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    ComponentDto fileWithHotspot = dbTester.components().insertComponent(newFileDto(project));
+    Arrays.stream(RuleType.values())
+      .filter(t -> t != SECURITY_HOTSPOT)
+      .forEach(ruleType -> {
+        RuleDefinitionDto rule = newRule(ruleType);
+        dbTester.issues().insert(rule, project, file, t -> t.setType(ruleType));
+      });
+    IssueDto[] hotspots = IntStream.range(0, 1 + RANDOM.nextInt(10))
+      .mapToObj(i -> {
+        RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+        return dbTester.issues().insert(rule, project, fileWithHotspot, t -> t.setType(SECURITY_HOTSPOT));
+      })
+      .toArray(IssueDto[]::new);
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList())
+      .extracting(Hotspots.Hotspot::getKey)
+      .containsOnly(Arrays.stream(hotspots).map(IssueDto::getKey).toArray(String[]::new));
+    assertThat(response.getComponentsList())
+      .extracting(Component::getKey)
+      .containsOnly(project.getKey(), fileWithHotspot.getKey());
+    assertThat(response.getRules().getRulesList())
+      .extracting(Common.Rule::getKey)
+      .containsOnly(Arrays.stream(hotspots).map(t -> t.getRuleKey().toString()).toArray(String[]::new));
+  }
+
+  @Test
+  public void returns_hotspots_of_specified_project() {
+    ComponentDto project1 = dbTester.components().insertPublicProject();
+    ComponentDto project2 = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project1, project2);
+    indexPermissions();
+    ComponentDto file1 = dbTester.components().insertComponent(newFileDto(project1));
+    ComponentDto file2 = dbTester.components().insertComponent(newFileDto(project2));
+    IssueDto[] hotspots2 = IntStream.range(0, 1 + RANDOM.nextInt(10))
+      .mapToObj(i -> {
+        RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+        dbTester.issues().insert(rule, project1, file1, t -> t.setType(SECURITY_HOTSPOT));
+        return dbTester.issues().insert(rule, project2, file2, t -> t.setType(SECURITY_HOTSPOT));
+      })
+      .toArray(IssueDto[]::new);
+    indexIssues();
+
+    SearchWsResponse responseProject1 = actionTester.newRequest()
+      .setParam("projectKey", project1.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(responseProject1.getHotspotsList())
+      .extracting(Hotspots.Hotspot::getKey)
+      .doesNotContainAnyElementsOf(Arrays.stream(hotspots2).map(IssueDto::getKey).collect(Collectors.toList()));
+    assertThat(responseProject1.getComponentsList())
+      .extracting(Component::getKey)
+      .containsOnly(project1.getKey(), file1.getKey());
+    assertThat(responseProject1.getRules().getRulesList())
+      .extracting(Common.Rule::getKey)
+      .containsOnly(Arrays.stream(hotspots2).map(t -> t.getRuleKey().toString()).toArray(String[]::new));
+
+    SearchWsResponse responseProject2 = actionTester.newRequest()
+      .setParam("projectKey", project2.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(responseProject2.getHotspotsList())
+      .extracting(Hotspots.Hotspot::getKey)
+      .containsOnly(Arrays.stream(hotspots2).map(IssueDto::getKey).toArray(String[]::new));
+    assertThat(responseProject2.getComponentsList())
+      .extracting(Component::getKey)
+      .containsOnly(project2.getKey(), file2.getKey());
+    assertThat(responseProject2.getRules().getRulesList())
+      .extracting(Common.Rule::getKey)
+      .containsOnly(Arrays.stream(hotspots2).map(t -> t.getRuleKey().toString()).toArray(String[]::new));
+  }
+
+  @Test
+  public void returns_only_unresolved_hotspots() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto unresolvedHotspot = dbTester.issues().insert(rule, project, file,
+      t -> t.setType(SECURITY_HOTSPOT).setResolution(null));
+    // unrealistic case since a resolution must be set, but shows a limit of current implementation
+    IssueDto badlyClosedHotspot = dbTester.issues().insert(rule, project, file,
+      t -> t.setType(SECURITY_HOTSPOT).setStatus(Issue.STATUS_CLOSED).setResolution(null));
+    IssueDto resolvedHotspot = dbTester.issues().insert(rule, project, file,
+      t -> t.setType(SECURITY_HOTSPOT).setResolution(randomAlphabetic(5)));
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList())
+      .extracting(Hotspots.Hotspot::getKey)
+      .containsOnly(unresolvedHotspot.getKey(), badlyClosedHotspot.getKey());
+  }
+
+  @Test
+  public void returns_fields_of_hotspot() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto hotspot = dbTester.issues().insert(rule, project, file,
+      t -> t.setType(SECURITY_HOTSPOT)
+        .setStatus(randomAlphabetic(11))
+        .setLine(RANDOM.nextInt(230))
+        .setMessage(randomAlphabetic(10))
+        .setAssigneeUuid(randomAlphabetic(9))
+        .setAuthorLogin(randomAlphabetic(8)));
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList()).hasSize(1);
+    Hotspots.Hotspot actual = response.getHotspots(0);
+    assertThat(actual.getComponent()).isEqualTo(file.getKey());
+    assertThat(actual.getProject()).isEqualTo(project.getKey());
+    assertThat(actual.getRule()).isEqualTo(hotspot.getRuleKey().toString());
+    assertThat(actual.getStatus()).isEqualTo(hotspot.getStatus());
+    // FIXME resolution field will be added later
+    // assertThat(actual.getResolution()).isEqualTo(hotspot.getResolution());
+    assertThat(actual.getLine()).isEqualTo(hotspot.getLine());
+    assertThat(actual.getMessage()).isEqualTo(hotspot.getMessage());
+    assertThat(actual.getAssignee()).isEqualTo(hotspot.getAssigneeUuid());
+    assertThat(actual.getAuthor()).isEqualTo(hotspot.getAuthorLogin());
+    assertThat(actual.getCreationDate()).isEqualTo(formatDateTime(hotspot.getIssueCreationDate()));
+    assertThat(actual.getUpdateDate()).isEqualTo(formatDateTime(hotspot.getIssueUpdateDate()));
+  }
+
+  @Test
+  public void does_not_fail_when_hotspot_has_none_of_the_nullable_fields() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    dbTester.issues().insert(rule, project, file,
+      t -> t.setType(SECURITY_HOTSPOT)
+        .setStatus(null)
+        // FIXME resolution field will be added later
+        // .setResolution(null)
+        .setLine(null)
+        .setMessage(null)
+        .setAssigneeUuid(null)
+        .setAuthorLogin(null));
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList())
+      .hasSize(1);
+    Hotspots.Hotspot actual = response.getHotspots(0);
+    assertThat(actual.hasStatus()).isFalse();
+    // FIXME resolution field will be added later
+    // assertThat(actual.hasResolution()).isFalse();
+    assertThat(actual.hasLine()).isFalse();
+    assertThat(actual.getMessage()).isEmpty();
+    assertThat(actual.hasAssignee()).isFalse();
+    assertThat(actual.getAuthor()).isEmpty();
+  }
+
+  @Test
+  public void returns_details_of_components() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto directory = dbTester.components().insertComponent(newDirectory(project, "donut/acme"));
+    ComponentDto directory2 = dbTester.components().insertComponent(newDirectory(project, "foo/bar"));
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    ComponentDto file2 = dbTester.components().insertComponent(newFileDto(project));
+    RuleDefinitionDto rule = newRule(SECURITY_HOTSPOT);
+    IssueDto fileHotspot = dbTester.issues().insert(rule, project, file, t -> t.setType(SECURITY_HOTSPOT));
+    IssueDto dirHotspot = dbTester.issues().insert(rule, project, directory, t -> t.setType(SECURITY_HOTSPOT));
+    IssueDto projectHotspot = dbTester.issues().insert(rule, project, project, t -> t.setType(SECURITY_HOTSPOT));
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList())
+      .extracting(Hotspots.Hotspot::getKey)
+      .containsOnly(fileHotspot.getKey(), dirHotspot.getKey(), projectHotspot.getKey());
+    assertThat(response.getComponentsList()).hasSize(3);
+    assertThat(response.getComponentsList())
+      .extracting(Component::getOrganization)
+      .containsOnly(defaultOrganizationProvider.get().getKey());
+    assertThat(response.getComponentsList())
+      .extracting(Component::getKey)
+      .containsOnly(project.getKey(), directory.getKey(), file.getKey());
+    Map<String, Component> componentByKey = response.getComponentsList().stream().collect(uniqueIndex(Component::getKey));
+    Component actualProject = componentByKey.get(project.getKey());
+    assertThat(actualProject.getQualifier()).isEqualTo(project.qualifier());
+    assertThat(actualProject.getName()).isEqualTo(project.name());
+    assertThat(actualProject.getLongName()).isEqualTo(project.longName());
+    assertThat(actualProject.hasPath()).isFalse();
+    Component actualDirectory = componentByKey.get(directory.getKey());
+    assertThat(actualDirectory.getQualifier()).isEqualTo(directory.qualifier());
+    assertThat(actualDirectory.getName()).isEqualTo(directory.name());
+    assertThat(actualDirectory.getLongName()).isEqualTo(directory.longName());
+    assertThat(actualDirectory.getPath()).isEqualTo(directory.path());
+    Component actualFile = componentByKey.get(file.getKey());
+    assertThat(actualFile.getQualifier()).isEqualTo(file.qualifier());
+    assertThat(actualFile.getName()).isEqualTo(file.name());
+    assertThat(actualFile.getLongName()).isEqualTo(file.longName());
+    assertThat(actualFile.getPath()).isEqualTo(file.path());
+  }
+
+  @Test
+  public void returns_details_of_rule_with_language_name_when_available() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    userSessionRule.registerComponents(project);
+    indexPermissions();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    String language1 = randomAlphabetic(3);
+    String language2 = randomAlphabetic(2);
+    RuleDefinitionDto rule1a = newRule(SECURITY_HOTSPOT, t -> t.setLanguage(language1));
+    RuleDefinitionDto rule1b = newRule(SECURITY_HOTSPOT, t -> t.setLanguage(language1));
+    RuleDefinitionDto rule2 = newRule(SECURITY_HOTSPOT, t -> t.setLanguage(language2));
+    when(languages.get(language1)).thenReturn(new AbstractLanguage(language1, language1 + "_name") {
+      @Override
+      public String[] getFileSuffixes() {
+        return new String[0];
+      }
+    });
+    IssueDto hotspot1a = dbTester.issues().insert(rule1a, project, file, t -> t.setType(SECURITY_HOTSPOT));
+    IssueDto hotspot1b = dbTester.issues().insert(rule1b, project, file, t -> t.setType(SECURITY_HOTSPOT));
+    IssueDto hotspot2 = dbTester.issues().insert(rule2, project, file, t -> t.setType(SECURITY_HOTSPOT));
+    indexIssues();
+
+    SearchWsResponse response = actionTester.newRequest()
+      .setParam("projectKey", project.getKey())
+      .executeProtobuf(SearchWsResponse.class);
+
+    assertThat(response.getHotspotsList()).hasSize(3);
+    assertThat(response.getRules().getRulesList()).hasSize(3);
+    Map<RuleKey, Common.Rule> rulesByKey = response.getRules().getRulesList()
+      .stream()
+      .collect(uniqueIndex(t -> RuleKey.parse(t.getKey())));
+    Common.Rule actualRule1a = rulesByKey.get(hotspot1a.getRuleKey());
+    assertThat(actualRule1a.getName()).isEqualTo(rule1a.getName());
+    assertThat(actualRule1a.getLang()).isEqualTo(rule1a.getLanguage());
+    assertThat(actualRule1a.getLangName()).isEqualTo(rule1a.getLanguage() + "_name");
+    assertThat(actualRule1a.getStatus()).isEqualTo(RuleStatus.valueOf(rule1a.getStatus().name()));
+    Common.Rule actualRule1b = rulesByKey.get(hotspot1b.getRuleKey());
+    assertThat(actualRule1b.getName()).isEqualTo(rule1b.getName());
+    assertThat(actualRule1b.getLang()).isEqualTo(rule1b.getLanguage());
+    assertThat(actualRule1b.getLangName()).isEqualTo(rule1b.getLanguage() + "_name");
+    assertThat(actualRule1b.getStatus()).isEqualTo(RuleStatus.valueOf(rule1b.getStatus().name()));
+    Common.Rule actualRule2 = rulesByKey.get(hotspot2.getRuleKey());
+    assertThat(actualRule2.getName()).isEqualTo(rule2.getName());
+    assertThat(actualRule2.getLang()).isEqualTo(rule2.getLanguage());
+    assertThat(actualRule2.hasLangName()).isFalse();
+    assertThat(actualRule2.getStatus()).isEqualTo(RuleStatus.valueOf(rule2.getStatus().name()));
+  }
+
+  private void indexPermissions() {
+    permissionIndexer.indexOnStartup(permissionIndexer.getIndexTypes());
+  }
+
+  private void indexIssues() {
+    issueIndexer.indexOnStartup(issueIndexer.getIndexTypes());
+  }
+
+  private RuleDefinitionDto newRule(RuleType ruleType) {
+    return newRule(ruleType, t -> {
+    });
+  }
+
+  private RuleDefinitionDto newRule(RuleType ruleType, Consumer<RuleDefinitionDto> populate) {
+    RuleDefinitionDto ruleDefinition = RuleTesting.newRule()
+      .setType(ruleType);
+    populate.accept(ruleDefinition);
+    dbTester.rules().insert(ruleDefinition);
+    return ruleDefinition;
+  }
+}
index c52a7cb1ac6d607ece124d1be8b4de407246c718..e53a13750b0943cddaab8b03203da5f7adbd01c6 100644 (file)
@@ -74,6 +74,7 @@ import org.sonar.server.extension.CoreExtensionStopper;
 import org.sonar.server.favorite.FavoriteModule;
 import org.sonar.server.favorite.ws.FavoriteWsModule;
 import org.sonar.server.health.NodeHealthModule;
+import org.sonar.server.hotspot.ws.HotspotsWsModule;
 import org.sonar.server.issue.AddTagsAction;
 import org.sonar.server.issue.AssignAction;
 import org.sonar.server.issue.CommentAction;
@@ -429,6 +430,9 @@ public class PlatformLevel4 extends PlatformLevel {
       RemoveTagsAction.class,
       IssueChangePostProcessorImpl.class,
 
+      // hotspots
+      HotspotsWsModule.class,
+
       // source
       SourceWsModule.class,
 
diff --git a/sonar-ws/src/main/protobuf/ws-hotspots.proto b/sonar-ws/src/main/protobuf/ws-hotspots.proto
new file mode 100644 (file)
index 0000000..6630860
--- /dev/null
@@ -0,0 +1,61 @@
+// SonarQube, open source software quality management tool.
+// Copyright (C) 2008-2016 SonarSource
+// mailto:contact AT sonarsource DOT com
+//
+// SonarQube 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.
+//
+// SonarQube 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.
+
+syntax = "proto2";
+
+package sonarqube.ws.hotspots;
+
+import "ws-commons.proto";
+
+option java_package = "org.sonarqube.ws";
+option java_outer_classname = "Hotspots";
+option optimize_for = SPEED;
+
+// Response of GET api/hotspots/search
+message SearchWsResponse {
+  optional sonarqube.ws.commons.Paging paging = 1;
+  repeated Hotspot hotspots = 2;
+  repeated Component components = 3;
+  optional sonarqube.ws.commons.Rules rules = 4;
+}
+
+message Hotspot {
+  optional string key = 1;
+  optional string component = 2;
+  optional string project = 3;
+  optional string rule = 4;
+  optional string status = 5;
+//  FIXME resolution field will be added later
+//  optional string resolution = 6;
+  optional int32 line = 7;
+  optional string message = 8;
+  optional string assignee = 9;
+  // SCM login of the committer who introduced the issue
+  optional string author = 10;
+  optional string creationDate = 11;
+  optional string updateDate = 12;
+}
+
+message Component {
+  optional string organization = 1;
+  optional string key = 2;
+  optional string qualifier = 3;
+  optional string name = 4;
+  optional string longName = 5;
+  optional string path = 6;
+}