Skip to content

Commit 6cbfc11

Browse files
majk-pFristi
andauthored
Feature/zio (#487)
* ZIO support * Build process fixes --------- Co-authored-by: Fristi <[email protected]>
1 parent 514197b commit 6cbfc11

File tree

11 files changed

+398
-22
lines changed

11 files changed

+398
-22
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
strategy:
2828
matrix:
2929
os: [ubuntu-latest]
30-
scala: [2.12.19, 2.13.13, 3.2.2]
30+
scala: [2.12.19, 2.13.13, 3.3.3]
3131
3232
runs-on: ${{ matrix.os }}
3333
steps:
@@ -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-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
62+
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
6363

6464
- name: Upload target directories
6565
uses: actions/upload-artifact@v2
@@ -120,12 +120,12 @@ jobs:
120120
tar xf targets.tar
121121
rm targets.tar
122122
123-
- name: Download target directories (3.2.2)
123+
- name: Download target directories (3.3.3)
124124
uses: actions/download-artifact@v2
125125
with:
126-
name: target-${{ matrix.os }}-3.2.2-${{ matrix.java }}
126+
name: target-${{ matrix.os }}-3.3.3-${{ matrix.java }}
127127

128-
- name: Inflate target directories (3.2.2)
128+
- name: Inflate target directories (3.3.3)
129129
run: |
130130
tar xf targets.tar
131131
rm targets.tar

build.sbt

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(Cros
2121

2222
val Scala212 = "2.12.19"
2323
val Scala213 = "2.13.13"
24-
val Scala3 = "3.2.2"
24+
val Scala3 = "3.3.3"
2525

2626
val GraalVM11 = "[email protected]"
2727

@@ -62,7 +62,11 @@ val Versions = new {
6262

6363
def compilerPlugins =
6464
libraryDependencies ++= (if (scalaVersion.value.startsWith("3")) Seq()
65-
else Seq(compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")))
65+
else
66+
Seq(
67+
compilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full),
68+
compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")
69+
))
6670

6771
val mimaSettings =
6872
// revert the commit that made this change after releasing a new version
@@ -79,9 +83,6 @@ val mimaSettings =
7983
// }
8084
mimaPreviousArtifacts := Set.empty
8185

82-
// Workaround for https://github.com/typelevel/sbt-tpolecat/issues/102
83-
val jsSettings = scalacOptions ++= (if (scalaVersion.value.startsWith("3")) Seq("-scalajs") else Seq())
84-
8586
lazy val oauth2 = crossProject(JSPlatform, JVMPlatform)
8687
.withoutSuffixFor(JVMPlatform)
8788
.settings(
@@ -96,8 +97,7 @@ lazy val oauth2 = crossProject(JSPlatform, JVMPlatform)
9697
compilerPlugins
9798
)
9899
.jsSettings(
99-
libraryDependencies ++= Seq("org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0"),
100-
jsSettings
100+
libraryDependencies ++= Seq("org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0")
101101
)
102102

103103
lazy val `oauth2-circe` = crossProject(JSPlatform, JVMPlatform)
@@ -113,9 +113,6 @@ lazy val `oauth2-circe` = crossProject(JSPlatform, JVMPlatform)
113113
mimaSettings,
114114
compilerPlugins
115115
)
116-
.jsSettings(
117-
jsSettings
118-
)
119116
.dependsOn(oauth2 % "compile->compile;test->test")
120117

121118
lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform)
@@ -131,16 +128,15 @@ lazy val `oauth2-jsoniter` = crossProject(JSPlatform, JVMPlatform)
131128
compilerPlugins,
132129
scalacOptions ++= Seq("-Wconf:cat=deprecation:info") // jsoniter-scala macro-generated code uses deprecated methods
133130
)
134-
.jsSettings(
135-
jsSettings
136-
)
137131
.dependsOn(oauth2 % "compile->compile;test->test")
138132

139133
lazy val docs = project
140134
.in(file("mdoc")) // important: it must not be docs/
141135
.settings(
142136
mdocVariables := Map(
143-
"VERSION" -> { if (isSnapshot.value) previousStableVersion.value.get else version.value }
137+
"VERSION" -> {
138+
if (isSnapshot.value) previousStableVersion.value.get else version.value
139+
}
144140
)
145141
)
146142
.dependsOn(oauth2.jvm)
@@ -153,7 +149,6 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform)
153149
mimaSettings,
154150
compilerPlugins
155151
)
156-
.jsSettings(jsSettings)
157152
.dependsOn(oauth2)
158153

