Skip to content

Commit d97bc84

Browse files
authored
Implement expiring cache based on scalacache (#299)
* implement expiring cache based on scalacache * improve caching docs
1 parent 6848cac commit d97bc84

File tree

5 files changed

+171
-3
lines changed

5 files changed

+171
-3
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- run: sbt ++${{ matrix.scala }} test docs/mdoc mimaReportBinaryIssues
6060

6161
- name: Compress target directories
62-
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
62+
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
6363

6464
- name: Upload target directories
6565
uses: actions/upload-artifact@v2

build.sbt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ val Versions = new {
6666
val scalaTest = "3.2.13"
6767
val sttp = "3.3.18"
6868
val refined = "0.10.1"
69+
val scalaCache = "1.0.0-M6"
6970
}
7071

7172
def compilerPlugins =
@@ -130,6 +131,24 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform)
130131
.jsSettings(jsSettings)
131132
.dependsOn(oauth2)
132133

134+
// oauth2-cache-scalacache doesn't have JS support because scalacache doesn't compile for js https://github.com/cb372/scalacache/issues/354#issuecomment-913024231
135+
lazy val `oauth2-cache-scalacache` = project
136+
.settings(
137+
name := "sttp-oauth2-cache-scalacache",
138+
libraryDependencies ++= Seq(
139+
"com.github.cb372" %%% "scalacache-core" % Versions.scalaCache,
140+
"com.github.cb372" %% "scalacache-caffeine" % Versions.scalaCache % Test,
141+
"org.typelevel" %%% "cats-effect-kernel" % Versions.catsEffect,
142+
"org.typelevel" %%% "cats-effect-std" % Versions.catsEffect,
143+
"org.typelevel" %%% "cats-effect" % Versions.catsEffect % Test,
144+
"org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test,
145+
"org.scalatest" %%% "scalatest" % Versions.scalaTest % Test
146+
),
147+
mimaPreviousArtifacts := Set.empty,
148+
compilerPlugins
149+
)
150+
.dependsOn(`oauth2-cache`.jvm)
151+
133152
// oauth2-cache-cats doesn't have JS support because cats effect does not provide realTimeInstant on JS
134153
lazy val `oauth2-cache-cats` = project
135154
.settings(
@@ -141,7 +160,7 @@ lazy val `oauth2-cache-cats` = project
141160
"org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test,
142161
"org.scalatest" %%% "scalatest" % Versions.scalaTest % Test
143162
),
144-
mimaPreviousArtifacts := Set.empty,
163+
mimaSettings,
145164
compilerPlugins
146165
)
147166
.dependsOn(`oauth2-cache`.jvm)
@@ -189,5 +208,6 @@ val root = project
189208
`oauth2-cache-cats`,
190209
`oauth2-cache-ce2`,
191210
`oauth2-cache-future`.jvm,
192-
`oauth2-cache-future`.js
211+
`oauth2-cache-future`.js,
212+
`oauth2-cache-scalacache`
193213
)

