diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e97391..2de27c73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - run: sbt ++${{ matrix.scala }} test docs/mdoc mimaReportBinaryIssues - name: Compress target directories - run: tar cf targets.tar oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target target mdoc/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target + run: tar cf targets.tar oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target target oauth2-cache-scalacache/target mdoc/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target - name: Upload target directories uses: actions/upload-artifact@v2 diff --git a/build.sbt b/build.sbt index 88ab16bf..b9b43c97 100644 --- a/build.sbt +++ b/build.sbt @@ -66,6 +66,7 @@ val Versions = new { val scalaTest = "3.2.13" val sttp = "3.3.18" val refined = "0.10.1" + val scalaCache = "1.0.0-M6" } def compilerPlugins = @@ -130,6 +131,24 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform) .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 +lazy val `oauth2-cache-scalacache` = project + .settings( + name := "sttp-oauth2-cache-scalacache", + libraryDependencies ++= Seq( + "com.github.cb372" %%% "scalacache-core" % Versions.scalaCache, + "com.github.cb372" %% "scalacache-caffeine" % Versions.scalaCache % Test, + "org.typelevel" %%% "cats-effect-kernel" % Versions.catsEffect, + "org.typelevel" %%% "cats-effect-std" % Versions.catsEffect, + "org.typelevel" %%% "cats-effect" % Versions.catsEffect % Test, + "org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test, + "org.scalatest" %%% "scalatest" % Versions.scalaTest % Test + ), + mimaPreviousArtifacts := Set.empty, + compilerPlugins + ) + .dependsOn(`oauth2-cache`.jvm) + // oauth2-cache-cats doesn't have JS support because cats effect does not provide realTimeInstant on JS lazy val `oauth2-cache-cats` = project .settings( @@ -141,7 +160,7 @@ lazy val `oauth2-cache-cats` = project "org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test, "org.scalatest" %%% "scalatest" % Versions.scalaTest % Test ), - mimaPreviousArtifacts := Set.empty, + mimaSettings, compilerPlugins ) .dependsOn(`oauth2-cache`.jvm) @@ -189,5 +208,6 @@ val root = project `oauth2-cache-cats`, `oauth2-cache-ce2`, `oauth2-cache-future`.jvm, - `oauth2-cache-future`.js + `oauth2-cache-future`.js, + `oauth2-cache-scalacache` ) diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 00000000..0f143726 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,28 @@ +--- +sidebar_position: 6 +description: Caching +--- + +# Caching + +The sttp-oauth2 library comes with `CachingAccessTokenProvider` and `CachingTokenIntrospection` - interfaces that allow caching the responses provided by the OAuth2 provider. Both of those require an implementation of the `ExpiringCache` algebra, defined as follows: + +```scala +trait ExpiringCache[F[_], K, V] { + def get(key: K): F[Option[V]] + + def put(key: K, value: V, expirationTime: Instant): F[Unit] + + def remove(key: K): F[Unit] +} +``` + +As the user of the library you can either choose to implement your own cache mechanism, or go for one of the provided: + +| Class |Description | Import module | +|---------------------------|-------------------------------------------------------------|-------------------| +| `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) | `"com.ocadotechnology" %% "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) | `"com.ocadotechnology" %% "sttp-oauth2-cache-ce2" % "@VERSION@"` | +| `ScalacacheExpiringCache` | Implementation based on https://github.com/cb372/scalacache | `"com.ocadotechnology" %% "sttp-oauth2-cache-scalacache" % "@VERSION@"` | +| `MonixFutureCache` | Future based implementation powered by [Monix](https://monix.io/) | `"com.ocadotechnology" %% "sttp-oauth2-cache-future" % "@VERSION@"` | + diff --git a/oauth2-cache-scalacache/src/main/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCache.scala b/oauth2-cache-scalacache/src/main/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCache.scala new file mode 100644 index 00000000..18a23a53 --- /dev/null +++ b/oauth2-cache-scalacache/src/main/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCache.scala @@ -0,0 +1,40 @@ +package com.ocadotechnology.sttp.oauth2.cache.scalacache + +import cats.effect.Async +import cats.effect.kernel.Clock +import cats.implicits._ +import com.ocadotechnology.sttp.oauth2.cache.ExpiringCache +import scalacache._ + +import java.time.Instant +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration + +final class ScalacacheExpiringCache[F[_]: Async, K, V](cache: Cache[F, K, V]) extends ExpiringCache[F, K, V] { + + override def get(key: K): F[Option[V]] = + cache.get(key) + + override def put(key: K, value: V, expirationTime: Instant): F[Unit] = + for { + now <- Clock[F].realTimeInstant + ttl = calculateTTL(expirationTime, now) + _ <- cache.put(key)(value, Some(ttl)).void + } yield () + + override def remove(key: K): F[Unit] = + cache.remove(key).void + + private def calculateTTL(expirationTime: Instant, now: Instant): FiniteDuration = + if (expirationTime isAfter now) + FiniteDuration(expirationTime.toEpochMilli() - now.toEpochMilli(), TimeUnit.MILLISECONDS) + else FiniteDuration(0, TimeUnit.MILLISECONDS) + +} + +object ScalacacheExpiringCache { + + def apply[F[_]: Async, K, V](cache: Cache[F, K, V]): ExpiringCache[F, K, V] = + new ScalacacheExpiringCache[F, K, V](cache) + +} diff --git a/oauth2-cache-scalacache/src/test/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCacheSpec.scala b/oauth2-cache-scalacache/src/test/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCacheSpec.scala new file mode 100644 index 00000000..0cdf2d15 --- /dev/null +++ b/oauth2-cache-scalacache/src/test/scala/com/ocadotechnology/sttp/oauth2/cache/scalacache/ScalacacheExpiringCacheSpec.scala @@ -0,0 +1,80 @@ +package com.ocadotechnology.sttp.oauth2.cache.scalacache + +import cats.effect.IO +import cats.effect.kernel.Outcome.Succeeded +import cats.effect.testkit.TestContext +import cats.effect.testkit.TestInstances +import org.scalatest.Assertion +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import scalacache.caffeine._ + +import scala.concurrent.duration._ + +class ScalacacheExpiringCacheSpec extends AnyWordSpec with Matchers with TestInstances { + private implicit val ticker: Ticker = Ticker(TestContext()) + + private val someKey = "key" + private val someValue = 1 + + def runTest(test: IO[Assertion]): Assertion = + unsafeRun(test) match { + case Succeeded(Some(assertion)) => assertion + case wrongResult => fail(s"Test should finish successfully. Instead ended with $wrongResult") + } + + "Cache" should { + "return nothing on empty cache" in unsafeRun { + for { + cacheBackend <- CaffeineCache[IO, String, Int] + cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend) + value <- cache.get(someKey) + } yield value + }.shouldBe(Succeeded(Some(None))) + + "store and retrieve value immediately" in unsafeRun { + for { + cacheBackend <- CaffeineCache[IO, String, Int] + cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend) + now <- IO.realTimeInstant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + value <- cache.get(someKey) + } yield value + }.shouldBe(Succeeded(Some(Some(someValue)))) + + "return value right before expiration boundary" in unsafeRun { + for { + cacheBackend <- CaffeineCache[IO, String, Int] + cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend) + now <- IO.realTimeInstant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + _ <- IO.sleep(60.seconds - 1.nano) + value <- cache.get(someKey) + } yield value + }.shouldBe(Succeeded(Some(Some(someValue)))) + + "not return value if expired" in unsafeRun { + for { + cacheBackend <- CaffeineCache[IO, String, Int] + cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend) + now <- IO.realTimeInstant + _ <- cache.put(someKey, someValue, now.plusSeconds(60)) + _ <- IO.sleep(60.seconds) + value <- cache.get(someKey) + } yield value + }.shouldBe(Succeeded(Some(None))) + + "remove value when expired" in unsafeRun { + for { + cacheBackend <- CaffeineCache[IO, String, Int] + cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend) + now <- IO.realTimeInstant + _ <- cache.put(someKey, someValue, now.plusSeconds(1)) + _ <- IO.sleep(3.seconds) + value <- cache.get(someKey) + } yield value + }.shouldBe(Succeeded(Some(None))) + + } +}