159154
// 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
204199
)
205200
.dependsOn(`oauth2-cache`.jvm)
206201

202+
lazy val `oauth2-cache-zio` = project
203+
.settings(
204+
name := "sttp-oauth2-cache-zio",
205+
libraryDependencies ++= Seq(
206+
"dev.zio" %% "zio" % "2.1.1",
207+
"dev.zio" %% "zio-test" % "2.1.1" % Test,
208+
"dev.zio" %% "zio-test-sbt" % "2.1.1" % Test
209+
),
210+
mimaSettings,
211+
compilerPlugins,
212+
scalacOptions -= "-Ykind-projector",
213+
scalacOptions ++= (
214+
if (scalaVersion.value.startsWith("3")) Seq("-Ykind-projector:underscores")
215+
else Seq("-P:kind-projector:underscore-placeholders")
216+
)
217+
)
218+
.dependsOn(`oauth2-cache`.jvm)
219+
207220
lazy val `oauth2-cache-future` = crossProject(JSPlatform, JVMPlatform)
208221
.withoutSuffixFor(JVMPlatform)
209222
.settings(
@@ -215,7 +228,6 @@ lazy val `oauth2-cache-future` = crossProject(JSPlatform, JVMPlatform)
215228
mimaSettings,
216229
compilerPlugins
217230
)
218-
.jsSettings(jsSettings)
219231
.dependsOn(`oauth2-cache`)
220232

221233
val root = project
@@ -232,6 +244,7 @@ val root = project
232244
`oauth2-cache`.js,
233245
`oauth2-cache-cats`,
234246
`oauth2-cache-ce2`,
247+
`oauth2-cache-zio`,
235248
`oauth2-cache-future`.jvm,
236249
`oauth2-cache-future`.js,
237250
`oauth2-cache-scalacache`,

docs/caching.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ As the user of the library you can either choose to implement your own cache mec
2121

2222
| Class |Description | Import module |
2323
|---------------------------|-------------------------------------------------------------|-------------------|
24+
| `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@"` |
2425
| `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@"` |
2526
| `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@"` |
2627
| `ScalacacheExpiringCache` | Implementation based on https://github.com/cb372/scalacache | `"org.polyvariant" %% "sttp-oauth2-cache-scalacache" % "@VERSION@"` |

docs/client-credentials.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Caching modules provide cached `AccessTokenProvider`, which can:
3434
|----------------------------|------------------------------------|---------------------------------|--------------------------------------|-------------------------------------------------|
3535
| `sttp-oauth2-cache-cats` | `CachingAccessTokenProvider` | `cats-effect3`'s `Ref` | `cats-effect2`'s `Semaphore` | |
3636
| `sttp-oauth2-cache-ce2` | `CachingAccessTokenProvider` | `cats-effect2`'s `Ref` | `cats-effect2`'s `Semaphore` | |
37+
| `sttp-oauth2-cache-zio` | `CachingAccessTokenProvider` | `zio`'s `Ref` | `zio`'s `Semaphore` | |
3738
| `sttp-oauth2-cache-future` | `FutureCachingAccessTokenProvider` | `monix-execution`'s `AtomicAny` | `monix-execution`'s `AsyncSemaphore` | It only uses submodule of whole `monix` project |
3839

