]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-6610 WS custom_measures/create create a custom measure
authorTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Tue, 16 Jun 2015 08:20:29 +0000 (10:20 +0200)
committerTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Thu, 18 Jun 2015 08:13:16 +0000 (10:13 +0200)
16 files changed:
server/sonar-server/src/main/java/org/sonar/server/measure/custom/persistence/CustomMeasureDao.java
server/sonar-server/src/main/java/org/sonar/server/measure/custom/ws/CreateAction.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/measure/custom/ws/CustomMeasuresWsModule.java
server/sonar-server/src/main/java/org/sonar/server/metric/persistence/MetricDao.java
server/sonar-server/src/main/java/org/sonar/server/util/MetricLevelTypeValidation.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/util/MetricWorkDurationTypeValidation.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/measure/custom/ws/CreateActionTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/measure/custom/ws/CustomMeasuresWsModuleTest.java
server/sonar-server/src/test/java/org/sonar/server/measure/custom/ws/CustomMeasuresWsTest.java
server/sonar-server/src/test/resources/org/sonar/server/measure/custom/ws/CreateActionTest/custom-measure.json [new file with mode: 0644]
sonar-core/src/main/java/org/sonar/core/measure/custom/db/CustomMeasureMapper.java
sonar-core/src/main/resources/org/sonar/core/measure/custom/db/CustomMeasureMapper.xml
sonar-core/src/main/resources/org/sonar/l10n/core.properties
sonar-plugin-api/src/main/java/org/sonar/api/PropertyType.java
sonar-plugin-api/src/main/java/org/sonar/api/measures/Metric.java
sonar-plugin-api/src/main/java/org/sonar/api/utils/Durations.java

index 94c26bca1995ee64a7a0c19b07e811b2af954f24..0c609c98615ee690ed1579909c8eea4fbb173035 100644 (file)
@@ -65,12 +65,16 @@ public class CustomMeasureDao implements DaoComponent {
     return customMeasure;
   }
 
