Receive ETL configuration parameter

Modify 'PUT analytics-wizard~stack' to receive the ETL configuration
paramenters.

Feature: Issue 9868
Change-Id: I57790c40937de8d2f485e887c18185f9873c3634
diff --git a/build.sbt b/build.sbt
index f382090..50f2883 100644
--- a/build.sbt
+++ b/build.sbt
@@ -3,6 +3,8 @@
 val gerritApiVersion = "2.16-rc0"
 val pluginName       = "analytics-wizard"
 
+scalaVersion := "2.11.12"
+
 git.useGitDescribe := true
 
 scalafmtOnCompile in ThisBuild := true
@@ -16,6 +18,7 @@
       "com.google.inject" % "guice"             % "3.0" % Provided,
       "com.google.gerrit" % "gerrit-plugin-api" % gerritApiVersion % Provided withSources (),
       "com.spotify"       % "docker-client"     % "8.14.1",
+      "com.beachape"      %% "enumeratum"       % "1.5.13",
       "org.scalatest"     %% "scalatest"        % "3.0.4" % Test,
       "net.codingwell"    %% "scala-guice"      % "4.1.0" % Test
     ),
diff --git a/src/main/resources/static/js/analyticswizard.js b/src/main/resources/static/js/analyticswizard.js
index 7bbb2cf..565a7d7 100644
--- a/src/main/resources/static/js/analyticswizard.js
+++ b/src/main/resources/static/js/analyticswizard.js
@@ -49,12 +49,18 @@
 
 function submitDetailsForm() {
     var projectName = encodeURIComponent($("#input-project-name").val());
+    var requestBody = {
+      dashboard_name: projectName,
+      etl_config: {
+        aggregate: "email"
+      }
+    }
     $.ajax({
       type : "PUT",
       url : `/a/projects/${projectName}/analytics-wizard~stack`,
       dataType: 'application/json',
       // Initially project-dashboard is a 1 to 1 relationship
-      data: "{'dashboard_name': '" + projectName + "'}",
+      data: JSON.stringify(requestBody),
       contentType:"application/json; charset=utf-8",
       // Need to catch the status code since Gerrit doesn't return
       // a well formed JSON, hence Ajax treats it as an error
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/AnalyticsWizardActions.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/AnalyticsWizardActions.scala
index e424681..3eba31c 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/AnalyticsWizardActions.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/AnalyticsWizardActions.scala
@@ -12,11 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 package com.googlesource.gerrit.plugins.analytics.wizard
-
 import java.net.URLEncoder
 import java.nio.charset.StandardCharsets.UTF_8
 import java.nio.file.Path
-
 import com.google.common.io.ByteStreams
 import com.google.gerrit.extensions.annotations.PluginData
 import com.google.gerrit.extensions.restapi.{
@@ -29,52 +27,54 @@
 import com.google.gerrit.server.project.ProjectResource
 import com.google.inject.{ImplementedBy, Inject}
 import com.googlesource.gerrit.plugins.analytics.wizard.AnalyticDashboardSetup.writer
+import com.googlesource.gerrit.plugins.analytics.wizard.model.{
+  ETLConfig,
+  ETLConfigRaw,
+  ETLConfigValidationError
+}
 import com.googlesource.gerrit.plugins.analytics.wizard.utils._
 import com.spotify.docker.client.messages.ContainerInfo
 import com.spotify.docker.client.{DefaultDockerClient, DockerClient}
 import org.eclipse.jgit.lib.Config
-
 import scala.util.{Failure, Success}
-
-class Input(var dashboardName: String)
-
+case class Input(dashboardName: String, etlConfig: ETLConfigRaw)
 class PutAnalyticsStack @Inject()(@PluginData val dataPath: Path,
                                   @GerritServerConfig gerritConfig: Config)
     extends RestModifyView[ProjectResource, Input] {
-
   override def apply(resource: ProjectResource, input: Input): Response[String] = {
-
-    val projectName = resource.getName
-    val encodedName = AnalyticsWizardActions.encodedName(projectName)
-
-    val configHelper = new GerritConfigHelper(gerritConfig) with LocalAddressGetter
-
-    configHelper.getGerritLocalAddress match {
-      case Success(gerritLocalUrl) =>
-        AnalyticDashboardSetup(
-          projectName,
-          dataPath.resolve(s"docker-compose.$encodedName.yaml"),
-          gerritLocalUrl
-        ).createDashboardSetupFile()
-
-        Response.created(s"Dashboard configuration created for $encodedName!")
-      case Failure(exception) =>
-        Response.withStatusCode(
-          500,
-          s"Cannot create dashboard configuration - '${exception.getMessage}'")
-    }
+    val projectName                                             = resource.getName
+    val encodedName                                             = AnalyticsWizardActions.encodedName(projectName)
+    val etlConfigE: Either[ETLConfigValidationError, ETLConfig] = ETLConfig.fromRaw(input.etlConfig)
+    etlConfigE.fold(
+      configError =>
+        Response.withStatusCode(400,
+                                s"Cannot create dashboard configuration: ${configError.message}"),
+      etlConfig => {
+        val configHelper = new GerritConfigHelper(gerritConfig) with LocalAddressGetter
+        configHelper.getGerritLocalAddress match {
+          case Success(gerritLocalUrl) =>
+            AnalyticDashboardSetup(
+              projectName,
+              dataPath.resolve(s"docker-compose.$encodedName.yaml"),
+              gerritLocalUrl
+            ).createDashboardSetupFile()
+            Response.created(s"Dashboard configuration created for $encodedName!")
+          case Failure(exception) =>
+            Response.withStatusCode(
+              500,
+              s"Cannot create dashboard configuration - '${exception.getMessage}'")
+        }
+      }
+    )
   }
 }
-
 class DockerComposeCommand(var action: String)
 class PostAnalyticsStack @Inject()(@PluginData val dataPath: Path)
     extends RestModifyView[ProjectResource, DockerComposeCommand] {
   override def apply(resource: ProjectResource, input: DockerComposeCommand): Response[String] = {
-
     val projectName = resource.getName
     val encodedName = AnalyticsWizardActions
       .encodedName(projectName)
-
     val pb = new ProcessBuilder(
       "docker-compose",
       "-f",
@@ -83,13 +83,11 @@
       "--detach"
     )
     pb.redirectErrorStream(true)
-
     val ps: Process = pb.start
     ps.getOutputStream.close
     val output =
       new String(ByteStreams.toByteArray(ps.getInputStream), UTF_8)
     ps.waitFor
-
     ps.exitValue match {
       case 0 => Response.created(output)
       case _ =>
@@ -97,7 +95,6 @@
     }
   }
 }
-
 class GetAnalyticsStackStatus @Inject()(@PluginData val dataPath: Path,
                                         val dockerClientProvider: DockerClientProvider)
     extends RestReadView[ProjectResource] {
@@ -105,7 +102,6 @@
     val containerName = "analytics-wizard_spark-gerrit-analytics-etl_1"
     responseFromContainerInfo(dockerClientProvider.client.inspectContainer(containerName))
   }
-
   private def responseFromContainerInfo(containerInfo: ContainerInfo) = {
     containerInfo.state match {
       case s if s.exitCode != 0 =>
@@ -119,7 +115,6 @@
     }
   }
 }
-
 object AnalyticsWizardActions {
   // URLEncoder could potentially throw UnsupportedEncodingException,
   // but UTF-8 will *always* be resolved, otherwise, Gerrit wouldn't work at all
@@ -130,12 +125,10 @@
       case e: Throwable => throw new RuntimeException(e)
     }
 }
-
 @ImplementedBy(classOf[DockerClientProviderImpl])
 trait DockerClientProvider {
   def client: DockerClient
 }
-
 class DockerClientProviderImpl extends DockerClientProvider {
   def client: DockerClient = DefaultDockerClient.fromEnv.build
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfig.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfig.scala
new file mode 100644
index 0000000..11a1ab2
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfig.scala
@@ -0,0 +1,114 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.analytics.wizard.model
+
+import java.net.URL
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+import com.googlesource.gerrit.plugins.analytics.wizard.model.AggregationType.Email
+import enumeratum._
+
+import scala.util.{Failure, Success, Try}
+
+case class ETLConfig(aggregate: AggregationType,
+                     projectPrefix: Option[String],
+                     since: Option[LocalDate],
+                     until: Option[LocalDate],
+                     eventsUrl: Option[URL],
+                     writeNotProcessedEventsTo: Option[URL],
+                     emailAliasesPath: Option[String],
+                     username: Option[String],
+                     password: Option[String])
+
+sealed trait AggregationType extends EnumEntry
+object AggregationType extends Enum[AggregationType] {
+  val values = findValues
+
+  case object Email      extends AggregationType
+  case object EmailHour  extends AggregationType
+  case object EmailDay   extends AggregationType
+  case object EmailMonth extends AggregationType
+  case object EmailYear  extends AggregationType
+}
+
+object ETLConfig {
+  def fromRaw(raw: ETLConfigRaw): Either[ETLConfigValidationError, ETLConfig] = {
+    for {
+      s  <- validateLocalDate("since", raw.since).right
+      u  <- validateLocalDate("until", raw.until).right
+      w  <- validateUrl("writeNotProcessedEventsTo", raw.writeNotProcessedEventsTo).right
+      eu <- validateUrl("eventsUrl", raw.eventsUrl).right
+      a  <- validateAggregate(raw.aggregate).right
+    } yield
+      ETLConfig(
+        aggregate = a,
+        projectPrefix = Option(raw.projectPrefix),
+        since = s,
+        until = u,
+        eventsUrl = eu,
+        writeNotProcessedEventsTo = w,
+        emailAliasesPath = Option(raw.emailAliasesPath),
+        username = Option(raw.username),
+        password = Option(raw.password)
+      )
+  }
+
+  private def validateLocalDate(
+      parameter: String,
+      value: String): Either[LocalDateValidationError, Option[LocalDate]] = {
+    val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
+
+    Option(value)
+      .map { maybeDate =>
+        Try(LocalDate.parse(maybeDate, formatter)) match {
+          case Success(date)      => Right(Some(date))
+          case Failure(exception) => Left(LocalDateValidationError(parameter, maybeDate, exception))
+        }
+      }
+      .getOrElse(Right(None))
+  }
+  private def validateUrl(parameter: String,
+                          value: String): Either[UrlValidationError, Option[URL]] = {
+    Option(value)
+      .map { u =>
+        Try(new URL(u)) match {
+          case Success(url)       => Right(Some(url))
+          case Failure(exception) => Left(UrlValidationError(parameter, u, exception))
+        }
+      }
+      .getOrElse(Right(None))
+  }
+  private def validateAggregate(
+      value: String): Either[AggregateValidationError, AggregationType] = {
+    val maybeAggregate =
+      AggregationType.withNameInsensitiveOption(Option(value).getOrElse("email").replace("_", ""))
+    Either.cond(maybeAggregate.isDefined, maybeAggregate.get, AggregateValidationError(value))
+  }
+}
+
+sealed trait ETLConfigValidationError {
+  def message: String = s"Error validating '$parameter' parameter: $value. Exception: $cause"
+  def value: String
+  def parameter: String
+  def cause: Throwable
+}
+case class LocalDateValidationError(parameter: String, value: String, cause: Throwable)
+    extends ETLConfigValidationError
+case class UrlValidationError(parameter: String, value: String, cause: Throwable)
+    extends ETLConfigValidationError
+case class AggregateValidationError(value: String) extends ETLConfigValidationError {
+  val parameter        = "aggregate"
+  val cause: Throwable = new Throwable(s"Value $value is not a valid aggregation type")
+}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigRaw.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigRaw.scala
new file mode 100644
index 0000000..1a16df9
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigRaw.scala
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.analytics.wizard.model
+
+case class ETLConfigRaw(aggregate: String,
+                        projectPrefix: String,
+                        since: String,
+                        until: String,
+                        eventsUrl: String,
+                        emailAliasesPath: String,
+                        writeNotProcessedEventsTo: String,
+                        username: String,
+                        password: String)
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigSpec.scala
new file mode 100644
index 0000000..7f9f0e5
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/wizard/model/ETLConfigSpec.scala
@@ -0,0 +1,150 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.analytics.wizard.model
+
+import java.net.{MalformedURLException, URL}
+import java.time.LocalDate
+import java.time.format.DateTimeParseException
+
+import org.scalatest.EitherValues._
+import org.scalatest.{FlatSpec, Matchers}
+
+class ETLConfigSpec extends FlatSpec with Matchers {
+
+  behavior of "ETLConfig.fromRaw"
+
+  val etlConfigRaw = ETLConfigRaw(
+    aggregate = "email",
+    projectPrefix = "prefix",
+    since = "2018-10-10",
+    until = "2018-10-13",
+    eventsUrl = "file:///tmp/gerrit-events-export.json",
+    emailAliasesPath = "/tmp/path",
+    writeNotProcessedEventsTo = "file://tmp/myfile.json",
+    username = "dss",
+    password = "dsd"
+  )
+
+  it should "pass validation with correct parameters" in {
+
+    ETLConfig.fromRaw(etlConfigRaw).right.value shouldBe ETLConfig(
+      aggregate = AggregationType.Email,
+      projectPrefix = Some(etlConfigRaw.projectPrefix),
+      since = Some(LocalDate.of(2018, 10, 10)),
+      until = Some(LocalDate.of(2018, 10, 13)),
+      eventsUrl = Some(new URL(etlConfigRaw.eventsUrl)),
+      writeNotProcessedEventsTo = Some(new URL(etlConfigRaw.writeNotProcessedEventsTo)),
+      emailAliasesPath = Some(etlConfigRaw.emailAliasesPath),
+      username = Some(etlConfigRaw.username),
+      password = Some(etlConfigRaw.password)
+    )
+  }
+
+  it should "defaults non mandatory parameters" in {
+    ETLConfig
+      .fromRaw(
+        etlConfigRaw.copy(projectPrefix = null,
+                          since = null,
+                          until = null,
+                          eventsUrl = null,
+                          writeNotProcessedEventsTo = null,
+                          emailAliasesPath = null,
+                          username = null,
+                          password = null))
+      .right
+      .value shouldBe ETLConfig(
+      aggregate = AggregationType.Email,
+      projectPrefix = None,
+      since = None,
+      until = None,
+      eventsUrl = None,
+      writeNotProcessedEventsTo = None,
+      emailAliasesPath = None,
+      username = None,
+      password = None
+    )
+  }
+
+  it should "handle `null` mandatory parameters" in {
+    ETLConfig
+      .fromRaw(
+        etlConfigRaw.copy(
+          aggregate = null,
+          projectPrefix = null,
+          since = null,
+          until = null,
+          eventsUrl = null,
+          writeNotProcessedEventsTo = null,
+          emailAliasesPath = null,
+          username = null,
+          password = null
+        ))
+      .right
+      .value shouldBe ETLConfig(
+      aggregate = AggregationType.Email,
+      projectPrefix = None,
+      since = None,
+      until = None,
+      eventsUrl = None,
+      writeNotProcessedEventsTo = None,
+      emailAliasesPath = None,
+      username = None,
+      password = None
+    )
+  }
+
+  it should "fail validation for invalid 'since' parameter" in {
+    val invalidDate = "randomString"
+    val error = ETLConfig
+      .fromRaw(etlConfigRaw.copy(since = invalidDate))
+      .left
+      .value
+    error.value shouldBe invalidDate
+    error.parameter shouldBe "since"
+    error.cause shouldBe a[DateTimeParseException]
+  }
+
+  it should "fail validation for invalid 'until' parameter" in {
+    val invalidDate = "randomString"
+    val error = ETLConfig
+      .fromRaw(etlConfigRaw.copy(until = invalidDate))
+      .left
+      .value
+    error.value shouldBe invalidDate
+    error.parameter shouldBe "until"
+    error.cause shouldBe a[DateTimeParseException]
+  }
+
+  it should "fail validation for invalid 'writeNotProcessedEventsTo' parameter" in {
+    val invalidUrl = "randomString"
+    val error = ETLConfig
+      .fromRaw(etlConfigRaw.copy(writeNotProcessedEventsTo = invalidUrl))
+      .left
+      .value
+    error.value shouldBe invalidUrl
+    error.parameter shouldBe "writeNotProcessedEventsTo"
+    error.cause shouldBe a[MalformedURLException]
+  }
+
+  it should "fail validation for invalid 'eventsPath' parameter" in {
+    val invalidUrl = "not|good.txt"
+    val error = ETLConfig
+      .fromRaw(etlConfigRaw.copy(eventsUrl = invalidUrl))
+      .left
+      .value
+    error.value shouldBe invalidUrl
+    error.parameter shouldBe "eventsUrl"
+    error.cause shouldBe a[MalformedURLException]
+  }
+}