3940
### Cats example
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package org.polyvariant.sttp.oauth2.cache.zio
2+
3+
import org.polyvariant.sttp.oauth2.AccessTokenProvider
4+
import org.polyvariant.sttp.oauth2.ClientCredentialsToken
5+
import org.polyvariant.sttp.oauth2.Secret
6+
import org.polyvariant.sttp.oauth2.cache.ExpiringCache
7+
import org.polyvariant.sttp.oauth2.cache.zio.CachingAccessTokenProvider.TokenWithExpirationTime
8+
import org.polyvariant.sttp.oauth2.common.Scope
9+
import zio.Clock
10+
import zio.Semaphore
11+
import zio._
12+
13+
import java.time.Instant
14+
import scala.concurrent.duration.Duration
15+
16+
final class CachingAccessTokenProvider[R](
17+
delegate: AccessTokenProvider[RIO[R, _]],
18+
semaphore: Semaphore,
19+
tokenCache: ExpiringCache[RIO[R, _], Option[Scope], TokenWithExpirationTime]
20+
) extends AccessTokenProvider[RIO[R, _]] {
21+
22+
override def requestToken(scope: Option[Scope]): RIO[R, ClientCredentialsToken.AccessTokenResponse] =
23+
getFromCache(scope).flatMap {
24+
case Some(value) => ZIO.succeed(value)
25+
case None => semaphore.withPermit(acquireToken(scope))
26+
}
27+
28+
private def acquireToken(scope: Option[Scope]): ZIO[R, Throwable, ClientCredentialsToken.AccessTokenResponse] =
29+
getFromCache(scope).flatMap {
30+
case Some(value) => ZIO.succeed(value)
31+
case None => fetchAndSaveToken(scope)
32+
}
33+
34+
private def getFromCache(scope: Option[Scope]) =
35+
tokenCache.get(scope).flatMap { entry =>
36+
Clock.instant.map { now =>
37+
entry match {
38+
case Some(value) => Some(value.toAccessTokenResponse(now))
39+
case None => None
40+
}
41+
}
42+
}
43+
44+
private def fetchAndSaveToken(scope: Option[Scope]) =
45+
for {
46+
token <- delegate.requestToken(scope)
47+
tokenWithExpiry <- calculateExpiryInstant(token)
48+
_ <- tokenCache.put(scope, tokenWithExpiry, tokenWithExpiry.expirationTime)
49+
} yield token
50+
51+
private def calculateExpiryInstant(response: ClientCredentialsToken.AccessTokenResponse) =
52+
Clock.instant.map(TokenWithExpirationTime.from(response, _))
53+
54+
}
55+
56+
object CachingAccessTokenProvider {
57+
58+
def apply[R](
59+
delegate: AccessTokenProvider[RIO[R, _]],
60+
tokenCache: ExpiringCache[RIO[R, _], Option[Scope], TokenWithExpirationTime]
61+
): RIO[R, CachingAccessTokenProvider[R]] = Semaphore.make(permits = 1).map(new CachingAccessTokenProvider(delegate, _, tokenCache))
62+
63+
def refCacheInstance(delegate: AccessTokenProvider[Task]): Task[CachingAccessTokenProvider[Any]] =
64+
ZioRefExpiringCache[Option[Scope], TokenWithExpirationTime].flatMap(CachingAccessTokenProvider(delegate, _))
65+
66+
final case class TokenWithExpirationTime(
67+
accessToken: Secret[String],
68+
domain: Option[String],
69+
expirationTime: Instant,
70+
scope: Option[Scope]
71+
) {
72+
73+
def toAccessTokenResponse(now: Instant): ClientCredentialsToken.AccessTokenResponse = {
74+
val newExpiresIn = Duration.fromNanos(java.time.Duration.between(now, expirationTime).toNanos)
75+
ClientCredentialsToken.AccessTokenResponse(accessToken, domain, newExpiresIn, scope)
76+
}
77+
78+
}
79+
80+
object TokenWithExpirationTime {
81+
82+
def from(token: ClientCredentialsToken.AccessTokenResponse, now: Instant): TokenWithExpirationTime = {
83+
val expirationTime = now.plusNanos(token.expiresIn.toNanos)
84+
TokenWithExpirationTime(token.accessToken, token.domain, expirationTime, token.scope)
85+
}
86+
87+
}
88+
89+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.polyvariant.sttp.oauth2.cache.zio
2+
3+
import org.polyvariant.sttp.oauth2.cache.ExpiringCache
4+
import org.polyvariant.sttp.oauth2.cache.zio.ZioRefExpiringCache.Entry
5+
import zio.Clock
6+
import zio.Ref
7+
import zio.Task
8+
import zio.ZIO
9+
10+
import java.time.Instant
11+
12+
final class ZioRefExpiringCache[K, V] private (ref: Ref[Map[K, Entry[V]]]) extends ExpiringCache[Task, K, V] {
13+
14+
override def get(key: K): Task[Option[V]] =
15+
ref.get.map(_.get(key)).flatMap { entry =>
16+
Clock.instant.flatMap { now =>
17+
(entry, now) match {
18+
case (Some(Entry(value, expiryInstant)), now) =>
19+
if (now.isBefore(expiryInstant)) ZIO.succeed(Some(value)) else remove(key).as(None)
20+
case _ =>
21+
ZIO.none
22+
}
23+
}
24+
}
25+
26+
override def put(key: K, value: V, expirationTime: Instant): Task[Unit] = ref.update(_ + (key -> Entry(value, expirationTime)))
27+
28+
override def remove(key: K): Task[Unit] = ref.update(_ - key)
29+
}
30+
31+
object ZioRefExpiringCache {
32+
private final case class Entry[V](value: V, expirationTime: Instant)
33+
34+
def apply[K, V]: Task[ExpiringCache[Task, K, V]] = Ref.make(Map.empty[K, Entry[V]]).map(new ZioRefExpiringCache(_))
35+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.polyvariant.sttp.oauth2.cache.zio
2+
3+
import org.polyvariant.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse
4+
import org.polyvariant.sttp.oauth2.Secret
5+
import org.polyvariant.sttp.oauth2.cache.ExpiringCache
6+
import org.polyvariant.sttp.oauth2.cache.zio.CachingAccessTokenProvider.TokenWithExpirationTime
7+
import org.polyvariant.sttp.oauth2.common.Scope
8+
import zio.test._
9+
import zio.{Duration => ZDuration}
10+
import zio.Ref
11+
import zio.Task
12+
import zio.ZIO
13+
14+
import java.time.Instant
15+
import scala.concurrent.duration._
16+
17+
object CachingAccessTokenProviderParallelSpec extends ZIOSpecDefault {
18+
19+
private val testScope: Option[Scope] = Scope.of("test-scope")
20+
private val token = AccessTokenResponse(Secret("secret"), None, 10.seconds, testScope)
21+
22+
private val sleepDuration: FiniteDuration = 1.second
23+
24+
def spec = suite("CachingAccessTokenProvider")(
25+
test("block multiple parallel") {
26+
prepareTest.flatMap { case (delegate, cachingProvider) =>
27+
delegate.setToken(testScope, token) *>
28+
(cachingProvider.requestToken(testScope) zipPar cachingProvider.requestToken(testScope)).map { case (result1, result2) =>
29+
assert(result1)(Assertion.equalTo(token.copy(expiresIn = result1.expiresIn))) &&
30+
assert(result2)(Assertion.equalTo(token.copy(expiresIn = result2.expiresIn))) &&
31+
// if both calls would be made in parallel, both would get the same expiresIn from TestAccessTokenProvider.
32+
// When blocking is in place, the second call would be delayed by sleepDuration and would hit the cache,
33+
// which has Instant on top of which new expiresIn would be calculated
34+
assert(diffInExpirations(result1, result2))(Assertion.isGreaterThanEqualTo(sleepDuration))
35+
}
36+
}
37+
},
38+
test("not block multiple parallel access if its already in cache") {
39+
prepareTest.flatMap { case (delegate, cachingProvider) =>
40+
delegate.setToken(testScope, token) *> cachingProvider.requestToken(testScope) *>
41+
(cachingProvider.requestToken(testScope) zipPar cachingProvider.requestToken(testScope)) map { case (result1, result2) =>
42+
assert(result1)(Assertion.equalTo(token.copy(expiresIn = result1.expiresIn))) &&
43+
assert(result2)(Assertion.equalTo(token.copy(expiresIn = result2.expiresIn))) &&
44+
// second call should not be forced to wait sleepDuration, because some active token is already in cache
45+
assert(diffInExpirations(result1, result2))(Assertion.isLessThan(sleepDuration))
46+
}
47+
}
48+
}
49+
) @@ TestAspect.withLiveEnvironment
50+
51+
private def diffInExpirations(result1: AccessTokenResponse, result2: AccessTokenResponse) =
52+
if (result1.expiresIn > result2.expiresIn) result1.expiresIn - result2.expiresIn else result2.expiresIn - result1.expiresIn
53+
54+
class DelayingCache[K, V](delegate: ExpiringCache[Task, K, V]) extends ExpiringCache[Task, K, V] {
55+
override def get(key: K): Task[Option[V]] = delegate.get(key)
56+
57+
override def put(key: K, value: V, expirationTime: Instant): Task[Unit] =
58+
ZIO.sleep(ZDuration.fromScala(sleepDuration)) *> delegate.put(key, value, expirationTime)
59+
60+
override def remove(key: K): Task[Unit] = delegate.remove(key)
61+
}
62+
63+
private def prepareTest =
64+
for {
65+
state <- Ref.make[TestAccessTokenProvider.State](TestAccessTokenProvider.State.empty)
66+
delegate = TestAccessTokenProvider(state)
67+
cache <- ZioRefExpiringCache[Option[Scope], TokenWithExpirationTime]
68+
delayingCache = new DelayingCache(cache)
69+
cachingProvider <- CachingAccessTokenProvider(delegate, delayingCache)
70+
} yield (delegate, cachingProvider)
71+
72+
}

0 commit comments

Comments
 (0)