diff --git a/README.md b/README.md index 684e1e4b..a2bc5730 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Scala-Pact is intended for Scala developers who are looking for a better way to If you are just starting out on your pact journey in scala, we recommend checking out [pact4s](https://github.com/jbwheatley/pact4s). This is built directly on top of pact-jvm, and provides support for writing and verifying contracts using [scalaTest](https://github.com/scalatest/scalatest), [weaver-test](https://github.com/disneystreaming/weaver-test), and [munit-cats-effect-3](https://github.com/typelevel/munit-cats-effect). -## Latest version is 3.3.1 +## Latest version is 3.3.2-RC1 Scala-Pact currently only supports [v2 of the pact specification](https://github.com/pact-foundation/pact-specification/tree/version-2). Support for v3 is a future goal of the project. @@ -16,7 +16,7 @@ Before this version, the project versioning did not follow semantic versioning. Scala-Pact now has two branches based on SBT requirements. -#### SBT 1.x compatible (Latest 3.3.1) +#### SBT 1.x compatible (Latest 3.3.2-RC1) All development going forward begins at `2.3.x` and resides on the `master` branch. For the sake of the maintainer's sanity, version 2.3.x and beyond will only support Scala 2.12 and SBT 1.x or greater. The project is currently cross-compiled across scala 2.12.12 and 2.13.4. @@ -52,14 +52,14 @@ import com.itv.scalapact.plugin._ enablePlugins(ScalaPactPlugin) libraryDependencies ++= Seq( - "com.itv" %% "scalapact-scalatest-suite" % "3.3.1" % "test", + "com.itv" %% "scalapact-scalatest-suite" % "3.3.2-RC1" % "test", "org.scalatest" %% "scalatest" % "3.0.5" % "test" ) ``` Add this line to your `project/plugins.sbt` file to install the plugin: ```scala -addSbtPlugin("com.itv" % "sbt-scalapact" % "3.3.1") +addSbtPlugin("com.itv" % "sbt-scalapact" % "3.3.2-RC1") ``` Both the import and the plugin come pre-packaged with the latest JSON and Http libraries (http4s 0.21.x, and circe 0.13.x). @@ -75,27 +75,27 @@ import com.itv.scalapact.plugin._ enablePlugins(ScalaPactPlugin) libraryDependencies ++= Seq( - "com.itv" %% "scalapact-circe-0-13" % "3.3.1" % "test", - "com.itv" %% "scalapact-http4s-0-21" % "3.3.1" % "test", - "com.itv" %% "scalapact-scalatest" % "3.3.1" % "test", + "com.itv" %% "scalapact-circe-0-13" % "3.3.2-RC1" % "test", + "com.itv" %% "scalapact-http4s-0-21" % "3.3.2-RC1" % "test", + "com.itv" %% "scalapact-scalatest" % "3.3.2-RC1" % "test", "org.scalatest" %% "scalatest" % "3.0.5" % "test" ) ``` Add this line to your `project/plugins.sbt` file to install the plugin: ```scala -addSbtPlugin("com.itv" % "sbt-scalapact" % "3.3.1") +addSbtPlugin("com.itv" % "sbt-scalapact" % "3.3.2-RC1") ``` This version of the plugin comes pre-packaged with the latest JSON and Http libraries. Thanks to the way SBT works, that one plugin line will work in most cases, but if you're still having conflicts, you can also do this to use your preferred libraries: ```scala libraryDependencies ++= Seq( - "com.itv" %% "scalapact-argonaut-6-2" % "3.3.1", - "com.itv" %% "scalapact-http4s-0-21" % "3.3.1" + "com.itv" %% "scalapact-argonaut-6-2" % "3.3.2-RC1", + "com.itv" %% "scalapact-http4s-0-21" % "3.3.2-RC1" ) - addSbtPlugin("com.itv" % "sbt-scalapact-nodeps" % "3.3.1") + addSbtPlugin("com.itv" % "sbt-scalapact-nodeps" % "3.3.2-RC1") ``` In your test suite, you will need the following imports: diff --git a/build.sbt b/build.sbt index 5c5b0063..86e56a1f 100644 --- a/build.sbt +++ b/build.sbt @@ -67,8 +67,8 @@ def compilerOptionsVersion(scalaVersion: String) = case _ => Nil }) -lazy val scalaVersion212: String = "2.12.12" -lazy val scalaVersion213: String = "2.13.4" +lazy val scalaVersion212: String = "2.12.13" +lazy val scalaVersion213: String = "2.13.6" lazy val supportedScalaVersions = List(scalaVersion212, scalaVersion213) ThisBuild / scalaVersion := scalaVersion212 @@ -78,7 +78,7 @@ lazy val commonSettings = Seq( crossScalaVersions := supportedScalaVersions, scalacOptions ++= compilerOptionsVersion(scalaVersion.value), libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % "3.0.8" % "test" + "org.scalatest" %% "scalatest" % "3.0.9" % "test" ), wartremoverWarnings in (Compile, compile) ++= Warts.allBut( Wart.Any, @@ -184,6 +184,21 @@ lazy val http4s021 = ) .dependsOn(shared) +lazy val http4s023 = + (project in file("scalapact-http4s-0-23")) + .settings(commonSettings: _*) + .settings(publishSettings: _*) + .settings( + name := "scalapact-http4s-0-23", + libraryDependencies ++= Seq( + "org.http4s" %% "http4s-blaze-server" % "0.23.0-RC1" exclude("org.scala-lang.modules", "scala-xml"), + "org.http4s" %% "http4s-blaze-client" % "0.23.0-RC1" exclude("org.scala-lang.modules", "scala-xml"), + "org.http4s" %% "http4s-dsl" % "0.23.0-RC1", + "com.github.tomakehurst" % "wiremock" % "2.25.1" % "test" + ) + ) + .dependsOn(shared) + lazy val testShared = (project in file("scalapact-test-shared")) .settings(commonSettings: _*) @@ -316,7 +331,7 @@ lazy val testsWithDeps = .settings( libraryDependencies ++= Seq( "org.scalaj" %% "scalaj-http" % "2.4.2" % "test", - "org.json4s" %% "json4s-native" % "3.6.9" % "test", + "org.json4s" %% "json4s-native" % "3.6.11" % "test", "com.github.tomakehurst" % "wiremock" % "1.56" % "test", "fr.hmil" %% "roshttp" % "2.1.0" % "test", "io.argonaut" %% "argonaut" % "6.2.5" @@ -351,7 +366,7 @@ lazy val scalaPactProject = crossScalaVersions := Nil ) .aggregate(shared, core, pluginShared, plugin, pluginNoDeps, framework, testShared) - .aggregate(http4s021) + .aggregate(http4s021, http4s023) .aggregate(argonaut62, circe13) .aggregate(standalone, frameworkWithDeps) .aggregate(docs) diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http/package.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http/package.scala new file mode 100644 index 00000000..50e1fbef --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http/package.scala @@ -0,0 +1,5 @@ +package com.itv.scalapact + +import com.itv.scalapact.http4s23.impl.HttpInstances + +package object http extends HttpInstances diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sClientHelper.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sClientHelper.scala new file mode 100644 index 00000000..ef5b4510 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sClientHelper.scala @@ -0,0 +1,49 @@ +package com.itv.scalapact.http4s23.impl + +import cats.effect.{IO, Resource} +import com.itv.scalapact.shared.http.{SimpleRequest, SimpleResponse, SslContextMap} +import com.itv.scalapact.shared.utils.PactLogger +import org.http4s._ +import org.http4s.blaze.client.BlazeClientBuilder +import org.http4s.client.Client +import org.http4s.headers.`User-Agent` + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ + +object Http4sClientHelper { + + def defaultClient: Resource[IO, Client[IO]] = + buildPooledBlazeHttpClient(1, Duration(5, SECONDS), None) + + def buildPooledBlazeHttpClient(maxTotalConnections: Int, clientTimeout: Duration, sslContextName: Option[String])( + implicit sslContextMap: SslContextMap + ): Resource[IO, Client[IO]] = { + val sslContext = sslContextMap(sslContextName) + val builder = BlazeClientBuilder[IO](ExecutionContext.Implicits.global) + .withMaxTotalConnections(maxTotalConnections) + .withRequestTimeout(clientTimeout) + .withUserAgentOption(Option(`User-Agent`(ProductId("scala-pact", Option(BuildInfo.version))))) + + PactLogger.debug( + s"Creating http4s client: connections $maxTotalConnections, timeout $clientTimeout, sslContextName: $sslContextName, sslContextMap: $sslContextMap" + ) + + sslContext.fold(builder)(s => builder.withSslContext(s)).resource + } + + def doRequest(request: SimpleRequest, httpClient: Resource[IO, Client[IO]]): IO[SimpleResponse] = + for { + request <- Http4sRequestResponseFactory.buildRequest(request) + _ <- IO(PactLogger.message(s"cURL for request: ${request.asCurl()}")) + response <- httpClient.use { c => + c.run(request).use { r: Response[IO] => + r.bodyText.compile.toVector + .map(_.mkString) + .map { b => + SimpleResponse(r.status.code, r.headers.toMap, Some(b)) + } + } + } + } yield response +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sRequestResponseFactory.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sRequestResponseFactory.scala new file mode 100644 index 00000000..fdf21f23 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/Http4sRequestResponseFactory.scala @@ -0,0 +1,96 @@ +package com.itv.scalapact.http4s23.impl + +import java.nio.charset.StandardCharsets + +import cats.effect.IO +import com.itv.scalapact.shared.http.HttpMethod._ +import com.itv.scalapact.shared.http.{HttpMethod, SimpleRequest} +import fs2.Chunk +import org.http4s.Method +import org.http4s._ +import scodec.bits.ByteVector + +object Http4sRequestResponseFactory { + + implicit val enc: EntityEncoder[IO, String] = + EntityEncoder.simple[IO, String]() { str => + Chunk.byteVector(ByteVector(str.getBytes(StandardCharsets.UTF_8))) + } + + def buildUri(baseUrl: String, endpoint: String): IO[Uri] = + IO.fromEither(Uri.fromString(baseUrl + endpoint)) + + def intToStatus(status: IntAndReason): ParseResult[Status] = + status match { + case IntAndReason(code, Some(reason)) => + Status.fromIntAndReason(code, reason) + + case IntAndReason(code, None) => + Status.fromInt(code) + } + + def httpMethodToMethod(httpMethod: HttpMethod): Method = + httpMethod match { + case GET => + Method.GET + + case POST => + Method.POST + + case PUT => + Method.PUT + + case DELETE => + Method.DELETE + + case OPTIONS => + Method.OPTIONS + + case PATCH => + Method.PATCH + + case CONNECT => + Method.CONNECT + + case TRACE => + Method.TRACE + + case HEAD => + Method.HEAD + } + + def buildRequest(request: SimpleRequest): IO[Request[IO]] = + buildUri(request.baseUrl, request.endPoint).flatMap { uri => + val r = Request[IO]( + method = httpMethodToMethod(request.method), + uri = uri + ).withHeaders( + request.headers.toHttp4sHeaders + ) + + request.body + .map { b => + IO(r.withEntity(b)) + } + .getOrElse(IO(r)) + } + + def buildResponse(status: IntAndReason, headers: Map[String, String], body: Option[String]): Response[IO] = + intToStatus(status) match { + case Left(l) => + l.toHttpResponse[IO](HttpVersion.`HTTP/1.1`) + case Right(code) => + val response = Response[IO]( + status = code + ).withHeaders(headers.toHttp4sHeaders) + + body + .map { b => + response.withEntity(b) + } + .getOrElse(response) + } + +} + +case class IntAndReason(code: Int, reason: Option[String]) diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/HttpInstances.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/HttpInstances.scala new file mode 100644 index 00000000..9f530eb2 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/HttpInstances.scala @@ -0,0 +1,39 @@ +package com.itv.scalapact.http4s23.impl + +import java.util.concurrent.ConcurrentHashMap + +import HttpInstances.ClientConfig +import com.itv.scalapact.shared.IPactStubber +import com.itv.scalapact.shared.http.{IScalaPactHttpClient, IScalaPactHttpClientBuilder, SslContextMap} +import com.itv.scalapact.shared.utils.PactLogger + +import scala.concurrent.duration.Duration + +trait HttpInstances { + + // Note that we create a new stubber anytime this implicit is needed (i.e. this is a `def`). + // We need this because implementations of `IPactStubber` might want to have their own state about the server running. + implicit def serverInstance: IPactStubber = + new PactStubber + + private val clients: ConcurrentHashMap[ClientConfig, IScalaPactHttpClient] = new ConcurrentHashMap + + implicit def httpClientBuilder(implicit sslContextMap: SslContextMap): IScalaPactHttpClientBuilder = { + (clientTimeout: Duration, sslContextName: Option[String], maxTotalConnections: Int) => + val clientConfig = ClientConfig(clientTimeout, sslContextName, maxTotalConnections, sslContextMap) + PactLogger.debug(s"Checking client cache for config $clientConfig, cache size is ${clients.size}") + clients.computeIfAbsent( + clientConfig, + _ => ScalaPactHttpClient(clientTimeout, sslContextName, maxTotalConnections) + ) + } +} + +object HttpInstances { + private final case class ClientConfig( + timeout: Duration, + sslContextName: Option[String], + maxTotalConnections: Int, + sslContextMap: SslContextMap + ) +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/JsonString.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/JsonString.scala new file mode 100644 index 00000000..9812aa09 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/JsonString.scala @@ -0,0 +1,15 @@ +package com.itv.scalapact.http4s23.impl + +import cats.effect.IO +import org.http4s.{EntityEncoder, MediaType} +import org.http4s.headers.`Content-Type` + +final case class JsonString(value: String) + +object JsonString { + implicit val entityEncoder: EntityEncoder[IO, JsonString] = + EntityEncoder + .stringEncoder[IO] + .contramap[JsonString](_.value) + .withContentType(`Content-Type`(MediaType.application.json)) +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubService.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubService.scala new file mode 100644 index 00000000..751cb70c --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubService.scala @@ -0,0 +1,156 @@ +package com.itv.scalapact.http4s23.impl + +import java.util.concurrent.Executors +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ +import com.itv.scalapact.shared.http.SslContextMap +import com.itv.scalapact.shared.json.{IPactReader, IPactWriter} +import com.itv.scalapact.shared.{ScalaPactSettings, _} +import org.http4s.blaze.server.BlazeServerBuilder + +import javax.net.ssl.SSLContext +import org.http4s.dsl.io._ +import org.http4s.implicits._ +import org.http4s.{BuildInfo => _, _} +import org.typelevel.ci.CIString + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ + +object PactStubService { + + implicit class BlazeBuilderOps(val blazeBuilder: BlazeServerBuilder[IO]) extends AnyVal { + def withOptionalSsl(sslContext: Option[SSLContext]): BlazeServerBuilder[IO] = + sslContext.fold(blazeBuilder)(ssl => blazeBuilder.withSslContext(ssl)) + } + + def createServer( + interactionManager: IInteractionManager, + connectionPoolSize: Int, + sslContextName: Option[String], + port: Option[Int], + config: ScalaPactSettings + )(implicit pactReader: IPactReader, pactWriter: IPactWriter, sslContextMap: SslContextMap): BlazeServerBuilder[IO] = { + + val executionContext: ExecutionContext = + ExecutionContext.fromExecutor(Executors.newFixedThreadPool(2)) + + BlazeServerBuilder[IO](executionContext) + .bindHttp(port.getOrElse(config.givePort), config.giveHost) + .withIdleTimeout(60.seconds) + .withOptionalSsl(sslContextMap(sslContextName)) + .withConnectorPoolSize(connectionPoolSize) + .withHttpApp(PactStubService.service(interactionManager, config.giveStrictMode)) + } + + def service( + interactionManager: IInteractionManager, + strictMatching: Boolean + )(implicit pactReader: IPactReader, pactWriter: IPactWriter): HttpApp[IO] = { + + val isAdminCall: Request[IO] => Boolean = request => + request.headers.get(CIString("X-Pact-Admin")).map(_.head.value).contains("true") + + def admin(routes: HttpRoutes[IO]): HttpRoutes[IO] = HttpRoutes { req => + if (isAdminCall(req)) routes(req) + else OptionT.none + } + + def statusRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] { case GET -> Root / "stub" / "status" => + Ok() + } + + def interactionsRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ (PUT | POST) -> Root / "interactions" => putOrPostAdminInteraction(req) + case GET -> Root / "interactions" => + val output = + pactWriter.pactToJsonString( + Pact(PactActor(""), PactActor(""), interactionManager.getInteractions, None, None), + BuildInfo.version + ) + Ok(JsonString(output)) + case DELETE -> Root / "interactions" => + IO(interactionManager.clearInteractions()).flatMap { _ => + val output = + pactWriter.pactToJsonString( + Pact(PactActor(""), PactActor(""), interactionManager.getInteractions, None, None), + BuildInfo.version + ) + Ok(JsonString(output)) + } + } + + def putOrPostAdminInteraction(req: Request[IO]): IO[Response[IO]] = + req + .attemptAs[String] + .fold(_ => None, Option.apply) + .map { x => + pactReader.jsonStringToScalaPact(x.getOrElse("")) + } + .flatMap { + case Right(r) => + interactionManager.addInteractions(r.interactions) + + val output = + pactWriter.pactToJsonString( + Pact(PactActor(""), PactActor(""), interactionManager.getInteractions, None, None), + BuildInfo.version + ) + Ok(JsonString(output)) + + case Left(l) => + InternalServerError(l) + } + + def pactRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req => + req.attemptAs[String].toOption.value.flatMap { maybeBody => + interactionManager.findMatchingInteraction( + InteractionRequest( + method = Option(req.method.name.toUpperCase), + headers = Option(req.headers.toMap), + query = + if (req.params.isEmpty) None + else + Option( + req.multiParams.toList + .flatMap { case (key, values) => values.map((key, _)) } + .map(p => p._1 + "=" + p._2) + .mkString("&") + ), + path = Option(req.pathInfo.renderString), + body = maybeBody, + matchingRules = None + ), + strictMatching = strictMatching + ) match { + case Right(ir) => + Status.fromInt(ir.response.status.getOrElse(200)) match { + case Right(_) => + IO( + Http4sRequestResponseFactory.buildResponse( + status = IntAndReason(ir.response.status.getOrElse(200), None), + headers = ir.response.headers.getOrElse(Map.empty), + body = ir.response.body + ) + ) + + case Left(l) => + InternalServerError(l.sanitized) + } + + case Left(message) => + IO( + Http4sRequestResponseFactory.buildResponse( + status = IntAndReason(598, Some("Pact Match Failure")), + headers = Map("X-Pact-Admin" -> "Pact Match Failure"), + body = Option(message) + ) + ) + } + } + } + + (admin(statusRoutes) <+> admin(interactionsRoutes) <+> pactRoutes).orNotFound + } +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubber.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubber.scala new file mode 100644 index 00000000..f832a4c5 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/PactStubber.scala @@ -0,0 +1,72 @@ +package com.itv.scalapact.http4s23.impl + +import cats.effect._ +import cats.effect.unsafe.implicits.global +import com.itv.scalapact.shared.http.SslContextMap +import com.itv.scalapact.shared.{IInteractionManager, IPactStubber, ScalaPactSettings} +import com.itv.scalapact.shared.json.{IPactReader, IPactWriter} +import org.http4s.blaze.server.BlazeServerBuilder + +import scala.concurrent.Future + +class PactStubber extends IPactStubber { + + private var instance: Option[() => Future[Unit]] = None + private var _port: Option[Int] = None + + private def blazeServerBuilder( + scalaPactSettings: ScalaPactSettings, + interactionManager: IInteractionManager, + connectionPoolSize: Int, + sslContextName: Option[String], + port: Option[Int] + )(implicit pactReader: IPactReader, pactWriter: IPactWriter, sslContextMap: SslContextMap): BlazeServerBuilder[IO] = + PactStubService.createServer( + interactionManager, + connectionPoolSize, + sslContextName, + port, + scalaPactSettings + ) + + def start( + interactionManager: IInteractionManager, + connectionPoolSize: Int, + sslContextName: Option[String], + port: Option[Int] + )(implicit + pactReader: IPactReader, + pactWriter: IPactWriter, + sslContextMap: SslContextMap + ): ScalaPactSettings => IPactStubber = + scalaPactSettings => { + instance match { + case Some(_) => + this + + case None => + instance = Some( + blazeServerBuilder( + scalaPactSettings, + interactionManager, + connectionPoolSize, + sslContextName, + _port + ).resource + .use { server => + IO { _port = Some(server.address.getPort) } *> IO.never + } + .unsafeRunCancelable() + ) + this + } + } + + def shutdown(): Unit = { + instance.foreach(_()) + instance = None + _port = None + } + + def port: Option[Int] = _port +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/ScalaPactHttpClient.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/ScalaPactHttpClient.scala new file mode 100644 index 00000000..594776fe --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/ScalaPactHttpClient.scala @@ -0,0 +1,57 @@ +package com.itv.scalapact.http4s23.impl + +import cats.effect._ +import cats.effect.unsafe.implicits.global +import com.itv.scalapact.shared._ +import com.itv.scalapact.shared.http.{HttpMethod, IScalaPactHttpClient, SimpleRequest, SimpleResponse, SslContextMap} +import org.http4s.client.Client + +import scala.concurrent.duration._ + +class ScalaPactHttpClient(client: Resource[IO, Client[IO]])( + fetcher: (SimpleRequest, Resource[IO, Client[IO]]) => IO[SimpleResponse] +) extends IScalaPactHttpClient { + + def doRequest(simpleRequest: SimpleRequest): Either[Throwable, SimpleResponse] = + doRequestIO(simpleRequest).attempt.unsafeRunSync() + + def doInteractionRequest( + url: String, + ir: InteractionRequest + ): Either[Throwable, InteractionResponse] = + doInteractionRequestIO(url, ir).attempt.unsafeRunSync() + + def doRequestIO(simpleRequest: SimpleRequest): IO[SimpleResponse] = fetcher(simpleRequest, client) + + def doInteractionRequestIO(url: String, ir: InteractionRequest): IO[InteractionResponse] = { + val request = + SimpleRequest( + url, + ir.path.getOrElse("") + ir.query.map(q => s"?$q").getOrElse(""), + HttpMethod.maybeMethodToMethod(ir.method), + ir.headers.getOrElse(Map.empty[String, String]), + ir.body, + None + ) + fetcher(request, client).map { r => + InteractionResponse( + status = Option(r.statusCode), + headers = + if (r.headers.isEmpty) None + else Option(r.headers.map(p => p._1 -> p._2)), + body = r.body, + matchingRules = None + ) + } + } + +} + +object ScalaPactHttpClient { + def apply(clientTimeout: Duration, sslContextName: Option[String], maxTotalConnections: Int)(implicit + sslContextMap: SslContextMap + ): IScalaPactHttpClient = { + val client = Http4sClientHelper.buildPooledBlazeHttpClient(maxTotalConnections, clientTimeout, sslContextName) + new ScalaPactHttpClient(client)(Http4sClientHelper.doRequest) + } +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/package.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/package.scala new file mode 100644 index 00000000..8ce2aa52 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/impl/package.scala @@ -0,0 +1,16 @@ +package com.itv.scalapact.http4s23 + +import org.http4s.{Header, Headers} +import org.typelevel.ci.CIString + +package object impl { + implicit class HeaderOps(headers: Headers) { + def toMap: Map[String, String] = + headers.headers.map(h => h.name.toString -> h.value).toMap + } + + implicit class MapOps(val values: Map[String, String]) extends AnyVal { + def toHttp4sHeaders: Headers = Headers(values.map { case (k, v) => Header.Raw(CIString(k), v) }.toList) + } + +} diff --git a/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/package.scala b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/package.scala new file mode 100644 index 00000000..c3adbc94 --- /dev/null +++ b/scalapact-http4s-0-23/src/main/scala/com/itv/scalapact/http4s23/package.scala @@ -0,0 +1,5 @@ +package com.itv.scalapact + +import com.itv.scalapact.http4s23.impl.HttpInstances + +package object http4s23 extends HttpInstances diff --git a/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sClientHelperSpec.scala b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sClientHelperSpec.scala new file mode 100644 index 00000000..65539b11 --- /dev/null +++ b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sClientHelperSpec.scala @@ -0,0 +1,48 @@ +package com.itv.scalapact.http4s.http4s23.impl + +import cats.effect.unsafe.implicits.global +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock._ +import com.github.tomakehurst.wiremock.core.WireMockConfiguration._ +import com.itv.scalapact.http4s23.impl.Http4sClientHelper +import com.itv.scalapact.shared.http.{HttpMethod, SimpleRequest} +import org.scalatest.{BeforeAndAfterAll, FunSpec, Matchers} + +class Http4sClientHelperSpec extends FunSpec with Matchers with BeforeAndAfterAll { + + private val wireMockServer = new WireMockServer(wireMockConfig().port(0)) + + private def port(): Int = wireMockServer.port + + override def beforeAll(): Unit = { + + wireMockServer.start() + + WireMock.configureFor("localhost", port()) + + val response = aResponse().withStatus(200).withBody("Success").withHeader("foo", "bar") + + wireMockServer.addStubMapping( + get(urlEqualTo("/test")).willReturn(response).build() + ) + + } + + override def afterAll(): Unit = + wireMockServer.stop() + + describe("Making an HTTP request") { + + it("should be able to make a simple request") { + val request = SimpleRequest(s"http://localhost:${port()}", "/test", HttpMethod.GET, None) + val response = Http4sClientHelper.doRequest(request, Http4sClientHelper.defaultClient).unsafeRunSync() + + response.statusCode shouldEqual 200 + response.body.get shouldEqual "Success" + response.headers.exists(_ == ("foo" -> "bar")) shouldEqual true + } + + } + +} diff --git a/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sRequestResponseFactorySpec.scala b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sRequestResponseFactorySpec.scala new file mode 100644 index 00000000..a2521a38 --- /dev/null +++ b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/Http4sRequestResponseFactorySpec.scala @@ -0,0 +1,52 @@ +package com.itv.scalapact.http4s.http4s23.impl + +import cats.effect.unsafe.implicits.global +import com.itv.scalapact.http4s23.impl.{Http4sRequestResponseFactory, IntAndReason} +import com.itv.scalapact.shared.http.{HttpMethod, SimpleRequest} +import org.scalatest.{FunSpec, Matchers} + +class Http4sRequestResponseFactorySpec extends FunSpec with Matchers { + + describe("Creating Http4s requests and responses") { + + it("should be able to manufacture a good request") { + + val simpleRequest = SimpleRequest( + "http://localhost:8080", + "/foo", + HttpMethod.POST, + Map( + "Accept" -> "application/json", + "Content-Type" -> "test/plain" + ), + Some("Greetings!"), + None + ) + + val request = Http4sRequestResponseFactory.buildRequest(simpleRequest).unsafeRunSync() + + request.method.name shouldEqual "POST" + request.pathInfo.renderString.contains("foo") shouldEqual true + request.bodyText.compile.toVector.unsafeRunSync().mkString shouldEqual "Greetings!" + + } + + it("should be able to manufacture a good response") { + + val response = Http4sRequestResponseFactory + .buildResponse( + IntAndReason(404, Some("Not Found")), + Map( + "Content-Type" -> "test/plain" + ), + Some("Missing") + ) + + response.status.code shouldEqual 404 + response.bodyText.compile.toVector.unsafeRunSync().mkString shouldEqual "Missing" + + } + + } + +} diff --git a/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/ScalaPactHttpClientSpec.scala b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/ScalaPactHttpClientSpec.scala new file mode 100644 index 00000000..63e0b5a0 --- /dev/null +++ b/scalapact-http4s-0-23/src/test/scala/com/itv/scalapact/http4s/http4s23/impl/ScalaPactHttpClientSpec.scala @@ -0,0 +1,44 @@ +package com.itv.scalapact.http4s.http4s23.impl + +import cats.effect.{IO, Resource} +import cats.effect.unsafe.implicits.global +import com.itv.scalapact.http4s23.impl.ScalaPactHttpClient +import com.itv.scalapact.shared._ +import com.itv.scalapact.shared.http.{SimpleRequest, SimpleResponse} +import org.http4s.client.Client +import org.scalatest.{FunSpec, Matchers} + +class ScalaPactHttpClientSpec extends FunSpec with Matchers { + + describe("Making an interaction request") { + + it("should be able to make and interaction request and get an interaction response") { + + val requestDetails = InteractionRequest( + method = Some("GET"), + headers = None, + query = None, + path = Some("/foo"), + body = None, + matchingRules = None + ) + + val responseDetails = InteractionResponse( + status = Some(200), + headers = None, + body = None, + matchingRules = None + ) + + val fakeCaller: (SimpleRequest, Resource[IO, Client[IO]]) => IO[SimpleResponse] = + (_, _) => IO.pure(SimpleResponse(200)) + + val result = + new ScalaPactHttpClient(null)(fakeCaller) + .doInteractionRequestIO("", requestDetails) + .unsafeRunSync() + + result shouldEqual responseDetails + } + } +}