diff --git a/build.sbt b/build.sbt index cd969e4f..9df1892e 100644 --- a/build.sbt +++ b/build.sbt @@ -106,7 +106,7 @@ lazy val `mauth-signer-sttp` = scalaModuleProject("mauth-signer-sttp") Dependencies.test(scalaMock, scalaTest, wiremock, sttpAkkaHttpBackend).map(withExclusions) ) -lazy val `mauth-signer-http4s` = scalaModuleProject("mauth-signer-http4s") +lazy val `mauth-signer-http4s-023` = scalaModuleProject("mauth-signer-http4s-023") .dependsOn(`mauth-signer`, `mauth-signer-scala-core`, `mauth-test-utils` % "test") .settings( basicSettings, @@ -119,6 +119,18 @@ lazy val `mauth-signer-http4s` = scalaModuleProject("mauth-signer-http4s") Dependencies.test(munitCatsEffect, http4sDsl) ) +lazy val `mauth-signer-http4s-022` = scalaModuleProject("mauth-signer-http4s-022") + .dependsOn(`mauth-signer`, `mauth-signer-scala-core`, `mauth-test-utils` % "test") + .settings( + basicSettings, + publishSettings, + testFrameworks += new TestFramework("munit.Framework"), + libraryDependencies ++= + Dependencies.provided(http4sClient022) ++ + Dependencies.compile(enumeratum) ++ + Dependencies.test(munitCatsEffect2, http4sDsl022) + ) + // A separate module to sign and send sttp request using akka-http backend // This keeps mauth-signer-sttp free of dependencies like akka and cats-effect in turn helps reduce dependency footprint // of our client libraries (which will only need to depend on mauth-signer-sttp) @@ -167,7 +179,7 @@ lazy val `mauth-authenticator-akka-http` = scalaModuleProject("mauth-authenticat ) lazy val `mauth-authenticator-http4s` = (project in file("modules/mauth-authenticator-http4s")) // don't need to cross-compile - .dependsOn(`mauth-signer-http4s`, `mauth-authenticator-scala` % "test->test;compile->compile", `mauth-test-utils` % "test") + .dependsOn(`mauth-signer-http4s-023`, `mauth-authenticator-scala` % "test->test;compile->compile", `mauth-test-utils` % "test") .settings( basicSettings, moduleName := "mauth-authenticator-http4s", @@ -195,7 +207,8 @@ lazy val `mauth-jvm-clients` = (project in file(".")) `mauth-signer`, `mauth-signer-akka-http`, `mauth-signer-scala-core`, - `mauth-signer-http4s`, + `mauth-signer-http4s-023`, + `mauth-signer-http4s-022`, `mauth-signer-sttp`, `mauth-signer-apachehttp`, `mauth-sender-sttp-akka-http`, diff --git a/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/Implicits.scala b/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/Implicits.scala new file mode 100644 index 00000000..9ba6b4c5 --- /dev/null +++ b/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/Implicits.scala @@ -0,0 +1,51 @@ +package com.mdsol.mauth.http4s022.client + +import cats.MonadThrow +import com.mdsol.mauth.models.SignedRequest +import org.http4s.headers.`Content-Type` +import org.http4s.{headers, Header, Headers, Method, Request, Uri} +import org.typelevel.ci.CIString +import cats.syntax.all._ + +import scala.annotation.nowarn +import scala.collection.immutable + +object Implicits { + + implicit class NewSignedRequestOps(val signedRequest: SignedRequest) extends AnyVal { + + /** Create a http4s request from a [[models.SignedRequest]] + */ + def toHttp4sRequest[F[_]: MonadThrow]: F[Request[F]] = { + val contentType: Option[`Content-Type`] = extractContentTypeFromHeaders(signedRequest.req.headers) + val headersWithoutContentType: Map[String, String] = removeContentTypeFromHeaders(signedRequest.req.headers) + + val allHeaders: immutable.Seq[Header.Raw] = (headersWithoutContentType ++ signedRequest.mauthHeaders).toList + .map { case (name, value) => + Header.Raw(CIString(name), value) + } + + for { + uri <- Uri.fromString(signedRequest.req.uri.toString).liftTo[F] + method <- Method.fromString(signedRequest.req.httpMethod).liftTo[F] + } yield Request[F]( + method = method, + uri = uri, + body = fs2.Stream.emits(signedRequest.req.body), + headers = Headers(allHeaders) + ).withContentTypeOption(contentType) + } + + private def extractContentTypeFromHeaders(requestHeaders: Map[String, String]): Option[`Content-Type`] = + requestHeaders + .get(headers.`Content-Type`.toString) + .flatMap(str => `Content-Type`.parse(str).toOption) + + @nowarn("msg=.*Unused import.*") // compat import only needed for 2.12 + private def removeContentTypeFromHeaders(requestHeaders: Map[String, String]): Map[String, String] = { + import scala.collection.compat._ + requestHeaders.view.filterKeys(_ != headers.`Content-Type`.toString).toMap + } + } + +} diff --git a/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/MAuthSigner.scala b/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/MAuthSigner.scala new file mode 100644 index 00000000..c0e98156 --- /dev/null +++ b/modules/mauth-signer-http4s-022/src/main/scala/com/mdsol/mauth/http4s022/client/MAuthSigner.scala @@ -0,0 +1,35 @@ +package com.mdsol.mauth.http4s022.client + +import cats.effect._ +import cats.syntax.all._ +import com.mdsol.mauth.RequestSigner +import com.mdsol.mauth.models.UnsignedRequest +import org.http4s.Request +import org.http4s.client.Client + +import java.net.URI + +object MAuthSigner { + def apply[F[_]: Sync](signer: RequestSigner)(client: Client[F]): Client[F] = + Client { req => + for { + req <- Resource.eval(req.as[Array[Byte]].flatMap { byteArray => + val signedRequest = signer.signRequest( + UnsignedRequest( + req.method.name, + URI.create(req.uri.renderString), + byteArray, + req.headers.headers.view.map(h => h.name.toString -> h.value).toMap + ) + ) + Request( + method = req.method, + uri = req.uri, + headers = req.headers.put(signedRequest.mauthHeaders.toList), + body = req.body + ).pure[F] + }) + res <- client.run(req) + } yield res + } +} diff --git a/modules/mauth-signer-http4s-022/src/test/scala/com/mdsol/mauth/http4s022/client/MAuthSignerMiddlewareSuite.scala b/modules/mauth-signer-http4s-022/src/test/scala/com/mdsol/mauth/http4s022/client/MAuthSignerMiddlewareSuite.scala new file mode 100644 index 00000000..2c1fcadf --- /dev/null +++ b/modules/mauth-signer-http4s-022/src/test/scala/com/mdsol/mauth/http4s022/client/MAuthSignerMiddlewareSuite.scala @@ -0,0 +1,99 @@ +package com.mdsol.mauth.http4s022.client + +import cats.effect.IO +import cats.syntax.all._ +import com.mdsol.mauth.models.UnsignedRequest +import com.mdsol.mauth.{MAuthRequestSigner, MAuthVersion} +import munit.CatsEffectSuite +import org.http4s.client.Client +import org.http4s.{Headers, HttpRoutes, Request, Response, Status, Uri} +import org.http4s.dsl.io._ +import com.mdsol.mauth.test.utils.TestFixtures._ +import com.mdsol.mauth.util.EpochTimeProvider + +import java.net.URI +import java.util.UUID + +class MAuthSignerMiddlewareSuite extends CatsEffectSuite { + + private val CONST_EPOCH_TIME_PROVIDER: EpochTimeProvider = new EpochTimeProvider() { override def inSeconds(): Long = EXPECTED_TIME_HEADER_1.toLong } + + private val signerV2: MAuthRequestSigner = new MAuthRequestSigner( + UUID.fromString(APP_UUID_1), + PRIVATE_KEY_1, + CONST_EPOCH_TIME_PROVIDER, + java.util.Arrays.asList[MAuthVersion](MAuthVersion.MWSV2) + ) + + val signerV1: MAuthRequestSigner = new MAuthRequestSigner( + UUID.fromString(APP_UUID_1), + PRIVATE_KEY_1, + CONST_EPOCH_TIME_PROVIDER, + java.util.Arrays.asList[MAuthVersion](MAuthVersion.MWS) + ) + + private def route(headers: Map[String, String]) = HttpRoutes + .of[IO] { case req @ POST -> Root / "v1" / "test" => + if (headers.forall(h => req.headers.headers.map(h => h.name.toString -> h.value).contains(h))) + Response[IO](Status.Ok).pure[IO] + else + Response[IO](Status.InternalServerError).pure[IO] + } + .orNotFound + + test("correctly send a customized content-type header for v2") { + + val simpleNewUnsignedRequest = + UnsignedRequest + .fromStringBodyUtf8( + httpMethod = "POST", + uri = new URI(s"/v1/test"), + body = "", + headers = Map("Content-Type" -> "application/json") + ) + + val signedReq = signerV2.signRequest(simpleNewUnsignedRequest) + + val client = Client.fromHttpApp(route(signedReq.mauthHeaders ++ simpleNewUnsignedRequest.headers)) + + val mAuthedClient = MAuthSigner(signerV2)(client) + + mAuthedClient + .status( + Request[IO]( + method = POST, + uri = Uri.unsafeFromString(s"/v1/test"), + headers = Headers(signedReq.mauthHeaders.toList ++ List("Content-Type" -> "application/json")) + ) + ) + .assertEquals(Status.Ok) + } + + test("correctly send a customized content-type header for v1") { + + val simpleNewUnsignedRequest = + UnsignedRequest + .fromStringBodyUtf8( + httpMethod = "POST", + uri = new URI(s"/v1/test"), + body = "", + headers = Map("Content-Type" -> "application/json") + ) + + val signedReq = signerV1.signRequest(simpleNewUnsignedRequest) + + val client = Client.fromHttpApp(route(signedReq.mauthHeaders ++ simpleNewUnsignedRequest.headers)) + + val mAuthedClient = MAuthSigner(signerV1)(client) + + mAuthedClient + .status( + Request[IO]( + method = POST, + uri = Uri.unsafeFromString(s"/v1/test"), + headers = Headers(signedReq.mauthHeaders.toList ++ List("Content-Type" -> "application/json")) + ) + ) + .assertEquals(Status.Ok) + } +} diff --git a/modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala b/modules/mauth-signer-http4s-023/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala similarity index 100% rename from modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala rename to modules/mauth-signer-http4s-023/src/main/scala/com/mdsol/mauth/http4s/client/Implicits.scala diff --git a/modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/MAuthSigner.scala b/modules/mauth-signer-http4s-023/src/main/scala/com/mdsol/mauth/http4s/client/MAuthSigner.scala similarity index 100% rename from modules/mauth-signer-http4s/src/main/scala/com/mdsol/mauth/http4s/client/MAuthSigner.scala rename to modules/mauth-signer-http4s-023/src/main/scala/com/mdsol/mauth/http4s/client/MAuthSigner.scala diff --git a/modules/mauth-signer-http4s/src/test/scala/com/mdsol/mauth/http4s/client/MAuthSignerMiddlewareSuite.scala b/modules/mauth-signer-http4s-023/src/test/scala/com/mdsol/mauth/http4s/client/MAuthSignerMiddlewareSuite.scala similarity index 100% rename from modules/mauth-signer-http4s/src/test/scala/com/mdsol/mauth/http4s/client/MAuthSignerMiddlewareSuite.scala rename to modules/mauth-signer-http4s-023/src/test/scala/com/mdsol/mauth/http4s/client/MAuthSignerMiddlewareSuite.scala diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala index 28558d5a..e4c9e1aa 100644 --- a/project/BuildSettings.scala +++ b/project/BuildSettings.scala @@ -9,9 +9,9 @@ object BuildSettings { val scala213 = "2.13.14" lazy val basicSettings = Seq( - homepage := Some(new URL("https://github.com/mdsol/mauth-jvm-clients")), + homepage := Some(new URI("https://github.com/mdsol/mauth-jvm-clients").toURL), organization := "com.mdsol", - organizationHomepage := Some(new URL("http://mdsol.com")), + organizationHomepage := Some(new URI("http://mdsol.com").toURL), description := "MAuth clients", scalaVersion := scala213, resolvers += Resolver.mavenLocal, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7ec39231..afbc424b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -13,6 +13,7 @@ object Dependencies extends DependencyUtils { val log4cats = "2.7.0" val circe = "0.14.9" val circeGenericExtras = "0.14.3" + val http4s022 = "0.22.15" } val akkaHttp: ModuleID = "com.typesafe.akka" %% "akka-http" % Version.akkaHttp @@ -36,7 +37,9 @@ object Dependencies extends DependencyUtils { val scalaLibCompat: ModuleID = "org.scala-lang.modules" %% "scala-collection-compat" % "2.12.0" val caffeine: ModuleID = "com.github.ben-manes.caffeine" % "caffeine" % "3.1.8" val http4sDsl: ModuleID = "org.http4s" %% "http4s-dsl" % Version.http4s + val http4sDsl022: ModuleID = "org.http4s" %% "http4s-dsl" % Version.http4s022 val http4sClient: ModuleID = "org.http4s" %% "http4s-client" % Version.http4s + val http4sClient022: ModuleID = "org.http4s" %% "http4s-client" % Version.http4s022 val enumeratum: ModuleID = "com.beachape" %% "enumeratum" % Version.enumeratum val log4cats: ModuleID = "org.typelevel" %% "log4cats-slf4j" % Version.log4cats @@ -58,6 +61,7 @@ object Dependencies extends DependencyUtils { val scalaTest: ModuleID = "org.scalatest" %% "scalatest" % "3.2.19" val wiremock: ModuleID = "com.github.tomakehurst" % "wiremock" % "2.27.2" val munitCatsEffect: ModuleID = "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" + val munitCatsEffect2: ModuleID = "org.typelevel" %% "munit-cats-effect-2" % "1.0.7" val log4catsNoop: ModuleID = "org.typelevel" %% "log4cats-noop" % Version.log4cats val scalaCacheCaffeine: ModuleID = "com.github.cb372" %% "scalacache-caffeine" % "1.0.0-M6"