-  public List<CustomMeasureDto> selectByMetricId(DbSession session, int id) {
-    return mapper(session).selectByMetricId(id);
+  public List<CustomMeasureDto> selectByMetricId(DbSession session, int metricId) {
+    return mapper(session).selectByMetricId(metricId);
   }
 
-  public List<CustomMeasureDto> selectByComponentId(DbSession session, long id) {
-    return mapper(session).selectByComponentId(id);
+  public List<CustomMeasureDto> selectByComponentId(DbSession session, long componentId) {
+    return mapper(session).selectByComponentId(componentId);
+  }
+
+  public int countByComponentIdAndMetricId(DbSession session, String componentUuid, int metricId) {
+    return mapper(session).countByComponentIdAndMetricId(componentUuid, metricId);
   }
 
   private CustomMeasureMapper mapper(DbSession session) {
diff --git a/server/sonar-server/src/main/java/org/sonar/server/measure/custom/ws/CreateAction.java b/server/sonar-server/src/main/java/org/sonar/server/measure/custom/ws/CreateAction.java
new file mode 100644 (file)
index 0000000..39b9428
--- /dev/null
@@ -0,0 +1,307 @@
+/*
+ * SonarQube, open source software quality management tool.
+ * Copyright (C) 2008-2014 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.
+ */
+
+package org.sonar.server.measure.custom.ws;
+
+import com.google.common.base.Joiner;
+import java.net.HttpURLConnection;
+import org.sonar.api.PropertyType;
+import org.sonar.api.measures.Metric;
+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.Durations;
+import org.sonar.api.utils.System2;
+import org.sonar.api.utils.text.JsonWriter;
+import org.sonar.api.web.UserRole;
+import org.sonar.core.component.ComponentDto;
+import org.sonar.core.measure.custom.db.CustomMeasureDto;
+import org.sonar.core.metric.db.MetricDto;
+import org.sonar.core.permission.GlobalPermissions;
+import org.sonar.core.persistence.DbSession;
+import org.sonar.core.persistence.MyBatis;
+import org.sonar.server.db.DbClient;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.exceptions.ServerException;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.util.TypeValidations;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+public class CreateAction implements CustomMeasuresWsAction {
+  public static final String ACTION = "create";
+  public static final String PARAM_PROJECT_ID = "projectId";
+  public static final String PARAM_PROJECT_KEY = "projectKey";
+  public static final String PARAM_METRIC_ID = "metricId";
+  public static final String PARAM_METRIC_KEY = "metricKey";
+  public static final String PARAM_VALUE = "value";
+  public static final String PARAM_DESCRIPTION = "description";
+
+  private static final String FIELD_ID = "id";
+  private static final String FIELD_PROJECT_ID = PARAM_PROJECT_ID;
+  private static final String FIELD_PROJECT_KEY = PARAM_PROJECT_KEY;
+  private static final String FIELD_METRIC_ID = PARAM_METRIC_ID;
+  private static final String FIELD_VALUE = PARAM_VALUE;
+  private static final String FIELD_DESCRIPTION = PARAM_DESCRIPTION;
+  private static final String FIELD_METRIC_KEY = PARAM_METRIC_KEY;
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+  private final System2 system;
+  private final TypeValidations typeValidations;
+  private final Durations durations;
+
+  public CreateAction(DbClient dbClient, UserSession userSession, System2 system, TypeValidations typeValidations, Durations durations) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+    this.system = system;
+    this.typeValidations = typeValidations;
+    this.durations = durations;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    WebService.NewAction action = context.createAction(ACTION)
+      .setDescription("Create a custom measure.<br /> " +
+        "The project id or the project key must be provided. The metric id or the metric key must be provided.<br/>" +
+        "Requires 'Administer System' permission or 'Administer' permission on the project.")
+      .setSince("5.2")
+      .setPost(true)
+      .setHandler(this);
+
+    action.createParam(PARAM_PROJECT_ID)
+      .setDescription("Project id")
+      .setExampleValue("ce4c03d6-430f-40a9-b777-ad877c00aa4d");
+
+    action.createParam(PARAM_PROJECT_KEY)
+      .setDescription("Project key")
+      .setExampleValue("org.apache.hbas:hbase");
+
+    action.createParam(PARAM_METRIC_ID)
+      .setDescription("Metric id")
+      .setExampleValue("16");
+
+    action.createParam(PARAM_METRIC_KEY)
+      .setDescription("Metric key")
+      .setExampleValue("ncloc");
+
+    action.createParam(PARAM_VALUE)
+      .setRequired(true)
+      .setDescription(measureValueDescription())
+      .setExampleValue("47");
+
+    action.createParam(PARAM_DESCRIPTION)
+      .setDescription("Description")
+      .setExampleValue("Team size growing.");
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    DbSession dbSession = dbClient.openSession(false);
+    String description = request.param(PARAM_DESCRIPTION);
+    long now = system.now();
+
+    try {
+      ComponentDto component = searchProject(dbSession, request);
+      MetricDto metric = searchMetric(dbSession, request);
+      checkPermissions(component);
+      checkMeasureDoesNotExistAlready(dbSession, component, metric);
+      CustomMeasureDto measure = new CustomMeasureDto()
+        .setComponentUuid(component.uuid())
+        .setComponentId(component.getId())
+        .setMetricId(metric.getId())
+        .setDescription(description)
+        .setCreatedAt(now);
+      setMeasureValue(measure, request, metric);
+      dbClient.customMeasureDao().insert(dbSession, measure);
+      dbSession.commit();
+
+      JsonWriter json = response.newJsonWriter();
+      writeMeasure(json, measure, component, metric, request.mandatoryParam(PARAM_VALUE));
+      json.close();
+    } finally {
+      MyBatis.closeQuietly(dbSession);
+    }
+  }
+
+  private void checkPermissions(ComponentDto component) {
+    if (userSession.hasGlobalPermission(GlobalPermissions.SYSTEM_ADMIN)) {
+      return;
+    }
+
+    userSession.checkLoggedIn().checkProjectUuidPermission(UserRole.ADMIN, component.projectUuid());
+  }
+
+  private void checkMeasureDoesNotExistAlready(DbSession dbSession, ComponentDto component, MetricDto metric) {
+    int nbMeasuresOnSameMetricAndMeasure = dbClient.customMeasureDao().countByComponentIdAndMetricId(dbSession, component.uuid(), metric.getId());
+    if (nbMeasuresOnSameMetricAndMeasure > 0) {
+      throw new ServerException(HttpURLConnection.HTTP_CONFLICT, String.format("A measure already exists for project id '%s' and metric id '%d'",
+        component.uuid(), metric.getId()));
+    }
+  }
+
+  private static void writeMeasure(JsonWriter json, CustomMeasureDto measure, ComponentDto component, MetricDto metric, String measureWithoutInternalFormatting) {
+    json.beginObject();
+    json.prop(FIELD_ID, String.valueOf(measure.getId()));
+    json.prop(FIELD_METRIC_ID, String.valueOf(metric.getId()));
+    json.prop(FIELD_METRIC_KEY, metric.getKey());
+    json.prop(FIELD_PROJECT_ID, component.uuid());
+    json.prop(FIELD_PROJECT_KEY, component.key());
+    json.prop(FIELD_DESCRIPTION, measure.getDescription());
+    json.prop(FIELD_VALUE, measureWithoutInternalFormatting);
+    json.endObject();
+  }
+
+  private void setMeasureValue(CustomMeasureDto measure, Request request, MetricDto metric) {
+    String valueAsString = request.mandatoryParam(PARAM_VALUE);
+    Metric.ValueType metricType = Metric.ValueType.valueOf(metric.getValueType());
+    try {
+      switch (metricType) {
+        case BOOL:
+          checkAndSetBooleanMeasureValue(measure, valueAsString);
+          break;
+        case INT:
+        case MILLISEC:
+          checkAndSetIntegerMeasureValue(measure, valueAsString);
+          break;
+        case WORK_DUR:
+          checkAndSetWorkDurationMeasureValue(measure, valueAsString);
+          break;
+        case FLOAT:
+        case PERCENT:
+        case RATING:
+          checkAndSetFloatMeasureValue(measure, valueAsString);
+          break;
+        case LEVEL:
+          checkAndSetLevelMeasureValue(measure, valueAsString);
+          break;
+        case STRING:
+        case DATA:
+        case DISTRIB:
+          measure.setTextValue(valueAsString);
+          break;
+        default:
+          throw new IllegalArgumentException("Unsupported metric type:" + metricType.description());
+      }
+    } catch (Exception e) {
+      throw new IllegalArgumentException(String.format("Ill formatted value '%s' for metric type '%s'", valueAsString, metricType.description()), e);
+    }
+  }
+
+  private void checkAndSetLevelMeasureValue(CustomMeasureDto measure, String valueAsString) {
+    typeValidations.validate(valueAsString, PropertyType.METRIC_LEVEL.name(), null);
+    measure.setTextValue(valueAsString);
+  }
+
+  private void checkAndSetFloatMeasureValue(CustomMeasureDto measure, String valueAsString) {
+    typeValidations.validate(valueAsString, PropertyType.FLOAT.name(), null);
+    measure.setValue(Double.parseDouble(valueAsString));
+  }
+
+  private void checkAndSetWorkDurationMeasureValue(CustomMeasureDto measure, String valueAsString) {
+    typeValidations.validate(valueAsString, PropertyType.METRIC_WORK_DURATION.name(), null);
+    measure.setValue(durations.decode(valueAsString).toMinutes());
+  }
+
+  private void checkAndSetIntegerMeasureValue(CustomMeasureDto measure, String valueAsString) {
+    typeValidations.validate(valueAsString, PropertyType.INTEGER.name(), null);
+    measure.setValue(Integer.parseInt(valueAsString));
+  }
+
+  private void checkAndSetBooleanMeasureValue(CustomMeasureDto measure, String valueAsString) {
+    typeValidations.validate(valueAsString, PropertyType.BOOLEAN.name(), null);
+    measure.setValue(Boolean.parseBoolean(valueAsString) ? 1.0d : 0.0d);
+  }
+
+  private MetricDto searchMetric(DbSession dbSession, Request request) {
+    Integer metricId = request.paramAsInt(PARAM_METRIC_ID);
+    String metricKey = request.param(PARAM_METRIC_KEY);
+    checkArgument(metricId != null ^ metricKey != null, "The metric id or the metric key must be provided, not both.");
+
+    if (metricId != null) {
+      return dbClient.metricDao().selectById(dbSession, metricId);
+    }
+
+    return dbClient.metricDao().selectByKey(dbSession, metricKey);
+  }
+
+  private ComponentDto searchProject(DbSession dbSession, Request request) {
+    String projectUuid = request.param(PARAM_PROJECT_ID);
+    String projectKey = request.param(PARAM_PROJECT_KEY);
+    checkArgument(projectUuid != null ^ projectKey != null, "The project key or the project id must be provided, not both.");
+
+    if (projectUuid != null) {
+      ComponentDto project = dbClient.componentDao().selectNullableByUuid(dbSession, projectUuid);
+      if (project == null) {
+        throw new NotFoundException(String.format("Project id '%s' not found", projectUuid));
+      }
+
+      return project;
+    }
+
+    ComponentDto project = dbClient.componentDao().selectNullableByKey(dbSession, projectKey);
+    if (project == null) {
+      throw new NotFoundException(String.format("Project key '%s' not found", projectKey));
+    }
+
+    return project;
+  }
+
+  private static String measureValueDescription() {
+    StringBuilder description = new StringBuilder("Measure value. Value type depends on metric type:");
+    description.append("<ul>");
+    for (Metric.ValueType metricType : Metric.ValueType.values()) {
+      description.append("<li>");
+      description.append(String.format("%s - %s", metricType.description(), metricTypeWsDescription(metricType)));
+      description.append("</li>");
+    }
+    description.append("</ul>");
+
+    return description.toString();
+  }
+
+  private static String metricTypeWsDescription(Metric.ValueType metricType) {
+    switch (metricType) {
+      case BOOL:
+        return "the possible values are true or false";
+      case INT:
+      case MILLISEC:
+        return "type: integer";
+      case FLOAT:
+      case PERCENT:
+      case RATING:
+        return "type: double";
+      case LEVEL:
+        return "the possible values are " + formattedMetricLevelNames();
+      case STRING:
+      case DATA:
+      case DISTRIB:
+        return "type: string";
+      case WORK_DUR:
+        return "duration format: 12d 5h 30min";
+      default:
+        return "metric type not supported";
+    }
+  }
+
+  private static String formattedMetricLevelNames() {
+    return Joiner.on(", ").join(Metric.Level.names());
+  }
+}
index f0c981e7e464dd5a0446c39ff3f872805d227e8a..ef50f5e42b3ecb957dac7e421fea8042949c5167 100644 (file)
@@ -27,6 +27,7 @@ public class CustomMeasuresWsModule extends Module {
   protected void configureModule() {
     add(
       CustomMeasuresWs.class,
-      DeleteAction.class);
+      DeleteAction.class,
+      CreateAction.class);
   }
 }
index 50de63febca2f4711f43f8d13a0fa64960c52e6d..8765806f7351633058223c58483251c2fa314186 100644 (file)
@@ -63,7 +63,7 @@ public class MetricDao implements DaoComponent {
   public MetricDto selectByKey(DbSession session, String key) {
     MetricDto metric = selectNullableByKey(session, key);
     if (metric == null) {
-      throw new NotFoundException(String.format("Metric '%s' not found", key));
+      throw new NotFoundException(String.format("Metric key '%s' not found", key));
     }
     return metric;
   }
@@ -129,4 +129,12 @@ public class MetricDao implements DaoComponent {
   public MetricDto selectNullableById(DbSession session, long id) {
     return mapper(session).selectById(id);
   }
+
+  public MetricDto selectById(DbSession session, int id) {
+    MetricDto metric = mapper(session).selectById(id);
+    if (metric == null) {
+      throw new NotFoundException(String.format("Metric id '%d' not found", id));
+    }
+    return metric;
+  }
 }
diff --git a/server/sonar-server/src/main/java/org/sonar/server/util/MetricLevelTypeValidation.java b/server/sonar-server/src/main/java/org/sonar/server/util/MetricLevelTypeValidation.java
new file mode 100644 (file)
index 0000000..4c964de
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube, open source software quality management tool.
+ * Copyright (C) 2008-2014 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.
+ */
+
+package org.sonar.server.util;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.sonar.api.PropertyType;
+import org.sonar.api.measures.Metric;
+import org.sonar.server.exceptions.BadRequestException;
+
+public class MetricLevelTypeValidation implements TypeValidation {
+  @Override
+  public String key() {
+    return PropertyType.METRIC_LEVEL.name();
+  }
+
+  @Override
+  public void validate(String value, @Nullable List<String> options) {
+    try {
+      Metric.Level.valueOf(value);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("errors.type.notMetricLevel", value);
+    }
+  }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/util/MetricWorkDurationTypeValidation.java b/server/sonar-server/src/main/java/org/sonar/server/util/MetricWorkDurationTypeValidation.java
new file mode 100644 (file)
index 0000000..c2bbf3c
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube, open source software quality management tool.
+ * Copyright (C) 2008-2014 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.
+ */
+
+package org.sonar.server.util;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.sonar.api.PropertyType;
+import org.sonar.api.utils.Durations;
+import org.sonar.server.exceptions.BadRequestException;
+
+public class MetricWorkDurationTypeValidation implements TypeValidation {
+  private final Durations durations;
+
+  public MetricWorkDurationTypeValidation(Durations durations) {
+    this.durations = durations;
+  }
+
+  @Override
+  public String key() {
+    return PropertyType.METRIC_WORK_DURATION.name();
+  }
+
+  @Override
+  public void validate(String value, @Nullable List<String> options) {
+    try {
+      durations.decode(value);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("errors.type.notMetricWorkDuration", value);
+    }
+  }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/measure/custom/ws/CreateActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/measure/custom/ws/CreateActionTest.java
new file mode 100644 (file)
index 0000000..532d73e
--- /dev/null
@@ -0,0 +1,417 @@
+/*
+ * SonarQube, open source software quality management tool.
+ * Copyright (C) 2008-2014 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.
+ */
+
+package org.sonar.server.measure.custom.ws;
+
+import java.util.Arrays;
+import java.util.List;
+import org.assertj.core.data.Offset;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.Settings;
+import org.sonar.api.i18n.I18n;
+import org.sonar.api.measures.Metric;
+import org.sonar.api.measures.Metric.ValueType;
+import org.sonar.api.utils.Durations;
+import org.sonar.api.utils.System2;
+import org.sonar.api.web.UserRole;
+import org.sonar.core.measure.custom.db.CustomMeasureDto;
+import org.sonar.core.metric.db.MetricDto;
+import org.sonar.core.permission.GlobalPermissions;
+import org.sonar.core.persistence.DbSession;
+import org.sonar.core.persistence.DbTester;
+import org.sonar.server.component.ComponentTesting;
+import org.sonar.server.component.db.ComponentDao;
+import org.sonar.server.db.DbClient;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.exceptions.ServerException;
+import org.sonar.server.measure.custom.persistence.CustomMeasureDao;
+import org.sonar.server.metric.persistence.MetricDao;
+import org.sonar.server.metric.ws.MetricTesting;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.util.BooleanTypeValidation;
+import org.sonar.server.util.FloatTypeValidation;
+import org.sonar.server.util.IntegerTypeValidation;
+import org.sonar.server.util.MetricLevelTypeValidation;
+import org.sonar.server.util.MetricWorkDurationTypeValidation;
+import org.sonar.server.util.TypeValidations;
+import org.sonar.server.ws.WsTester;
+import org.sonar.test.DbTests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.data.Offset.offset;
+import static org.mockito.Mockito.mock;
+
+@Category(DbTests.class)
+public class CreateActionTest {
+
+  private static final String DEFAULT_PROJECT_UUID = "project-uuid";
+  private static final String DEFAULT_PROJECT_KEY = "project-key";
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @ClassRule
+  public static DbTester db = new DbTester();
+  DbClient dbClient;
+  DbSession dbSession;
+
+  WsTester ws;
+
+  @Before
+  public void setUp() {
+    dbClient = new DbClient(db.database(), db.myBatis(), new CustomMeasureDao(), new MetricDao(), new ComponentDao());
+    dbSession = dbClient.openSession(false);
+    Durations durations = new Durations(mock(Settings.class), mock(I18n.class));
+    TypeValidations typeValidations = new TypeValidations(Arrays.asList(new BooleanTypeValidation(), new IntegerTypeValidation(), new FloatTypeValidation(),
+      new MetricLevelTypeValidation(), new MetricWorkDurationTypeValidation(durations)));
+    ws = new WsTester(new CustomMeasuresWs(new CreateAction(dbClient, userSession, System2.INSTANCE, typeValidations, durations)));
+    db.truncateTables();
+    userSession.setGlobalPermissions(GlobalPermissions.SYSTEM_ADMIN);
+  }
+
+  @After
+  public void tearDown() {
+    dbSession.close();
+  }
+
+  @Test
+  public void create_boolean_custom_measure_in_db() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.BOOL, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_DESCRIPTION, "custom-measure-description")
+      .setParam(CreateAction.PARAM_VALUE, "true")
+      .execute();
+
+    List<CustomMeasureDto> customMeasures = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId());
+    CustomMeasureDto customMeasure = customMeasures.get(0);
+    assertThat(customMeasures).hasSize(1);
+    assertThat(customMeasure.getDescription()).isEqualTo("custom-measure-description");
+    assertThat(customMeasure.getTextValue()).isNullOrEmpty();
+    assertThat(customMeasure.getValue()).isCloseTo(1.0d, offset(0.01d));
+    assertThat(customMeasure.getComponentUuid()).isEqualTo(DEFAULT_PROJECT_UUID);
+  }
+
+  @Test
+  public void create_int_custom_measure_in_db() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.INT, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "42")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure.getTextValue()).isNullOrEmpty();
+    assertThat(customMeasure.getValue()).isCloseTo(42.0d, offset(0.01d));
+  }
+
+  @Test
+  public void create_text_custom_measure_in_db() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "custom-measure-free-text")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure.getTextValue()).isEqualTo("custom-measure-free-text");
+  }
+
+  @Test
+  public void create_text_custom_measure_as_project_admin() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+    userSession.login("login").addProjectUuidPermissions(UserRole.ADMIN, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "custom-measure-free-text")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure).isNotNull();
+  }
+
+  @Test
+  public void create_text_custom_measure_with_metric_key() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_KEY, metric.getKey())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure).isNotNull();
+  }
+
+  @Test
+  public void create_text_custom_measure_with_project_key() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_KEY, DEFAULT_PROJECT_KEY)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure).isNotNull();
+  }
+
+  @Test
+  public void create_float_custom_measure_indb() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.FLOAT, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "4.2")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure.getValue()).isCloseTo(4.2d, Offset.offset(0.01d));
+    assertThat(customMeasure.getTextValue()).isNullOrEmpty();
+  }
+
+  @Test
+  public void create_work_duration_custom_measure_in_db() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.WORK_DUR, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "4h 12min")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure.getTextValue()).isNullOrEmpty();
+    assertThat(customMeasure.getValue()).isCloseTo(4 * 60 + 12, offset(0.01d));
+  }
+
+  @Test
+  public void create_level_type_custom_measure_in_db() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.LEVEL, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, Metric.Level.WARN.name())
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    assertThat(customMeasure.getTextValue()).isEqualTo(Metric.Level.WARN.name());
+  }
+
+  @Test
+  public void response_with_object_and_id() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    WsTester.Result response = newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_DESCRIPTION, "custom-measure-description")
+      .setParam(CreateAction.PARAM_VALUE, "custom-measure-free-text")
+      .execute();
+
+    CustomMeasureDto customMeasure = dbClient.customMeasureDao().selectByMetricId(dbSession, metric.getId()).get(0);
+    response.assertJson(getClass(), "custom-measure.json");
+    assertThat(response.outputAsString()).matches(String.format(".*\"id\"\\s*:\\s*\"%d\".*", customMeasure.getId()));
+    assertThat(response.outputAsString()).matches(String.format(".*\"metricId\"\\s*:\\s*\"%d\".*", metric.getId()));
+  }
+
+  @Test
+  public void fail_when_get_request() throws Exception {
+    expectedException.expect(ServerException.class);
+
+    ws.newGetRequest(CustomMeasuresWs.ENDPOINT, CreateAction.ACTION)
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, "whatever-id")
+      .setParam(CreateAction.PARAM_VALUE, "custom-measure-free-text")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_project_id_nor_project_key_provided() throws Exception {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("The project key or the project id must be provided, not both.");
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_METRIC_ID, "whatever-id")
+      .setParam(CreateAction.PARAM_VALUE, metric.getId().toString())
+      .execute();
+  }
+
+  @Test
+  public void fail_when_project_id_and_project_key_are_provided() throws Exception {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("The project key or the project id must be provided, not both.");
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_PROJECT_KEY, DEFAULT_PROJECT_KEY)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_project_key_does_not_exist_in_db() throws Exception {
+    expectedException.expect(NotFoundException.class);
+    expectedException.expectMessage("Project key 'another-project-key' not found");
+    insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_KEY, "another-project-key")
+      .setParam(CreateAction.PARAM_METRIC_ID, "whatever-id")
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_project_id_does_not_exist_in_db() throws Exception {
+    expectedException.expect(NotFoundException.class);
+    expectedException.expectMessage("Project id 'another-project-uuid' not found");
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, "another-project-uuid")
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_metric_id_nor_metric_key_is_provided() throws Exception {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("The metric id or the metric key must be provided, not both.");
+    insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_metric_id_and_metric_key_are_provided() throws Exception {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("The metric id or the metric key must be provided, not both.");
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_METRIC_KEY, metric.getKey())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+
+  }
+
+  @Test
+  public void fail_when_metric_is_not_found_in_db() throws Exception {
+    dbClient.componentDao().insert(dbSession, ComponentTesting.newProjectDto(DEFAULT_PROJECT_UUID));
+    dbSession.commit();
+
+    expectedException.expect(ServerException.class);
+    expectedException.expectMessage("Metric id '42' not found");
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, "42")
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_measure_already_exists_on_same_project_and_same_metric() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    expectedException.expect(ServerException.class);
+    expectedException.expectMessage(String.format("A measure already exists for project id 'project-uuid' and metric id '%d'", metric.getId()));
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_value_is_not_well_formatted() throws Exception {
+    MetricDto metric = insertMetricAndProject(ValueType.BOOL, DEFAULT_PROJECT_UUID);
+
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Ill formatted value 'non-correct-boolean-value' for metric type 'Yes/No'");
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "non-correct-boolean-value")
+      .execute();
+  }
+
+  @Test
+  public void fail_when_not_enough_permission() throws Exception {
+    expectedException.expect(ForbiddenException.class);
+    userSession.login("login");
+    MetricDto metric = insertMetricAndProject(ValueType.STRING, DEFAULT_PROJECT_UUID);
+
+    newRequest()
+      .setParam(CreateAction.PARAM_PROJECT_ID, DEFAULT_PROJECT_UUID)
+      .setParam(CreateAction.PARAM_METRIC_ID, metric.getId().toString())
+      .setParam(CreateAction.PARAM_VALUE, "whatever-value")
+      .execute();
+  }
+
+  private WsTester.TestRequest newRequest() {
+    return ws.newPostRequest(CustomMeasuresWs.ENDPOINT, CreateAction.ACTION);
+  }
+
+  private MetricDto insertMetricAndProject(ValueType metricType, String projectUuid) {
+    MetricDto metric = MetricTesting.newMetricDto().setEnabled(true).setValueType(metricType.name()).setKey("metric-key");
+    dbClient.metricDao().insert(dbSession, metric);
+    dbClient.componentDao().insert(dbSession, ComponentTesting.newProjectDto(projectUuid).setKey(DEFAULT_PROJECT_KEY));
+    dbSession.commit();
+
+    return metric;
+  }
+}
index c0ed75c4b9bb8975078dbf7dd3b90e20a63e844e..098819b9cb06eec1ad3adefbd65923d6d4d9868f 100644 (file)
@@ -30,6 +30,6 @@ public class CustomMeasuresWsModuleTest {
   public void verify_count_of_added_components() {
     ComponentContainer container = new ComponentContainer();
     new CustomMeasuresWsModule().configure(container);
-    assertThat(container.size()).isEqualTo(4);
+    assertThat(container.size()).isEqualTo(5);
   }
 }
