From 6a9e8b555db82647e050de79339e9367030be542 Mon Sep 17 00:00:00 2001 From: Tristan Garwood Date: Fri, 21 Apr 2023 10:42:54 -0400 Subject: [PATCH] Initial pact commit. --- pact4s/README.md | 51 +++++ .../leonardo/consumer/DrsHubClient.scala | 38 ++++ .../workbench/leonardo/consumer/Helper.scala | 200 ++++++++++++++++++ pact4s/src/test/resources/logback.xml | 14 ++ .../leonardo/consumer/DrsHubClientSpec.scala | 106 ++++++++++ project/Dependencies.scala | 26 ++- project/Settings.scala | 16 ++ 7 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 pact4s/README.md create mode 100644 pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClient.scala create mode 100644 pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/Helper.scala create mode 100644 pact4s/src/test/resources/logback.xml create mode 100644 pact4s/src/test/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClientSpec.scala diff --git a/pact4s/README.md b/pact4s/README.md new file mode 100644 index 00000000000..002cef39a64 --- /dev/null +++ b/pact4s/README.md @@ -0,0 +1,51 @@ +c# pact4s [Under construction] + +pact4s is used for contract testing. + +# Dependencies + +```scala + val pact4sDependencies = Seq( + pact4sScalaTest, + pact4sCirce, + http4sEmberClient, + http4sDsl, + http4sEmberServer, + http4sCirce, + circeCore, + typelevelCat, + scalaTest + ) + +lazy val pact4s = project.in(file("pact4s")) + .settings(pact4sSettings) + .dependsOn(http % "test->test;compile->compile") +``` + +## Building and running contract tests +Clone the repo. +``` +$ git clone https://github.com/DataBiosphere/cromwell.git +$ cd cromwell +``` + +If you are already using OpenJDK 17, run the following command. +``` +$ sbt "project pact4s" clean test +``` + +Otherwise, you can run the command inside a docker container with OpenJDK 17 installed. +This is especially useful when automating contract tests in a GitHub Action runner which does not guarantee the correct OpenJDK version. +``` +docker run --rm -v $PWD:/working \ + -v jar-cache:/root/.ivy \ + -v jar-cache:/root/.ivy2 \ + -w /working \ + sbtscala/scala-sbt:openjdk-17.0.2_1.7.2_2.13.10 \ + sbt "project pact4s" clean test +``` + +The generated contracts can be found in the `./target/pacts` folder +- `cromwell-consumer-drshub-provider.json` +- `cromwell-consumer-fake-provider.json` + diff --git a/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClient.scala b/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClient.scala new file mode 100644 index 00000000000..7148eb5a141 --- /dev/null +++ b/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClient.scala @@ -0,0 +1,38 @@ +package org.broadinstitute.dede.workbench.leonardo.consumer + +import cats.effect.kernel.Concurrent +import cats.syntax.all._ +import io.circe.Decoder +import org.broadinstitute.dsde.workbench.leonardo.DrsHubResourceType +import org.broadinstitute.dsde.workbench.leonardo.dao.HttpDrsHubDAO._ +import org.broadinstitute.dsde.workbench.leonardo.dao.ListResourceResponse +import org.broadinstitute.dsde.workbench.util.health.StatusCheckResponse +import org.http4s.Credentials.Token +import org.http4s._ +import org.http4s.circe.CirceEntityCodec.circeEntityDecoder +import org.http4s.client.Client +import org.http4s.headers.Authorization + +trait DrsHubClient[F[_]] { + def fetchSystemStatus(): F[StatusCheckResponse] +} + +/* + This class represents the consumer (Cromwell) view of the DrsHub provider that implements the following endpoints: + - GET /status + */ +class DrsHubClientImpl[F[_]: Concurrent](client: Client[F], baseUrl: Uri, bearer: Token) extends DrsHubClient[F] { + + override def fetchSystemStatus(): F[StatusCheckResponse] = { + val request = Request[F](uri = baseUrl / "status").withHeaders( + org.http4s.headers.Accept(MediaType.application.json) + ) + client.run(request).use { resp => + resp.status match { + case Status.Ok => resp.as[StatusCheckResponse] + case Status.InternalServerError => resp.as[StatusCheckResponse] + case _ => UnknownError.raiseError + } + } + } +} diff --git a/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/Helper.scala b/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/Helper.scala new file mode 100644 index 00000000000..54e6279243f --- /dev/null +++ b/pact4s/src/main/scala/org/broadinstitute/dede/workbench/leonardo/consumer/Helper.scala @@ -0,0 +1,200 @@ +package org.broadinstitute.dede.workbench.leonardo.consumer + +import au.com.dius.pact.consumer.dsl.{DslPart, PactDslResponse, PactDslWithProvider} +import org.broadinstitute.dsde.workbench.model.WorkbenchEmail +import org.http4s.Credentials.Token +import org.http4s.{AuthScheme, Credentials} +import pact4s.algebras.PactBodyJsonEncoder + +case object InvalidCredentials extends Exception + +case object UserAlreadyExists extends Exception + +case object UnknownError extends Exception + +object AuthHelper { + def mockBearerHeader(workbenchEmail: WorkbenchEmail) = s"Bearer TokenFor$workbenchEmail" + def mockAuthToken(workbenchEmail: WorkbenchEmail): Token = + Credentials.Token(AuthScheme.Bearer, s"TokenFor$workbenchEmail") +} + +object PactHelper { + def buildInteraction(builder: PactDslResponse, + state: String, + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int, + responseHeaders: Seq[(String, String)], + body: DslPart + ): PactDslResponse = + builder + .`given`(state) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(body) + + def buildInteraction(builder: PactDslResponse, + state: String, + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int + ): PactDslResponse = + builder + .`given`(state) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + + def buildInteraction(builder: PactDslResponse, + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int + ): PactDslResponse = + builder + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + + def buildInteraction(builder: PactDslWithProvider, + state: String, + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int, + responseHeaders: Seq[(String, String)], + body: DslPart + ): PactDslResponse = + builder + .`given`(state) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(body) + + def buildInteraction(builder: PactDslWithProvider, + state: String, + stateParams: Map[String, Any], + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int, + responseHeaders: Seq[(String, String)], + body: DslPart + ): PactDslResponse = + builder + .`given`(state, scala.jdk.CollectionConverters.MapHasAsJava(stateParams).asJava) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(body) + + def buildInteraction(builder: PactDslResponse, + state: String, + stateParams: Map[String, Any], + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int, + responseHeaders: Seq[(String, String)], + body: DslPart + ): PactDslResponse = + builder + .`given`(state, scala.jdk.CollectionConverters.MapHasAsJava(stateParams).asJava) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(body) + + def buildInteraction[A](builder: PactDslWithProvider, + state: String, + stateParams: Map[String, Any], + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + status: Int, + responseHeaders: Seq[(String, String)], + body: A + )(implicit ev: PactBodyJsonEncoder[A]): PactDslResponse = + builder + .`given`(state, scala.jdk.CollectionConverters.MapHasAsJava(stateParams).asJava) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(ev.toJsonString(body)) + + def buildInteraction[A](builder: PactDslResponse, + state: String, + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + requestBody: A, + status: Int + )(implicit ev: PactBodyJsonEncoder[A]): PactDslResponse = + builder + .`given`(state) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .body(ev.toJsonString(requestBody)) + .willRespondWith() + .status(status) + + def buildInteraction[A](builder: PactDslResponse, + state: String, + stateParams: Map[String, Any], + uponReceiving: String, + method: String, + path: String, + requestHeaders: Seq[(String, String)], + requestBody: A, + status: Int + )(implicit ev: PactBodyJsonEncoder[A]): PactDslResponse = + builder + .`given`(state, scala.jdk.CollectionConverters.MapHasAsJava(stateParams).asJava) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .body(ev.toJsonString(requestBody)) + .willRespondWith() + .status(status) +} diff --git a/pact4s/src/test/resources/logback.xml b/pact4s/src/test/resources/logback.xml new file mode 100644 index 00000000000..24316805e7e --- /dev/null +++ b/pact4s/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d [%thread] %-5level %logger{35} - %msg%n + + + + + + + + + + diff --git a/pact4s/src/test/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClientSpec.scala b/pact4s/src/test/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClientSpec.scala new file mode 100644 index 00000000000..a27d63df63a --- /dev/null +++ b/pact4s/src/test/scala/org/broadinstitute/dede/workbench/leonardo/consumer/DrsHubClientSpec.scala @@ -0,0 +1,106 @@ +package org.broadinstitute.dede.workbench.leonardo.consumer + +import au.com.dius.pact.consumer.dsl.LambdaDsl.{newJsonArray, newJsonBody} +import au.com.dius.pact.consumer.dsl._ +import au.com.dius.pact.consumer.{ConsumerPactBuilder, PactTestExecutionContext} +import au.com.dius.pact.core.model.RequestResponsePact +import cats.effect.IO +import cats.effect.unsafe.implicits._ +import io.circe.parser._ +import org.broadinstitute.dede.workbench.leonardo.consumer.AuthHelper._ +import org.broadinstitute.dede.workbench.leonardo.consumer.PactHelper._ +import org.broadinstitute.dsde.workbench.leonardo.JsonCodec._ +import org.broadinstitute.dsde.workbench.leonardo.DrsHubResourceId.WorkspaceResourceDrsHubResourceId +import org.broadinstitute.dsde.workbench.leonardo.dao.HttpDrsHubDAO._ +import org.broadinstitute.dsde.workbench.leonardo.dao.{ListResourceResponse, MockDrsHubDAO} +import org.broadinstitute.dsde.workbench.leonardo.{DrsHubPolicyName, DrsHubResourceType, WorkspaceId} +import org.broadinstitute.dsde.workbench.util.health.Subsystems._ +import org.broadinstitute.dsde.workbench.util.health.{StatusCheckResponse, SubsystemStatus} +import org.http4s.Uri +import org.http4s.blaze.client.BlazeClientBuilder +import org.http4s.client.Client +import org.http4s.headers.Authorization +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import pact4s.scalatest.RequestResponsePactForger + +import java.util.UUID +import scala.concurrent.ExecutionContext + +class DrsHubClientSpec extends AnyFlatSpec with Matchers with RequestResponsePactForger { + /* + Define the folder that the pact contracts get written to upon completion of this test suite. + */ + override val pactTestExecutionContext: PactTestExecutionContext = + new PactTestExecutionContext( + "./target/pacts" + ) + + // Uncomment this so that mock server will run on specific port (e.g. 9003) instead of dynamically generated port. + // override val mockProviderConfig: MockProviderConfig = MockProviderConfig.httpConfig("localhost", 9003) + + // These fixtures are used for assertions in scala tests + // The subsystems are taken from HttpDrsHubDAOSpec "get DrsHub ok status" + val subsystems = List(GoogleGroups, GooglePubSub, GoogleIam, OpenDJ) + val okSystemStatus: StatusCheckResponse = StatusCheckResponse( + ok = true, + systems = subsystems.map(s => (s, SubsystemStatus(ok = true, messages = None))).toMap + ) + + + // --- End of fixtures section + + // ---- Dsl for specifying pacts between consumer and provider + // Lambda Dsl: required for generating matching rules. + // Favored over old-style Pact Dsl using PactDslJsonBody. + // This rule expects DrsHub to respond with + // 1. ok status + // 2. ok statuses matching the given subsystem states + val okSystemStatusDsl: DslPart = newJsonBody { o => + o.booleanType("ok", true) + o.`object`("systems", + s => + for (subsystem <- subsystems) + s.`object`(subsystem.value, o => o.booleanType("ok", true)) + ) + }.build() + + val consumerPactBuilder: ConsumerPactBuilder = ConsumerPactBuilder + .consumer("cromwell-consumer") + + val pactProvider: PactDslWithProvider = consumerPactBuilder + .hasPactWith("drshub-provider") + + // stateParams provides the desired subsystem states + // for DrsHub provider to generate the expected response + var pactDslResponse: PactDslResponse = buildInteraction( + pactProvider, + state = "DrsHub is ok", + stateParams = subsystems.map(s => s.toString() -> "ok").toMap, + uponReceiving = "Request to get DrsHub ok status", + method = "GET", + path = "/status", + requestHeaders = Seq("Accept" -> "application/json"), + status = 200, + responseHeaders = Seq("Content-type" -> "application/json"), + okSystemStatusDsl + ) + + + override val pact: RequestResponsePact = pactDslResponse.toPact + + // val client: Client[IO] = EmberClientBuilder.default[IO].build.allocated.unsafeRunSync()._1 + + val client: Client[IO] = + BlazeClientBuilder[IO](ExecutionContext.global).resource.allocated.unsafeRunSync()._1 + + /* + we should use these tests to ensure that our client class correctly handles responses from the provider - i.e. decoding, error mapping, validation + */ + it should "get DrsHub ok status" in { + new DrsHubClientImpl[IO](client, Uri.unsafeFromString(mockServer.getUrl), mockAuthToken(MockDrsHubDAO.petSA)) + .fetchSystemStatus() + .attempt + .unsafeRunSync() shouldBe Right(okSystemStatus) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c98eba6d346..852e8399215 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -90,6 +90,7 @@ object Dependencies { private val mysqlV = "8.0.28" private val nettyV = "4.1.72.Final" private val owlApiV = "5.1.19" + private val pact4sV = "0.9.0" private val postgresV = "42.4.1" private val pprintV = "0.7.3" private val rdf4jV = "3.7.1" @@ -591,14 +592,14 @@ object Dependencies { val sfsBackendDependencies = List ( "org.lz4" % "lz4-java" % lz4JavaV ) - + val scalaTest = "org.scalatest" %% "scalatest" % scalatestV val testDependencies: List[ModuleID] = List( - "org.scalatest" %% "scalatest" % scalatestV, + scalaTest, // Use mockito Java DSL directly instead of the numerous and often hard to keep updated Scala DSLs. // See also scaladoc in common.mock.MockSugar and that trait's various usages. "org.mockito" % "mockito-core" % mockitoV, - "io.github.jbwheatley" %% "pact4s-scalatest" % "0.7.0", - "io.github.jbwheatley" %% "pact4s-circe" % "0.7.0" + "io.github.jbwheatley" %% "pact4s-scalatest" % pact4sV, + "io.github.jbwheatley" %% "pact4s-circe" % pact4sV, ) ++ slf4jBindingDependencies // During testing, add an slf4j binding for _all_ libraries. val kindProjectorPlugin = "org.typelevel" % "kind-projector" % kindProjectorV cross CrossVersion.full @@ -795,4 +796,21 @@ object Dependencies { */ ExclusionRule("javax.annotation", "javax.annotation-api"), ) + + val http4sDsl = "org.http4s" %% "http4s-dsl" % http4sV + val http4sEmberClient = "org.http4s" %% "http4s-ember-client" % http4sV + val http4sEmberServer = "org.http4s" %% "http4s-ember-server" % http4sV + val http4sCirce = "org.http4s" %% "http4s-circe" % http4sV + val pact4sScalaTest = "io.github.jbwheatley" %% "pact4s-scalatest" % pact4sV % Test + val pact4sCirce = "io.github.jbwheatley" %% "pact4s-circe" % pact4sV + + val pact4sDependencies = Seq( + pact4sScalaTest, + pact4sCirce, + http4sEmberClient, + http4sDsl, + http4sEmberServer, + http4sCirce, + scalaTest + ) } diff --git a/project/Settings.scala b/project/Settings.scala index 797d7917dd9..32559422066 100644 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -98,6 +98,22 @@ object Settings { ) ) + val pact4sSettings = sharedSettings ++ List( + libraryDependencies ++= pact4sDependencies, + + /** + * Invoking pact tests from root project (sbt "project pact" test) + * will launch tests in a separate JVM context that ensures contracts + * are written to the pact/target/pacts folder. Otherwise, contracts + * will be written to the root folder. + */ + Test / fork := true + + ) ++ assemblySettings + + lazy val pact4s = project.in(file("pact4s")) + .settings(pact4sSettings) + /* Docker instructions to install Google Cloud SDK image in docker image. It also installs `crcmod` which is needed while downloading large files using `gsutil`.