diff --git a/build.sbt b/build.sbt index e167d1a0..d11cc3ea 100644 --- a/build.sbt +++ b/build.sbt @@ -57,12 +57,12 @@ ThisBuild / githubWorkflowEnv ++= List("PGP_PASSPHRASE", "PGP_SECRET", "SONATYPE val Versions = new { val catsCore = "2.5.0" - val catsEffect = "2.3.1" + val catsEffect = "3.0.2" val circe = "0.13.0" val kindProjector = "0.11.3" val monix = "3.3.0" val scalaTest = "3.2.7" - val sttp = "3.2.3" + val sttp = "3.3.0-RC1" val refined = "0.9.23" } @@ -105,7 +105,9 @@ lazy val `oauth2-backend-cats` = project .settings( name := "sttp-oauth2-backend-cats", libraryDependencies ++= Seq( - "org.typelevel" %% "cats-effect" % Versions.catsEffect, + "org.typelevel" %% "cats-effect-std" % Versions.catsEffect, + "org.typelevel" %% "cats-effect" % Versions.catsEffect % Test, + "org.typelevel" %% "cats-effect-testkit" % Versions.catsEffect % Test, "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % Versions.sttp % Test ) ++ plugins ++ testDependencies, mimaSettings @@ -126,7 +128,7 @@ lazy val `oauth2-backend-future` = project val root = project .in(file(".")) .settings( - skip in publish := true, + publish / skip := true, mimaPreviousArtifacts := Set.empty ) // after adding a module remember to regenerate ci.yml using `sbt githubWorkflowGenerate` diff --git a/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/CatsRefCache.scala b/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/CatsRefCache.scala index 7dd0d80a..23af7fb8 100644 --- a/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/CatsRefCache.scala +++ b/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/CatsRefCache.scala @@ -1,7 +1,7 @@ package com.ocadotechnology.sttp.oauth2.backend -import cats.effect.Sync -import cats.effect.concurrent.Ref +import cats.Functor +import cats.effect.kernel.Ref import cats.implicits._ final class CatsRefCache[F[_], A] private (ref: Ref[F, Option[A]]) extends Cache[F, A] { @@ -11,5 +11,5 @@ final class CatsRefCache[F[_], A] private (ref: Ref[F, Option[A]]) extends Cache object CatsRefCache { - def apply[F[_]: Sync, A]: F[Cache[F, A]] = Ref[F].of(Option.empty[A]).map(new CatsRefCache(_)) + def apply[F[_]: Ref.Make: Functor, A]: F[Cache[F, A]] = Ref[F].of(Option.empty[A]).map(new CatsRefCache(_)) } diff --git a/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackend.scala b/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackend.scala index 8606f29b..1e8616f6 100644 --- a/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackend.scala +++ b/oauth2-backend-cats/src/main/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackend.scala @@ -1,10 +1,10 @@ package com.ocadotechnology.sttp.oauth2.backend -import cats.Monad import cats.data.OptionT -import cats.effect.Clock -import cats.effect.Concurrent -import cats.effect.concurrent.Semaphore +import cats.effect.kernel.Clock +import cats.effect.kernel.Concurrent +import cats.effect.kernel.MonadCancelThrow +import cats.effect.std.Semaphore import cats.implicits._ import com.ocadotechnology.sttp.oauth2.ClientCredentialsProvider import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse @@ -18,7 +18,7 @@ import sttp.model.Uri import java.time.Instant -final class SttpOauth2ClientCredentialsCatsBackend[F[_]: Monad: Clock, P] private ( +final class SttpOauth2ClientCredentialsCatsBackend[F[_]: Clock: MonadCancelThrow, P] private ( delegate: SttpBackend[F, P], fetchTokenAction: F[AccessTokenResponse], cache: Cache[F, TokenWithExpiryInstant], @@ -26,13 +26,13 @@ final class SttpOauth2ClientCredentialsCatsBackend[F[_]: Monad: Clock, P] privat ) extends DelegateSttpBackend(delegate) { override def send[T, R >: P with Effect[F]](request: Request[T, R]): F[Response[T]] = for { - token <- semaphore.withPermit(resolveToken) + token <- semaphore.permit.surround(resolveToken) response <- delegate.send(request.auth.bearer(token.value)) } yield response private val resolveToken: F[Secret[String]] = OptionT(cache.get) - .product(OptionT.liftF(Clock[F].instantNow)) + .product(OptionT.liftF(Clock[F].realTimeInstant)) .filter { case (TokenWithExpiryInstant(_, expiryInstant), currentInstant) => currentInstant.isBefore(expiryInstant) } .map(_._1) .getOrElseF(fetchAndSaveToken) @@ -42,14 +42,14 @@ final class SttpOauth2ClientCredentialsCatsBackend[F[_]: Monad: Clock, P] privat fetchTokenAction.flatMap(calculateExpiryInstant).flatTap(cache.set) private def calculateExpiryInstant(response: AccessTokenResponse): F[TokenWithExpiryInstant] = - Clock[F].instantNow.map(_ plusMillis response.expiresIn.toMillis).map(TokenWithExpiryInstant(response.accessToken, _)) + Clock[F].realTimeInstant.map(_ plusMillis response.expiresIn.toMillis).map(TokenWithExpiryInstant(response.accessToken, _)) } object SttpOauth2ClientCredentialsCatsBackend { final case class TokenWithExpiryInstant(token: Secret[String], expiryInstant: Instant) - def apply[F[_]: Concurrent: Clock, P]( + def apply[F[_]: Clock: Concurrent, P]( tokenUrl: Uri, tokenIntrospectionUrl: Uri, clientId: NonEmptyString, @@ -65,7 +65,7 @@ object SttpOauth2ClientCredentialsCatsBackend { /** Keep in mind that the given implicit `backend` may be different than this one used by `clientCredentialsProvider` */ - def usingClientCredentialsProvider[F[_]: Concurrent: Clock, P]( + def usingClientCredentialsProvider[F[_]: Clock: Concurrent, P]( clientCredentialsProvider: ClientCredentialsProvider[F] )( scope: Scope @@ -74,7 +74,7 @@ object SttpOauth2ClientCredentialsCatsBackend { ): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] = CatsRefCache[F, TokenWithExpiryInstant].flatMap(usingClientCredentialsProviderAndCache(clientCredentialsProvider, _)(scope)) - def usingCache[F[_]: Concurrent: Clock, P]( + def usingCache[F[_]: Clock: Concurrent, P]( cache: Cache[F, TokenWithExpiryInstant] )( tokenUrl: Uri, @@ -92,7 +92,7 @@ object SttpOauth2ClientCredentialsCatsBackend { /** Keep in mind that the given implicit `backend` may be different than this one used by `clientCredentialsProvider` */ - def usingClientCredentialsProviderAndCache[F[_]: Concurrent: Clock, P]( + def usingClientCredentialsProviderAndCache[F[_]: Clock: Concurrent, P]( clientCredentialsProvider: ClientCredentialsProvider[F], cache: Cache[F, TokenWithExpiryInstant] )( @@ -104,12 +104,12 @@ object SttpOauth2ClientCredentialsCatsBackend { /** Keep in mind that the given implicit `backend` may be different than this one used by `fetchTokenAction` */ - def usingFetchTokenActionAndCache[F[_]: Concurrent: Clock, P]( + def usingFetchTokenActionAndCache[F[_]: Clock: Concurrent, P]( fetchTokenAction: F[AccessTokenResponse], cache: Cache[F, TokenWithExpiryInstant] )( implicit backend: SttpBackend[F, P] ): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] = - Semaphore(n = 1).map(new SttpOauth2ClientCredentialsCatsBackend(backend, fetchTokenAction, cache, _)) + Semaphore[F](n = 1).map(new SttpOauth2ClientCredentialsCatsBackend[F, P](backend, fetchTokenAction, cache, _)) } diff --git a/oauth2-backend-cats/src/test/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackendSpec.scala b/oauth2-backend-cats/src/test/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackendSpec.scala index f25a0dda..919259f8 100644 --- a/oauth2-backend-cats/src/test/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackendSpec.scala +++ b/oauth2-backend-cats/src/test/scala/com/ocadotechnology/sttp/oauth2/backend/SttpOauth2ClientCredentialsCatsBackendSpec.scala @@ -1,15 +1,17 @@ package com.ocadotechnology.sttp.oauth2.backend -import cats.effect.ContextShift import cats.effect.IO -import cats.effect.Timer +import cats.effect.kernel.Outcome +import cats.effect.kernel.testkit.TestContext +import cats.effect.testkit.TestInstances import cats.implicits._ import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse import com.ocadotechnology.sttp.oauth2.Secret import com.ocadotechnology.sttp.oauth2.common.Scope import eu.timepit.refined.types.string.NonEmptyString +import org.scalatest.compatible.Assertion import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AsyncWordSpec +import org.scalatest.wordspec.AnyWordSpec import sttp.client3._ import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend import sttp.client3.testing.SttpBackendStub @@ -17,13 +19,15 @@ import sttp.client3.testing._ import sttp.model.HeaderNames.Authorization import sttp.model._ -import scala.concurrent.ExecutionContext import scala.concurrent.duration._ -class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matchers { - implicit override val executionContext: ExecutionContext = ExecutionContext.global - implicit val contextShift: ContextShift[IO] = IO.contextShift(executionContext) - implicit val timer: Timer[IO] = IO.timer(executionContext) +class SttpOauth2ClientCredentialsCatsBackendSpec extends AnyWordSpec with Matchers with TestInstances { + + def ticked(f: IO[Assertion]): Assertion = { + implicit val ticker = Ticker(TestContext()) + + unsafeRun(f) shouldBe Outcome.succeeded(Some(succeed)) + } "SttpOauth2ClientCredentialsBackend" when { val tokenUrl: Uri = uri"https://authserver.org/oauth2/token" @@ -34,7 +38,7 @@ class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matc val testAppUrl: Uri = uri"https://testapp.org/test" "TestApp is invoked once" should { - "request a token. add the token to the TestApp request" in { + "request a token. add the token to the TestApp request" in ticked { val accessToken: Secret[String] = Secret("token") implicit val mockBackend: SttpBackendStub[IO, Any] = AsyncHttpClientCatsBackend .stub[IO] @@ -44,14 +48,16 @@ class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matc .thenRespondOk() for { - backend <- SttpOauth2ClientCredentialsCatsBackend[IO, Any](tokenUrl, uri"https://unused", clientId, clientSecret)(scope) + backend <- + SttpOauth2ClientCredentialsCatsBackend[IO, Any](tokenUrl, uri"https://unused", clientId, clientSecret)(scope) response <- backend.send(basicRequest.get(testAppUrl).response(asStringAlways)) } yield response.code shouldBe StatusCode.Ok - }.unsafeToFuture() + } } "TestApp is invoked twice sequentially" should { - "first invocation is requesting a token, second invocation is getting the token from the cache. add the token to the both TestApp requests" in { + "first invocation is requesting a token, second invocation is getting the token from the cache. add the token to the both TestApp requests" in ticked { + val accessToken: Secret[String] = Secret("token") implicit val recordingMockBackend: RecordingSttpBackend[IO, Any] = new RecordingSttpBackend( AsyncHttpClientCatsBackend @@ -72,11 +78,12 @@ class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matc response2.code shouldBe StatusCode.Ok recordingMockBackend.invocationCountByUri shouldBe Map(tokenUrl -> 1, testAppUrl -> 2) } - }.unsafeToFuture() + } } "TestApp is invoked twice in parallel" should { - "first invocation is requesting a token, second invocation is waiting for token response and getting the token from the cache. add the token to the both TestApp requests" in { + "first invocation is requesting a token, second invocation is waiting for token response and getting the token from the cache. add the token to the both TestApp requests" in ticked { + val accessToken: Secret[String] = Secret("token") implicit val recordingMockBackend: RecordingSttpBackend[IO, Any] = new RecordingSttpBackend( AsyncHttpClientCatsBackend @@ -96,11 +103,12 @@ class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matc response2.code shouldBe StatusCode.Ok recordingMockBackend.invocationCountByUri shouldBe Map(tokenUrl -> 1, testAppUrl -> 2) } - }.unsafeToFuture() + } } "TestApp is invoked after token expires" should { - "first invocation is requesting a token, second invocation is requesting a token, because the previous token is expired. add the token to the both TestApp requests" in { + "first invocation is requesting a token, second invocation is requesting a token, because the previous token is expired. add the token to the both TestApp requests" in ticked { + val accessToken1: Secret[String] = Secret("token1") val accessToken2: Secret[String] = Secret("token2") implicit val recordingMockBackend: RecordingSttpBackend[IO, Any] = new RecordingSttpBackend( @@ -130,7 +138,7 @@ class SttpOauth2ClientCredentialsCatsBackendSpec extends AsyncWordSpec with Matc response2.body shouldBe "body2" recordingMockBackend.invocationCountByUri shouldBe Map(tokenUrl -> 2, testAppUrl -> 2) } - }.unsafeToFuture() + } } implicit class SttpBackendStubOps[F[_], P](val backend: SttpBackendStub[F, P]) {