index d0a50285eba8c566a07ea28c1d94ebb2d70f383d..4a7a34fed552e8eb3252c8e611dbec319c6dff40 100644 (file)
@@ -23,8 +23,11 @@ package org.sonar.server.measure.custom.ws;
 import org.junit.Before;
 import org.junit.Test;
 import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.Durations;
+import org.sonar.api.utils.System2;
 import org.sonar.server.db.DbClient;
 import org.sonar.server.user.UserSession;
+import org.sonar.server.util.TypeValidations;
 import org.sonar.server.ws.WsTester;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -38,7 +41,8 @@ public class CustomMeasuresWsTest {
     DbClient dbClient = mock(DbClient.class);
     UserSession userSession = mock(UserSession.class);
     ws = new WsTester(new CustomMeasuresWs(
-      new DeleteAction(dbClient, userSession)
+      new DeleteAction(dbClient, userSession),
+      new CreateAction(dbClient, userSession, System2.INSTANCE, mock(TypeValidations.class), mock(Durations.class))
       ));
   }
 
@@ -47,7 +51,7 @@ public class CustomMeasuresWsTest {
     WebService.Controller controller = ws.controller("api/custom_measures");
     assertThat(controller).isNotNull();
     assertThat(controller.description()).isNotEmpty();
-    assertThat(controller.actions()).hasSize(1);
+    assertThat(controller.actions()).hasSize(2);
   }
 
   @Test
