diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05e3b982..6c37f615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.12.19, 2.13.13, 3.2.2] + scala: [2.12.19, 2.13.13, 3.3.3] java: [graalvm-ce-java11@20.3.0] runs-on: ${{ matrix.os }} steps: @@ -59,7 +59,7 @@ jobs: - run: sbt ++${{ matrix.scala }} test docs/mdoc mimaReportBinaryIssues - name: Compress target directories - run: tar cf targets.tar oauth2-jsoniter/jvm/target oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target oauth2-jsoniter/js/target target oauth2-cache-scalacache/target mdoc/target oauth2-circe/jvm/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-circe/js/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target + run: tar cf targets.tar oauth2-jsoniter/jvm/target oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target oauth2-cache-zio/target oauth2-jsoniter/js/target target oauth2-cache-scalacache/target mdoc/target oauth2-circe/jvm/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-circe/js/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target - name: Upload target directories uses: actions/upload-artifact@v2 @@ -120,12 +120,12 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (3.2.2) + - name: Download target directories (3.3.3) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-3.2.2-${{ matrix.java }} + name: target-${{ matrix.os }}-3.3.3-${{ matrix.java }} - - name: Inflate target directories (3.2.2) + - name: Inflate target directories (3.3.3) run: | tar xf targets.tar rm targets.tar diff --git a/build.sbt b/build.sbt index 519c37ed..72a06369 100644 --- a/build.sbt +++ b/build.sbt @@ -21,7 +21,7 @@ def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(Cros val Scala212 = "2.12.19" val Scala213 = "2.13.13" -val Scala3 = "3.2.2" +val Scala3 = "3.3.3" val GraalVM11 = "graalvm-ce-java11@20.3.0" @@ -62,7 +62,11 @@ val Versions = new { def compilerPlugins = libraryDependencies ++= (if (scalaVersion.value.startsWith("3")) Seq() - else Seq(compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"))) + else + Seq( + compilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full), + compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") + )) val mimaSettings = // revert the commit that made this change after releasing a new version @@ -79,9 +83,6 @@ val mimaSettings = // } mimaPreviousArtifacts := Set.empty -// Workaround for https://github.com/typelevel/sbt-tpolecat/issues/102 -val jsSettings = scalacOptions ++= (if (scalaVersion.value.startsWith("3")) Seq("-scalajs") else Seq()) - lazy val oauth2 = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) .settings( @@ -96,8 +97,7 @@ lazy val oauth2 = crossProject(JSPlatform, JVMPlatform) compilerPlugins ) .jsSettings( - libraryDependencies ++= Seq("org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0"), - jsSettings + libraryDependencies ++= Seq("org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0") ) lazy val `oauth2-circe` = crossProject(JSPlatform, JVMPlatform) @@ -113,9 +113,6 @@ lazy val `oauth2-circe` = crossProject(JSPlatform, JVMPlatform) mimaSettings, compilerPlugins ) - .jsSettings( - jsSettings - ) .dependsOn(oauth2 % "compile->compile;test->test") lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform) @@ -131,16 +128,15 @@ lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform) compilerPlugins, scalacOptions ++= Seq("-Wconf:cat=deprecation:info") // jsoniter-scala macro-generated code uses deprecated methods ) - .jsSettings( - jsSettings - ) .dependsOn(oauth2 % "compile->compile;test->test") lazy val docs = project .in(file("mdoc")) // important: it must not be docs/ .settings( mdocVariables := Map( - "VERSION" -> { if (isSnapshot.value) previousStableVersion.value.get else version.value } + "VERSION" -> { + if (isSnapshot.value) previousStableVersion.value.get else version.value + } ) ) .dependsOn(oauth2.jvm) @@ -153,7 +149,6 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform) mimaSettings, compilerPlugins ) - .jsSettings(jsSettings) .dependsOn(oauth2) // oauth2-cache-scalacache doesn't have JS support because scalacache doesn't compile for js https://github.com/cb372/scalacache/issues/354#issuecomment-913024231 @@ -204,6 +199,24 @@ lazy val `oauth2-cache-ce2` = project ) .dependsOn(`oauth2-cache`.jvm) +lazy val `oauth2-cache-zio` = project + .settings( + name := "sttp-oauth2-cache-zio", + libraryDependencies ++= Seq( + "dev.zio" %% "zio" % "2.1.1", + "dev.zio" %% "zio-test" % "2.1.1" % Test, + "dev.zio" %% "zio-test-sbt" % "2.1.1" % Test + ), + mimaSettings, + compilerPlugins, + scalacOptions -= "-Ykind-projector", + scalacOptions ++= ( + if (scalaVersion.value.startsWith("3")) Seq("-Ykind-projector:underscores") + else Seq("-P:kind-projector:underscore-placeholders") + ) + ) + .dependsOn(`oauth2-cache`.jvm) + lazy val `oauth2-cache-future` = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) .settings( @@ -215,7 +228,6 @@ lazy val `oauth2-cache-future` = crossProject(JSPlatform, JVMPlatform) mimaSettings, compilerPlugins ) - .jsSettings(jsSettings) .dependsOn(`oauth2-cache`) val root = project @@ -232,6 +244,7 @@ val root = project `oauth2-cache`.js, `oauth2-cache-cats`, `oauth2-cache-ce2`, + `oauth2-cache-zio`, `oauth2-cache-future`.jvm, `oauth2-cache-future`.js, `oauth2-cache-scalacache`, diff --git a/docs/caching.md b/docs/caching.md index e66c6f49..b4becb96 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -21,6 +21,7 @@ As the user of the library you can either choose to implement your own cache mec | Class |Description | Import module | |---------------------------|-------------------------------------------------------------|-------------------| +| `ZioRefExpiringCache` | Simple ZIO Ref based implementation. Good enough for `CachingAccessTokenProvider`, but for `CachingTokenIntrospection` it's recommended to use an instance which better handles memory (this instance does not periodically remove expired entries) | `"org.polyvariant" %% "sttp-oauth2-cache-zio" % "@VERSION@"` | | `CatsRefExpiringCache` | Simple Cats Effect 3 Ref based implementation. Good enough for `CachingAccessTokenProvider`, but for `CachingTokenIntrospection` it's recommended to use an instance which better handles memory (this instance does not periodically remove expired entries) | `"org.polyvariant" %% "sttp-oauth2-cache-cats" % "@VERSION@"` | | `CatsRefExpiringCache` | Simple Cats Effect 2 Ref based implementation. Good enough for `CachingAccessTokenProvider`, but for `CachingTokenIntrospection` it's recommended to use an instance which better handles memory (this instance does not periodically remove expired entries) | `"org.polyvariant" %% "sttp-oauth2-cache-ce2" % "@VERSION@"` | | `ScalacacheExpiringCache` | Implementation based on https://github.com/cb372/scalacache | `"org.polyvariant" %% "sttp-oauth2-cache-scalacache" % "@VERSION@"` | diff --git a/docs/client-credentials.md b/docs/client-credentials.md index 2d3a38ce..9d68843f 100644 --- a/docs/client-credentials.md +++ b/docs/client-credentials.md @@ -34,6 +34,7 @@ Caching modules provide cached `AccessTokenProvider`, which can: |----------------------------|------------------------------------|---------------------------------|--------------------------------------|-------------------------------------------------| | `sttp-oauth2-cache-cats` | `CachingAccessTokenProvider` | `cats-effect3`'s `Ref` | `cats-effect2`'s `Semaphore` | | | `sttp-oauth2-cache-ce2` | `CachingAccessTokenProvider` | `cats-effect2`'s `Ref` | `cats-effect2`'s `Semaphore` | | +| `sttp-oauth2-cache-zio` | `CachingAccessTokenProvider` | `zio`'s `Ref` | `zio`'s `Semaphore` | | | `sttp-oauth2-cache-future` | `FutureCachingAccessTokenProvider` | `monix-execution`'s `AtomicAny` | `monix-execution`'s `AsyncSemaphore` | It only uses submodule of whole `monix` project | ### Cats example diff --git a/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProvider.scala b/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProvider.scala new file mode 100644 index 00000000..836b6585 --- /dev/null +++ b/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProvider.scala @@ -0,0 +1,89 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import org.polyvariant.sttp.oauth2.AccessTokenProvider +import org.polyvariant.sttp.oauth2.ClientCredentialsToken +import org.polyvariant.sttp.oauth2.Secret +import org.polyvariant.sttp.oauth2.cache.ExpiringCache +import org.polyvariant.sttp.oauth2.cache.zio.CachingAccessTokenProvider.TokenWithExpirationTime +import org.polyvariant.sttp.oauth2.common.Scope +import zio.Clock +import zio.Semaphore +import zio._ + +import java.time.Instant +import scala.concurrent.duration.Duration + +final class CachingAccessTokenProvider[R]( + delegate: AccessTokenProvider[RIO[R, _]], + semaphore: Semaphore, + tokenCache: ExpiringCache[RIO[R, _], Option[Scope], TokenWithExpirationTime] +) extends AccessTokenProvider[RIO[R, _]] { + + override def requestToken(scope: Option[Scope]): RIO[R, ClientCredentialsToken.AccessTokenResponse] = + getFromCache(scope).flatMap { + case Some(value) => ZIO.succeed(value) + case None => semaphore.withPermit(acquireToken(scope)) + } + + private def acquireToken(scope: Option[Scope]): ZIO[R, Throwable, ClientCredentialsToken.AccessTokenResponse] = + getFromCache(scope).flatMap { + case Some(value) => ZIO.succeed(value) + case None => fetchAndSaveToken(scope) + } + + private def getFromCache(scope: Option[Scope]) = + tokenCache.get(scope).flatMap { entry => + Clock.instant.map { now => + entry match { + case Some(value) => Some(value.toAccessTokenResponse(now)) + case None => None + } + } + } + + private def fetchAndSaveToken(scope: Option[Scope]) = + for { + token <- delegate.requestToken(scope) + tokenWithExpiry <- calculateExpiryInstant(token) + _ <- tokenCache.put(scope, tokenWithExpiry, tokenWithExpiry.expirationTime) + } yield token + + private def calculateExpiryInstant(response: ClientCredentialsToken.AccessTokenResponse) = + Clock.instant.map(TokenWithExpirationTime.from(response, _)) + +} + +object CachingAccessTokenProvider { + + def apply[R]( + delegate: AccessTokenProvider[RIO[R, _]], + tokenCache: ExpiringCache[RIO[R, _], Option[Scope], TokenWithExpirationTime] + ): RIO[R, CachingAccessTokenProvider[R]] = Semaphore.make(permits = 1).map(new CachingAccessTokenProvider(delegate, _, tokenCache)) + + def refCacheInstance(delegate: AccessTokenProvider[Task]): Task[CachingAccessTokenProvider[Any]] = + ZioRefExpiringCache[Option[Scope], TokenWithExpirationTime].flatMap(CachingAccessTokenProvider(delegate, _)) + + final case class TokenWithExpirationTime( + accessToken: Secret[String], + domain: Option[String], + expirationTime: Instant, + scope: Option[Scope] + ) { + + def toAccessTokenResponse(now: Instant): ClientCredentialsToken.AccessTokenResponse = { + val newExpiresIn = Duration.fromNanos(java.time.Duration.between(now, expirationTime).toNanos) + ClientCredentialsToken.AccessTokenResponse(accessToken, domain, newExpiresIn, scope) + } + + } + + object TokenWithExpirationTime { + + def from(token: ClientCredentialsToken.AccessTokenResponse, now: Instant): TokenWithExpirationTime = { + val expirationTime = now.plusNanos(token.expiresIn.toNanos) + TokenWithExpirationTime(token.accessToken, token.domain, expirationTime, token.scope) + } + + } + +} diff --git a/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCache.scala b/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCache.scala new file mode 100644 index 00000000..82a815c6 --- /dev/null +++ b/oauth2-cache-zio/src/main/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCache.scala @@ -0,0 +1,35 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import org.polyvariant.sttp.oauth2.cache.ExpiringCache +import org.polyvariant.sttp.oauth2.cache.zio.ZioRefExpiringCache.Entry +import zio.Clock +import zio.Ref +import zio.Task +import zio.ZIO + +import java.time.Instant + +final class ZioRefExpiringCache[K, V] private (ref: Ref[Map[K, Entry[V]]]) extends ExpiringCache[Task, K, V] { + + override def get(key: K): Task[Option[V]] = + ref.get.map(_.get(key)).flatMap { entry => + Clock.instant.flatMap { now => + (entry, now) match { + case (Some(Entry(value, expiryInstant)), now) => + if (now.isBefore(expiryInstant)) ZIO.succeed(Some(value)) else remove(key).as(None) + case _ => + ZIO.none + } + } + } + + override def put(key: K, value: V, expirationTime: Instant): Task[Unit] = ref.update(_ + (key -> Entry(value, expirationTime))) + + override def remove(key: K): Task[Unit] = ref.update(_ - key) +} + +object ZioRefExpiringCache { + private final case class Entry[V](value: V, expirationTime: Instant) + + def apply[K, V]: Task[ExpiringCache[Task, K, V]] = Ref.make(Map.empty[K, Entry[V]]).map(new ZioRefExpiringCache(_)) +} diff --git a/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderParallelSpec.scala b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderParallelSpec.scala new file mode 100644 index 00000000..808e6d9f --- /dev/null +++ b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderParallelSpec.scala @@ -0,0 +1,72 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import org.polyvariant.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse +import org.polyvariant.sttp.oauth2.Secret +import org.polyvariant.sttp.oauth2.cache.ExpiringCache +import org.polyvariant.sttp.oauth2.cache.zio.CachingAccessTokenProvider.TokenWithExpirationTime +import org.polyvariant.sttp.oauth2.common.Scope +import zio.test._ +import zio.{Duration => ZDuration} +import zio.Ref +import zio.Task +import zio.ZIO + +import java.time.Instant +import scala.concurrent.duration._ + +object CachingAccessTokenProviderParallelSpec extends ZIOSpecDefault { + + private val testScope: Option[Scope] = Scope.of("test-scope") + private val token = AccessTokenResponse(Secret("secret"), None, 10.seconds, testScope) + + private val sleepDuration: FiniteDuration = 1.second + + def spec = suite("CachingAccessTokenProvider")( + test("block multiple parallel") { + prepareTest.flatMap { case (delegate, cachingProvider) => + delegate.setToken(testScope, token) *> + (cachingProvider.requestToken(testScope) zipPar cachingProvider.requestToken(testScope)).map { case (result1, result2) => + assert(result1)(Assertion.equalTo(token.copy(expiresIn = result1.expiresIn))) && + assert(result2)(Assertion.equalTo(token.copy(expiresIn = result2.expiresIn))) && + // if both calls would be made in parallel, both would get the same expiresIn from TestAccessTokenProvider. + // When blocking is in place, the second call would be delayed by sleepDuration and would hit the cache, + // which has Instant on top of which new expiresIn would be calculated + assert(diffInExpirations(result1, result2))(Assertion.isGreaterThanEqualTo(sleepDuration)) + } + } + }, + test("not block multiple parallel access if its already in cache") { + prepareTest.flatMap { case (delegate, cachingProvider) => + delegate.setToken(testScope, token) *> cachingProvider.requestToken(testScope) *> + (cachingProvider.requestToken(testScope) zipPar cachingProvider.requestToken(testScope)) map { case (result1, result2) => + assert(result1)(Assertion.equalTo(token.copy(expiresIn = result1.expiresIn))) && + assert(result2)(Assertion.equalTo(token.copy(expiresIn = result2.expiresIn))) && + // second call should not be forced to wait sleepDuration, because some active token is already in cache + assert(diffInExpirations(result1, result2))(Assertion.isLessThan(sleepDuration)) + } + } + } + ) @@ TestAspect.withLiveEnvironment + + private def diffInExpirations(result1: AccessTokenResponse, result2: AccessTokenResponse) = + if (result1.expiresIn > result2.expiresIn) result1.expiresIn - result2.expiresIn else result2.expiresIn - result1.expiresIn + + class DelayingCache[K, V](delegate: ExpiringCache[Task, K, V]) extends ExpiringCache[Task, K, V] { + override def get(key: K): Task[Option[V]] = delegate.get(key) + + override def put(key: K, value: V, expirationTime: Instant): Task[Unit] = + ZIO.sleep(ZDuration.fromScala(sleepDuration)) *> delegate.put(key, value, expirationTime) + + override def remove(key: K): Task[Unit] = delegate.remove(key) + } + + private def prepareTest = + for { + state <- Ref.make[TestAccessTokenProvider.State](TestAccessTokenProvider.State.empty) + delegate = TestAccessTokenProvider(state) + cache <- ZioRefExpiringCache[Option[Scope], TokenWithExpirationTime] + delayingCache = new DelayingCache(cache) + cachingProvider <- CachingAccessTokenProvider(delegate, delayingCache) + } yield (delegate, cachingProvider) + +} diff --git a/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderSpec.scala b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderSpec.scala new file mode 100644 index 00000000..2271d657 --- /dev/null +++ b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/CachingAccessTokenProviderSpec.scala @@ -0,0 +1,70 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import org.polyvariant.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse +import org.polyvariant.sttp.oauth2.Secret +import org.polyvariant.sttp.oauth2.cache.zio.CachingAccessTokenProvider.TokenWithExpirationTime +import org.polyvariant.sttp.oauth2.common.Scope +import zio.test._ +import zio.Ref +import zio.{Duration => ZDuration} + +import scala.concurrent.duration._ + +object CachingAccessTokenProviderSpec extends ZIOSpecDefault { + + private val testScope: Option[Scope] = Scope.of("test-scope") + private val token = AccessTokenResponse(Secret("secret"), None, 10.seconds, testScope) + private val newToken = AccessTokenResponse(Secret("secret2"), None, 20.seconds, testScope) + + def spec = suite("CachingAccessTokenProvider")( + test("delegate token retrieval on first call") { + prepareTest.flatMap { case (delegate, cachingProvider) => + for { + _ <- delegate.setToken(testScope, token) + result <- cachingProvider.requestToken(testScope) + } yield assert(result)(Assertion.equalTo(token)) + } + }, + test("decrease expiresIn in second read") { + prepareTest.flatMap { case (delegate, cachingProvider) => + for { + _ <- delegate.setToken(testScope, token) + _ <- cachingProvider.requestToken(testScope) + _ <- TestClock.adjust(ZDuration.fromScala(3.seconds)) + result <- cachingProvider.requestToken(testScope) + } yield assert(result)(Assertion.equalTo(token.copy(expiresIn = 7.seconds))) + } + }, + test("not refresh token before expiration") { + prepareTest.flatMap { case (delegate, cachingProvider) => + for { + _ <- delegate.setToken(testScope, token) + _ <- cachingProvider.requestToken(testScope) + _ <- delegate.setToken(testScope, newToken) + _ <- TestClock.adjust(ZDuration.fromScala(10.seconds - 1.milliseconds)) + result <- cachingProvider.requestToken(testScope) + } yield assert(result)(Assertion.equalTo(token.copy(expiresIn = 1.milliseconds))) + } + }, + test("ask for token again after expiration") { + prepareTest.flatMap { case (delegate, cachingProvider) => + for { + _ <- delegate.setToken(testScope, token) + _ <- cachingProvider.requestToken(testScope) + _ <- delegate.setToken(testScope, newToken) + _ <- TestClock.adjust(ZDuration.fromScala(11.seconds)) + result <- cachingProvider.requestToken(testScope) + } yield assert(result)(Assertion.equalTo(newToken)) + } + } + ) + + private def prepareTest = + for { + state <- Ref.make[TestAccessTokenProvider.State](TestAccessTokenProvider.State.empty) + delegate = TestAccessTokenProvider(state) + cache <- ZioRefExpiringCache[Option[Scope], TokenWithExpirationTime] + cachingProvider <- CachingAccessTokenProvider(delegate, cache) + } yield (delegate, cachingProvider) + +} diff --git a/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/TestAccessTokenProvider.scala b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/TestAccessTokenProvider.scala new file mode 100644 index 00000000..6de6f220 --- /dev/null +++ b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/TestAccessTokenProvider.scala @@ -0,0 +1,35 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import org.polyvariant.sttp.oauth2.common.Scope +import org.polyvariant.sttp.oauth2.AccessTokenProvider +import org.polyvariant.sttp.oauth2.ClientCredentialsToken +import org.polyvariant.sttp.oauth2.Introspection +import org.polyvariant.sttp.oauth2.Secret +import zio.Ref +import zio.Task + +trait TestAccessTokenProvider extends AccessTokenProvider[Task] { + def setToken(scope: Option[Scope], token: ClientCredentialsToken.AccessTokenResponse): Task[Unit] +} + +object TestAccessTokenProvider { + + final case class State( + tokens: Map[Option[Scope], ClientCredentialsToken.AccessTokenResponse], + introspections: Map[Secret[String], Introspection.TokenIntrospectionResponse] + ) + + object State { + val empty: State = State(Map.empty, Map.empty) + } + + def apply(ref: Ref[State]): TestAccessTokenProvider = + new TestAccessTokenProvider { + override def requestToken(scope: Option[Scope]): Task[ClientCredentialsToken.AccessTokenResponse] = + ref.get.map(_.tokens.getOrElse(scope, throw new IllegalArgumentException(s"Unknown $scope"))) + + override def setToken(scope: Option[Scope], token: ClientCredentialsToken.AccessTokenResponse): Task[Unit] = + ref.update(state => state.copy(tokens = state.tokens + (scope -> token))) + } + +} diff --git a/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCacheSpec.scala b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCacheSpec.scala new file mode 100644 index 00000000..cdf31ac1 --- /dev/null +++ b/oauth2-cache-zio/src/test/scala/org/polyvariant/ocadotechnology/sttp/oauth2/cache/zio/ZioRefExpiringCacheSpec.scala @@ -0,0 +1,60 @@ +package org.polyvariant.sttp.oauth2.cache.zio + +import zio.Clock +import zio.{Duration => ZDuration} +import zio.test._ + +import scala.concurrent.duration._ + +object ZioRefExpiringCacheSpec extends ZIOSpecDefault { + + private val someKey = "key" + private val someValue = 1 + + def spec = suite("Cache")( + test("return nothing on empty cache") { + for { + cache <- ZioRefExpiringCache[String, Int] + value <- cache.get(someKey) + } yield assert(value)(Assertion.isNone) + }, + test("store and retrieve value immediately") { + for { + cache <- ZioRefExpiringCache[String, Int] + now <- Clock.instant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + value <- cache.get(someKey) + } yield assert(value)(Assertion.isSome(Assertion.equalTo(someValue))) + }, + test("return value right before expiration boundary") { + for { + cache <- ZioRefExpiringCache[String, Int] + now <- Clock.instant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + _ <- TestClock.adjust(ZDuration.fromScala(60.seconds - 1.nano)) + value <- cache.get(someKey) + } yield assert(value)(Assertion.isSome(Assertion.equalTo(someValue))) + }, + test("not return value if expired") { + for { + cache <- ZioRefExpiringCache[String, Int] + now <- Clock.instant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + _ <- TestClock.adjust(ZDuration.fromScala(60.seconds)) + value <- cache.get(someKey) + } yield assert(value)(Assertion.isNone) + }, + test("remove value on expired get") { + for { + cache <- ZioRefExpiringCache[String, Int] + now <- Clock.instant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + _ <- TestClock.adjust(ZDuration.fromScala(60.seconds)) + value1 <- cache.get(someKey) // this call should remove expired value from cache + _ <- TestClock.adjust(ZDuration.fromScala(-10.seconds)) // travel back in time, so in fact token is not expired yet + value2 <- cache.get(someKey) + } yield assert(value1)(Assertion.isNone) && assert(value2)(Assertion.isNone) + } + ) + +} diff --git a/project/build.properties b/project/build.properties index 6a9f0388..abbbce5d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.7.3 +sbt.version=1.9.8