diff --git a/.github/workflows/consumer_contract_tests.yml b/.github/workflows/consumer_contract_tests.yml new file mode 100644 index 00000000000..27889230d1b --- /dev/null +++ b/.github/workflows/consumer_contract_tests.yml @@ -0,0 +1,145 @@ +name: Consumer contract tests +# The purpose of this workflow is to run a suite of Cromwell contract tests against mock service provider(s) using Pact framework. +# +# More details about Contract Testing can be found in our handbook +# +# https://broadworkbench.atlassian.net/wiki/spaces/IRT/pages/2660368406/Getting+Started+with+Pact+Contract+Testing +# +# This workflow involves Cromwell as a consumer, and ANY provider (e.g. Sam) Cromwell consumes. +# Each party owns a set of tests (aka contract tests). +# +# Consumer contract tests (aka consumer tests) runs on a mock provider service and does not require a real provider service. +# Provider contract tests (aka provider verification tests) runs independently of any consumer. +# +# Specifically: +# Cromwell runs consumer tests against mock service. Upon success, publish consumer pacts to +# Pact Broker https://pact-broker.dsp-eng-tools.broadinstitute.org/. +# +# Pact Broker is the source of truth to forge contractual obligations between consumer and provider. +# +# This workflow meets the criteria of Pact Broker *Platinum* as described in https://docs.pact.io/pact_nirvana/step_6. +# The can-i-deploy job has been added to this workflow to support *Platinum* and gate the code for promotion to default branch. +# +# This is how it works. +# +# Consumer makes a change that results in a new pact published to Pact Broker. +# Pact Broker notifies provider(s) of the changed pact and trigger corresponding verification workflows. +# Provider downloads relevant versions of consumer pacts from Pact Broker and kicks off verification tests against the consumer pacts. +# Provider updates Pact Broker with verification status. +# Consumer kicks off can-i-deploy on process to determine if changes can be promoted and used for deployment. +# +# NOTE: The publish-contracts workflow will use the latest commit of the branch that triggers this workflow to publish the unique consumer contract version to Pact Broker. + +on: + pull_request: + branches: + - develop + paths-ignore: + - 'README.md' + push: + branches: + - develop + paths-ignore: + - 'README.md' + merge_group: + branches: + - develop + +jobs: + init-github-context: + runs-on: ubuntu-latest + outputs: + repo-branch: ${{ steps.extract-branch.outputs.repo-branch }} + repo-version: ${{ steps.extract-branch.outputs.repo-version }} + fork: ${{ steps.extract-branch.outputs.fork }} + + steps: + - uses: actions/checkout@v3 + + - name: Obtain branch properties + id: extract-branch + run: | + FORK=false + GITHUB_EVENT_NAME=${{ github.event_name }} + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + GITHUB_REF=${{ github.ref }} + GITHUB_SHA=${{ github.sha }} + elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + FORK=${{ github.event.pull_request.head.repo.fork }} + GITHUB_REF=refs/heads/${{ github.head_ref }} + GITHUB_SHA=${{ github.event.pull_request.head.sha }} + elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then + GITHUB_REF=refs/heads/${{ github.head_ref }} + else + echo "Failed to extract branch information" + exit 1 + fi + echo "repo-branch=${GITHUB_REF/refs\/heads\//""}" >> $GITHUB_OUTPUT + echo "repo-version=${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "fork=${FORK}" >> $GITHUB_OUTPUT + + - name: Is PR triggered by forked repo? + if: ${{ steps.extract-branch.outputs.fork == 'true' }} + run: | + echo "PR was triggered by forked repo" + + - name: Echo repo and branch information + run: | + echo "repo-owner=${{ github.repository_owner }}" + echo "repo-name=${{ github.event.repository.name }}" + echo "repo-branch=${{ steps.extract-branch.outputs.repo-branch }}" + echo "repo-version=${{ steps.extract-branch.outputs.repo-version }}" + + cromwell-consumer-contract-tests: + runs-on: ubuntu-latest + needs: [init-github-context] + outputs: + pact-b64: ${{ steps.encode-pact.outputs.pact-b64 }} + + steps: + - uses: actions/checkout@v3 + - name: Run consumer tests + run: | + 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 + + - name: Output consumer contract as non-breaking base64 string + id: encode-pact + run: | + cd pact4s + NON_BREAKING_B64=$(cat target/pacts/cromwell-consumer-drshub-provider.json | base64 -w 0) + echo "pact-b64=${NON_BREAKING_B64}" >> $GITHUB_OUTPUT + + # Prevent untrusted sources from using PRs to publish contracts + # since access to secrets is not allowed. + publish-contracts: + runs-on: ubuntu-latest + if: ${{ needs.init-github-context.outputs.fork == 'false' }} + needs: [init-github-context, cromwell-consumer-contract-tests] + steps: + - name: Dispatch to terra-github-workflows + uses: broadinstitute/workflow-dispatch@v3 + with: + workflow: .github/workflows/publish-contracts.yaml + repo: broadinstitute/terra-github-workflows + ref: refs/heads/main + token: ${{ secrets.BROADBOT_GITHUB_TOKEN }} # github token for access to kick off a job in the private repo + inputs: '{ "pact-b64": "${{ needs.cromwell-consumer-contract-tests.outputs.pact-b64 }}", "repo-owner": "${{ github.repository_owner }}", "repo-name": "${{ github.event.repository.name }}", "repo-branch": "${{ needs.init-github-context.outputs.repo-branch }}" }' + + can-i-deploy: + runs-on: ubuntu-latest + if: ${{ needs.init-github-context.outputs.fork == 'false' && false}} # Disabling this step for now until there is a webhook setup + needs: [ init-github-context, publish-contracts ] + steps: + - name: Dispatch to terra-github-workflows + uses: broadinstitute/workflow-dispatch@v3 + with: + workflow: .github/workflows/can-i-deploy.yaml + repo: broadinstitute/terra-github-workflows + ref: refs/heads/main + token: ${{ secrets.BROADBOT_GITHUB_TOKEN }} # github token for access to kick off a job in the private repo + inputs: '{ "pacticipant": "cromwell-consumer", "version": "${{ needs.init-github-context.outputs.repo-version }}" }' diff --git a/build.sbt b/build.sbt index 44a67721e42..3754a92305d 100644 --- a/build.sbt +++ b/build.sbt @@ -355,6 +355,11 @@ lazy val `cromwell-drs-localizer` = project .dependsOn(common) .dependsOn(`cloud-nio-impl-drs` % "test->test") +lazy val pact4s = project.in(file("pact4s")) + .settings(pact4sSettings) + .dependsOn(services) + .disablePlugins(sbtassembly.AssemblyPlugin) + lazy val server = project .withExecutableSettings("cromwell", serverDependencies) .dependsOn(engine) @@ -420,4 +425,5 @@ lazy val root = (project in file(".")) .aggregate(wes2cromwell) .aggregate(wom) .aggregate(womtool) + .aggregate(pact4s) .withAggregateSettings() diff --git a/pact4s/README.md b/pact4s/README.md new file mode 100644 index 00000000000..d33447f7356 --- /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/broadinstitute/cromwell.git +$ cd cromwell +``` + +If you are already using OpenJDK 11, run the following command. +``` +$ sbt "project pact4s" clean test +``` + +Otherwise, you can run the command inside a docker container with OpenJDK 11 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-11.0.16_1.8.1_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/dsde/workbench/cromwell/consumer/DrsHubClient.scala b/pact4s/src/main/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/DrsHubClient.scala new file mode 100644 index 00000000000..3fa11515118 --- /dev/null +++ b/pact4s/src/main/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/DrsHubClient.scala @@ -0,0 +1,93 @@ +package org.broadinstitute.dsde.workbench.cromwell.consumer + +import cats.effect.Concurrent +import cats.syntax.all._ +import com.typesafe.scalalogging.LazyLogging +import io.circe.{Decoder, Encoder} +import org.http4s._ +import org.http4s.circe.CirceEntityCodec.{circeEntityDecoder, circeEntityEncoder} +import org.http4s.client.Client + +case class AccessUrl(url: String, headers: List[String]) + +case class ResourceMetadataRequest(url: String, fields: List[String]) + +case class ResourceMetadata(contentType: String, + size: Long, + timeCreated: String, + timeUpdated: String, + bucket: Option[String], + name: Option[String], + gsUri: Option[String], + googleServiceAccount: Option[Map[String, Map[String, String]]], + fileName: Option[String], + accessUrl: Option[AccessUrl], + hashes: Map[String, String], + localizationPath: Option[String], + bondProvider: Option[String] +) +trait DrsHubClient[F[_]] extends LazyLogging { + def fetchSystemStatus(): F[Boolean] + + def resolveDrsObject(drsPath: String, fields: List[String]): F[ResourceMetadata] + +} + +/* + This class represents the consumer (Cromwell) view of the DrsHub provider that implements the following endpoints: + - GET /status + - GET /api/v4/drs/resolve + */ +class DrsHubClientImpl[F[_]: Concurrent](client: Client[F], baseUrl: Uri) extends DrsHubClient[F] { + val apiVersion = "v4" + + implicit val accessUrlDecoder: Decoder[AccessUrl] = Decoder.forProduct2("url", "headers")(AccessUrl.apply) + implicit val resourceMetadataDecoder: Decoder[ResourceMetadata] = Decoder.forProduct13( + "contentType", + "size", + "timeCreated", + "timeUpdated", + "bucket", + "name", + "gsUri", + "googleServiceAccount", + "fileName", + "accessUrl", + "hashes", + "localizationPath", + "bondProvider" + )(ResourceMetadata.apply) + implicit val resourceMetadataRequestEncoder: Encoder[ResourceMetadataRequest] = Encoder.forProduct2("url", "fields")(x => + (x.url, x.fields) + ) + implicit val resourceMetadataRequestEntityEncoder: EntityEncoder[F, ResourceMetadataRequest] = circeEntityEncoder[F, ResourceMetadataRequest] + override def fetchSystemStatus(): F[Boolean] = { + 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 => true.pure[F] + case Status.InternalServerError => false.pure[F] + case _ => UnknownError.raiseError + } + } + } + + override def resolveDrsObject(drsPath: String, fields: List[String]): F[ResourceMetadata] = { + val body = ResourceMetadataRequest(url = drsPath, fields = fields) + val entityBody: EntityBody[F] = EntityEncoder[F, ResourceMetadataRequest].toEntity(body).body + val request = Request[F](uri = baseUrl / "api" / apiVersion / "drs" / "resolve", method=Method.POST, body=entityBody).withHeaders( + org.http4s.headers.`Content-Type`(MediaType.application.json) + ) + client.run(request).use { resp => + resp.status match { + case Status.Ok => resp.as[ResourceMetadata] + case _ => UnknownError.raiseError + } + } + } + +} + + diff --git a/pact4s/src/main/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/Helper.scala b/pact4s/src/main/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/Helper.scala new file mode 100644 index 00000000000..ceeefc7479f --- /dev/null +++ b/pact4s/src/main/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/Helper.scala @@ -0,0 +1,207 @@ +package org.broadinstitute.dsde.workbench.cromwell.consumer + +import au.com.dius.pact.consumer.dsl.{DslPart, PactDslResponse, PactDslWithProvider} +import pact4s.algebras.PactBodyJsonEncoder +case object UnknownError extends Exception + +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, + responseHeaders: Seq[(String, String)], + ): 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) + + 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)], + requestBody: DslPart, + status: Int, + responseHeaders: Seq[(String, String)], + responsBody: DslPart + ): PactDslResponse = + builder + .`given`(state, scala.jdk.CollectionConverters.MapHasAsJava(stateParams).asJava) + .uponReceiving(uponReceiving) + .method(method) + .path(path) + .body(requestBody) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(requestHeaders.toMap).asJava) + .willRespondWith() + .status(status) + .headers(scala.jdk.CollectionConverters.MapHasAsJava(responseHeaders.toMap).asJava) + .body(responsBody) + + 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/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemContractSpec.scala b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/BlobFileSystemContractSpec.scala similarity index 97% rename from filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemContractSpec.scala rename to pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/BlobFileSystemContractSpec.scala index 611a50f5603..f023257fef9 100644 --- a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemContractSpec.scala +++ b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/BlobFileSystemContractSpec.scala @@ -1,4 +1,4 @@ -package cromwell.filesystems.blob +package org.broadinstitute.dsde.workbench.cromwell.consumer import au.com.dius.pact.consumer.{ConsumerPactBuilder, PactTestExecutionContext} import au.com.dius.pact.core.model.RequestResponsePact diff --git a/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/DrsHubClientSpec.scala b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/DrsHubClientSpec.scala new file mode 100644 index 00000000000..a4ebe56d76a --- /dev/null +++ b/pact4s/src/test/scala/org/broadinstitute/dsde/workbench/cromwell/consumer/DrsHubClientSpec.scala @@ -0,0 +1,170 @@ +package org.broadinstitute.dsde.workbench.cromwell.consumer + +import au.com.dius.pact.consumer.dsl.LambdaDsl.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 org.broadinstitute.dsde.workbench.cromwell.consumer.PactHelper._ +import org.http4s.Uri +import org.http4s.blaze.client.BlazeClientBuilder +import org.http4s.client.Client +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import pact4s.scalatest.RequestResponsePactForger + +import java.util.concurrent.Executors +import scala.concurrent.ExecutionContext + +class DrsHubClientSpec extends AnyFlatSpec with Matchers with RequestResponsePactForger { + val ec = ExecutionContext.fromExecutorService(Executors.newCachedThreadPool()) + implicit val cs = IO.contextShift(ec) + /* + Define the folder that the pact contracts get written to upon completion of this test suite. + */ + override val pactTestExecutionContext: PactTestExecutionContext = + new PactTestExecutionContext( + "./target/pacts" + ) + + private val requestFields = List( + "bucket", + "accessUrl", + "googleServiceAccount", + "fileName", + "hashes", + "localizationPath", + "bondProvider", + "name", + "size", + "timeCreated", + "timeUpdated", + "gsUri", + "contentType", + ) + + val filesize = 123L + val timeCreated = "2021-03-04T20:00:00.000Z" + val bucket = "fc-secure-1234567890" + val filename = "my-file.bam" + val bondProvider = "anvil" + val fileHash = "a2317edbd2eb6cf6b0ee49cb81e3a556" + val accessUrl = f"gs://${bucket}/${filename}" + + + val drsResourceResponsePlaceholder: ResourceMetadata = ResourceMetadata( + "application/octet-stream", + filesize, + timeCreated, + timeCreated, + None, + None, + None, + None, + None, + Option(AccessUrl(accessUrl, List("Header", "Example"))), + Map("md5" -> fileHash), + None, + Option(bondProvider) + ) + + val resourceMetadataResponseDsl: DslPart = newJsonBody { o => + o.stringType("contentType", "application/octet-stream") + o.numberType("size", filesize) + o.stringType("timeCreated", timeCreated) + o.stringType("timeUpdated", timeCreated) + o.nullValue("gsUri") + o.nullValue("googleServiceAccount") + o.nullValue("fileName") + o.`object`("accessUrl" , { a => + a.stringType("url", accessUrl) + a.`array`("headers", { h => + h.stringType("Header") + h.stringType("Example") + () + }) + () + }) + o.`object`("hashes", { o => + o.stringType("md5", fileHash) + () + }) + o.nullValue("localizationPath") + o.stringType("bondProvider", bondProvider) + () + }.build + + val fileId = "1234567890" + + val resourceRequestDsl = newJsonBody { o => + o.stringType("url", f"drs://test.theanvil.io/${fileId}") + o.array("fields", { a => + requestFields.map(a.stringType) + () + }) + () + }.build + + val consumerPactBuilder: ConsumerPactBuilder = ConsumerPactBuilder + .consumer("cromwell-consumer") + + val pactProvider: PactDslWithProvider = consumerPactBuilder + .hasPactWith("drshub-provider") + + var pactDslResponse: PactDslResponse = buildInteraction( + pactProvider, + state = "resolve Drs url", + stateParams = Map[String, String]( + "fileId" -> fileId, + "bucket" -> bucket, + "filename" -> filename, + "bondProvider" -> bondProvider, + "fileHash" -> fileHash, + "accessUrl" -> accessUrl, + "fileSize" -> filesize.toString, + "timeCreated" -> timeCreated + ), + uponReceiving = "Request to resolve drs url", + method = "POST", + path = "/api/v4/drs/resolve", + requestHeaders = Seq("Content-type" -> "application/json"), + requestBody = resourceRequestDsl, + status = 200, + responseHeaders = Seq(), + responsBody = resourceMetadataResponseDsl + ) + + pactDslResponse = buildInteraction( + pactDslResponse, + state = "Drshub is ok", + uponReceiving = "Request for drshub api status", + method = "GET", + path = "/status", + requestHeaders = Seq(), + status = 200, + responseHeaders = Seq() + ) + + override val pact: RequestResponsePact = pactDslResponse.toPact + + 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)) + .fetchSystemStatus() + .attempt + .unsafeRunSync() shouldBe Right(true) + } + + it should "resolve drs object" in { + new DrsHubClientImpl[IO](client, Uri.unsafeFromString(mockServer.getUrl)) + .resolveDrsObject("drs://drs.example.com/1234567890", requestFields) + .attempt + .unsafeRunSync() shouldBe Right(drsResourceResponsePlaceholder) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 405ecd2d3c4..ef829d6f780 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -91,6 +91,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" @@ -103,7 +104,7 @@ object Dependencies { private val scalaPoolV = "0.4.3" private val scalacticV = "3.2.13" private val scalameterV = "0.21" - private val scalatestV = "3.2.10" + private val scalatestV = "3.2.15" private val scalatestScalacheckV = scalatestV + ".0" private val scoptV = "4.1.0" private val sentryLogbackV = "5.7.4" @@ -442,7 +443,7 @@ object Dependencies { - https://www.scalatest.org/user_guide/generator_driven_property_checks - https://www.scalatest.org/user_guide/writing_scalacheck_style_properties */ - private val scalacheckBaseV = "1.15" + private val scalacheckBaseV = "1.17" private val scalacheckDependencies = List( "org.scalatestplus" %% s"scalacheck-${scalacheckBaseV.replace(".", "-")}" % scalatestScalacheckV % Test, ) @@ -594,14 +595,12 @@ 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" ) ++ slf4jBindingDependencies // During testing, add an slf4j binding for _all_ libraries. val kindProjectorPlugin = "org.typelevel" % "kind-projector" % kindProjectorV cross CrossVersion.full @@ -797,5 +796,23 @@ object Dependencies { The jakarta.annotation inclusion is above in googleApiClientDependencies. */ ExclusionRule("javax.annotation", "javax.annotation-api"), + ExclusionRule("javax.activation"), + ) + + 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`. diff --git a/project/build.properties b/project/build.properties index 2b4cbe4d584..54f486b9dc3 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,2 +1,2 @@ # scala-steward:off -sbt.version=1.5.5 +sbt.version=1.8.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index bdb54f675bf..151ba4b87e3 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.9.0") -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.1") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1") addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.0") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.4") addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.2.16")