@@ -55,4 +59,10 @@ public class CustomMeasuresWsTest {
     WebService.Action deleteAction = ws.controller("api/custom_measures").action("delete");
     assertThat(deleteAction.isPost()).isTrue();
   }
+
+  @Test
+  public void create_action_properties() {
+    WebService.Action action = ws.controller("api/custom_measures").action("create");
+    assertThat(action.isPost()).isTrue();
+  }
 }
diff --git a/server/sonar-server/src/test/resources/org/sonar/server/measure/custom/ws/CreateActionTest/custom-measure.json b/server/sonar-server/src/test/resources/org/sonar/server/measure/custom/ws/CreateActionTest/custom-measure.json
new file mode 100644 (file)
index 0000000..80e9f14
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "projectId": "project-uuid",
+  "projectKey": "project-key",
+  "metricKey": "metric-key",
+  "value": "custom-measure-free-text",
+  "description": "custom-measure-description"
+}
index ab0c89beb607852010a64bda7a1f40abf540b678..4b609b7fee2a948c440d3c6c338e5c35ffc7b66e 100644 (file)
@@ -35,4 +35,6 @@ public interface CustomMeasureMapper {
   List<CustomMeasureDto> selectByComponentId(long id);
 
   void delete(long id);
+
+  int countByComponentIdAndMetricId(@Param("componentUuid") String componentUuid, @Param("metricId") int metricId);
 }
