Skip to content

Commit

Permalink
Update to cats-effect 3
Browse files Browse the repository at this point in the history
  • Loading branch information
kubukoz committed Apr 10, 2021
1 parent c11cf1c commit 25d29d9
Show file tree
Hide file tree
Showing 4 changed files with 48 additions and 38 deletions.
10 changes: 6 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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] {
Expand All @@ -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(_))
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,21 +18,21 @@ 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],
semaphore: Semaphore[F]
) 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)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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]
)(
Expand All @@ -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, _))

}
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
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
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"
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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]) {
Expand Down

0 comments on commit 25d29d9

Please sign in to comment.