docs/caching.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
sidebar_position: 6
3+
description: Caching
4+
---
5+
6+
# Caching
7+
8+
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:
9+
10+
```scala
11+
trait ExpiringCache[F[_], K, V] {
12+
def get(key: K): F[Option[V]]
13+
14+
def put(key: K, value: V, expirationTime: Instant): F[Unit]
15+
16+
def remove(key: K): F[Unit]
17+
}
18+
```
19+
20+
As the user of the library you can either choose to implement your own cache mechanism, or go for one of the provided:
21+
22+
| Class |Description | Import module |
23+
|---------------------------|-------------------------------------------------------------|-------------------|
24+
| `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@"` |
25+
| `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@"` |
26+
| `ScalacacheExpiringCache` | Implementation based on https://github.com/cb372/scalacache | `"com.ocadotechnology" %% "sttp-oauth2-cache-scalacache" % "@VERSION@"` |
27+
| `MonixFutureCache` | Future based implementation powered by [Monix](https://monix.io/) | `"com.ocadotechnology" %% "sttp-oauth2-cache-future" % "@VERSION@"` |
28+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.ocadotechnology.sttp.oauth2.cache.scalacache
2+
3+
import cats.effect.Async
4+
import cats.effect.kernel.Clock
5+
import cats.implicits._
6+
import com.ocadotechnology.sttp.oauth2.cache.ExpiringCache
7+
import scalacache._
8+
9+
import java.time.Instant
10+
import java.util.concurrent.TimeUnit
11+
import scala.concurrent.duration.FiniteDuration
12+
13+
final class ScalacacheExpiringCache[F[_]: Async, K, V](cache: Cache[F, K, V]) extends ExpiringCache[F, K, V] {
14+
15+
override def get(key: K): F[Option[V]] =
16+
cache.get(key)
17+
18+
override def put(key: K, value: V, expirationTime: Instant): F[Unit] =
19+
for {
20+
now <- Clock[F].realTimeInstant
21+
ttl = calculateTTL(expirationTime, now)
22+
_ <- cache.put(key)(value, Some(ttl)).void
23+
} yield ()
24+
25+
override def remove(key: K): F[Unit] =
26+
cache.remove(key).void
27+
28+
private def calculateTTL(expirationTime: Instant, now: Instant): FiniteDuration =
29+
if (expirationTime isAfter now)
30+
FiniteDuration(expirationTime.toEpochMilli() - now.toEpochMilli(), TimeUnit.MILLISECONDS)
31+
else FiniteDuration(0, TimeUnit.MILLISECONDS)
32+
33+
}
34+
35+
object ScalacacheExpiringCache {
36+
37+
def apply[F[_]: Async, K, V](cache: Cache[F, K, V]): ExpiringCache[F, K, V] =
38+
new ScalacacheExpiringCache[F, K, V](cache)
39+
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.ocadotechnology.sttp.oauth2.cache.scalacache
2+
3+
import cats.effect.IO
4+
import cats.effect.kernel.Outcome.Succeeded
5+
import cats.effect.testkit.TestContext
6+
import cats.effect.testkit.TestInstances
7+
import org.scalatest.Assertion
8+
import org.scalatest.matchers.should.Matchers
9+
import org.scalatest.wordspec.AnyWordSpec
10+
11+
import scalacache.caffeine._
12+
13+
import scala.concurrent.duration._
14+
15+
class ScalacacheExpiringCacheSpec extends AnyWordSpec with Matchers with TestInstances {
16+
private implicit val ticker: Ticker = Ticker(TestContext())
17+
18+
private val someKey = "key"
19+
private val someValue = 1
20+
21+
def runTest(test: IO[Assertion]): Assertion =
22+
unsafeRun(test) match {
23+
case Succeeded(Some(assertion)) => assertion
24+
case wrongResult => fail(s"Test should finish successfully. Instead ended with $wrongResult")
25+
}
26+
27+
"Cache" should {
28+
"return nothing on empty cache" in unsafeRun {
29+
for {
30+
cacheBackend <- CaffeineCache[IO, String, Int]
31+
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
32+
value <- cache.get(someKey)
33+
} yield value
34+
}.shouldBe(Succeeded(Some(None)))
35+
36+
"store and retrieve value immediately" in unsafeRun {
37+
for {
38+
cacheBackend <- CaffeineCache[IO, String, Int]
39+
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
40+
now <- IO.realTimeInstant
41+
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
42+
value <- cache.get(someKey)
43+
} yield value
44+
}.shouldBe(Succeeded(Some(Some(someValue))))
45+
46+
"return value right before expiration boundary" in unsafeRun {
47+
for {
48+
cacheBackend <- CaffeineCache[IO, String, Int]
49+
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
50+
now <- IO.realTimeInstant
51+
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
52+
_ <- IO.sleep(60.seconds - 1.nano)
53+
value <- cache.get(someKey)
54+
} yield value
55+
}.shouldBe(Succeeded(Some(Some(someValue))))
56+
57+
"not return value if expired" in unsafeRun {
58+
for {
59+
cacheBackend <- CaffeineCache[IO, String, Int]
60+
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
61+
now <- IO.realTimeInstant
62+
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
63+
_ <- IO.sleep(60.seconds)
64+
value <- cache.get(someKey)
65+
} yield value
66+
}.shouldBe(Succeeded(Some(None)))
67+
68+
"remove value when expired" in unsafeRun {
69+
for {
70+
cacheBackend <- CaffeineCache[IO, String, Int]
71+
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
72+
now <- IO.realTimeInstant
73+
_ <- cache.put(someKey, someValue, now.plusSeconds(1))
74+
_ <- IO.sleep(3.seconds)
75+
value <- cache.get(someKey)
76+
} yield value
77+
}.shouldBe(Succeeded(Some(None)))
78+
79+
}
80+
}

0 commit comments

Comments
 (0)