index a3c935db3dc7f04514b3ede1367de8e05ff416d0..a3465ed2b90db15d315c295808d95f036a470307 100644 (file)
     delete from manual_measures
     where id=#{id}
   </delete>
+
+  <select id="countByComponentIdAndMetricId" resultType="Integer">
+    select count(*)
+    from manual_measures m
+    where m.metric_id=#{metricId} and m.component_uuid=#{componentUuid}
+  </select>
 </mapper>
index 3c36eb567763eddb300de084ff6d797bf48443c7..ea25e50f3693f5e28be23da2a4b39d8578f3e2e5 100644 (file)
@@ -2825,6 +2825,8 @@ errors.type.notBoolean=Value '{0}' must be one of "true" or "false".
 errors.type.notInteger=Value '{0}' must be an integer.
 errors.type.notFloat=Value '{0}' must be an floating point number.
 errors.type.notInOptions=Value '{0}' must be one of : {1}.
+errors.type.notMetricLevel=Value '{0}' must be one of "OK", "WARN", "ERROR".
+errors.type.notMetricWorkDuration=Value '{0}' is not well formatted.
 
 #------------------------------------------------------------------------------
 #
index 292a298c7e29f2c93ecfee4aee55fe40807066a9..06c2e867c3a40c35bc720755f669b8512f102c5f 100644 (file)
@@ -83,8 +83,18 @@ public enum PropertyType {
   PROPERTY_SET,
 
   /**
-   * User login
-   * @since 5.1
+  * User login
+  * @since 5.1
+  */
+  USER_LOGIN,
+
+  /**
+   * Level metric type
+   */
+  METRIC_LEVEL,
+
+  /**
+   * Work duration metric type
    */
-  USER_LOGIN
+  METRIC_WORK_DURATION
 }
