Skip to content

Commit

Permalink
Feature/zio (#487)
Browse files Browse the repository at this point in the history
* ZIO support
* Build process fixes

---------

Co-authored-by: Fristi <mark@vectos.net>
  • Loading branch information
majk-p and Fristi authored May 21, 2024
1 parent 514197b commit 6cbfc11
Show file tree
Hide file tree
Showing 11 changed files with 398 additions and 22 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 29 additions & 16 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -215,7 +228,6 @@ lazy val `oauth2-cache-future` = crossProject(JSPlatform, JVMPlatform)
mimaSettings,
compilerPlugins
)
.jsSettings(jsSettings)
.dependsOn(`oauth2-cache`)

val root = project
Expand All @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@"` |
Expand Down
1 change: 1 addition & 0 deletions docs/client-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

}

}
Original file line number Diff line number Diff line change
@@ -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(_))
}
Original file line number Diff line number Diff line change
@@ -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)

}
Loading

0 comments on commit 6cbfc11

Please sign in to comment.