index 987be1d33e40d323e066fd74137326014b2f639e..b02bc030b8e9ccd73a831ae4af9eadba206354e0 100644 (file)
  */
 package org.sonar.api.measures;
 
+import com.google.common.base.Function;
+import com.google.common.collect.Lists;
 import java.io.Serializable;
+import java.util.Arrays;
+import java.util.List;
 import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.persistence.Column;
 import javax.persistence.Entity;
@@ -124,6 +129,16 @@ public class Metric<G extends Serializable> implements Serializable, org.sonar.a
     public String getColorName() {
       return colorName;
     }
+
+    public static List<String> names() {
+      return Lists.transform(Arrays.asList(values()), new Function<Level, String>() {
+        @Nonnull
+        @Override
+        public String apply(@Nonnull Level level) {
+          return level.name();
+        }
+      });
+    }
   }
 
   @Id
index 4f88279c6d5c0720991f17f1496a271cccfea24e..bfaa830b94a184b24f33508f68e1bacdc13670a2 100644 (file)
@@ -63,7 +63,8 @@ public class Durations {
   /**
    * Convert the text to a Duration
    * <br>
-   * Example : decode("9d 10 h") -> Duration.encode("10d2h") (if sonar.technicalDebt.hoursInDay property is set to 8)
+   * Example : decode("9d 10 h") -> Duration.encode("10d2h") (if sonar.technicalDebt.hoursInDay property is set to 8)<br />
+   * Throws {@code IllegalArgumentException}
    */
   public Duration decode(String duration) {
     return Duration.decode(duration, hoursInDay());