Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add oauth2-backend-cats #46

Merged
merged 3 commits into from
Mar 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
- run: sbt ++${{ matrix.scala }} test mimaReportBinaryIssues

- name: Compress target directories
run: tar cf targets.tar target oauth2/target project/target
run: tar cf targets.tar target oauth2/target oauth2-backend-cats/target project/target

- name: Upload target directories
uses: actions/upload-artifact@v2
Expand Down
67 changes: 30 additions & 37 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -57,64 +57,57 @@ ThisBuild / githubWorkflowEnv ++= List("PGP_PASSPHRASE", "PGP_SECRET", "SONATYPE

val Versions = new {
val catsCore = "2.4.2"
val catsEffect = "2.3.1"
val circe = "0.13.0"
val kindProjector = "0.11.3"
val scalaTest = "3.2.5"
val sttp = "3.1.7"
val refined = "0.9.21"
}

val commonDependencies = {

val cats = Seq(
"org.typelevel" %% "cats-core" % Versions.catsCore
)

val circe = Seq(
"io.circe" %% "circe-parser" % Versions.circe,
"io.circe" %% "circe-core" % Versions.circe,
"io.circe" %% "circe-refined" % Versions.circe
)

val plugins = Seq(
compilerPlugin("org.typelevel" % "kind-projector" % Versions.kindProjector cross CrossVersion.full)
)

val sttp = Seq(
"com.softwaremill.sttp.client3" %% "core" % Versions.sttp,
"com.softwaremill.sttp.client3" %% "circe" % Versions.sttp
)

val refined = Seq(
"eu.timepit" %% "refined" % Versions.refined
)

cats ++ circe ++ sttp ++ refined ++ plugins
}

val oauth2Dependencies = {
val testDependencies = Seq(
"org.scalatest" %% "scalatest" % Versions.scalaTest,
"io.circe" %% "circe-literal" % Versions.circe
).map(_ % Test)
val plugins = Seq(
compilerPlugin("org.typelevel" % "kind-projector" % Versions.kindProjector cross CrossVersion.full),
compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")
)

commonDependencies ++ testDependencies
}
val testDependencies = Seq(
"org.scalatest" %% "scalatest" % Versions.scalaTest,
"io.circe" %% "circe-literal" % Versions.circe
).map(_ % Test)

val mimaSettings = mimaPreviousArtifacts := Set(
// organization.value %% name.value % "0.3.0" // TODO Define a process for resetting this after release
)

lazy val oauth2 = project.settings(
name := "sttp-oauth2",
libraryDependencies ++= oauth2Dependencies,
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-core" % Versions.catsCore,
"io.circe" %% "circe-parser" % Versions.circe,
"io.circe" %% "circe-core" % Versions.circe,
"io.circe" %% "circe-refined" % Versions.circe,
"com.softwaremill.sttp.client3" %% "core" % Versions.sttp,
"com.softwaremill.sttp.client3" %% "circe" % Versions.sttp,
"eu.timepit" %% "refined" % Versions.refined
) ++ plugins ++ testDependencies,
mimaSettings
)

lazy val `oauth2-backend-cats` = project
.settings(
name := "sttp-oauth2-backend-cats",
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % Versions.catsEffect,
"com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % Versions.sttp % Test
) ++ plugins ++ testDependencies,
mimaSettings
)
.dependsOn(oauth2)

val root = project
.in(file("."))
.settings(
skip in publish := true,
mimaPreviousArtifacts := Set.empty
)
.aggregate(oauth2)
.aggregate(oauth2, `oauth2-backend-cats`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ocadotechnology.sttp.oauth2.backend

import cats.effect.Sync
import cats.implicits._
import cats.effect.concurrent.Ref

trait Cache[F[_], A] {
def get: F[Option[A]]
def set(a: A): F[Unit]
}

object Cache {

def refCache[F[_]: Sync, A]: F[Cache[F, A]] = Ref[F].of(Option.empty[A]).map { ref =>
new Cache[F, A] {
override def get: F[Option[A]] = ref.get
override def set(a: A): F[Unit] = ref.set(Some(a))
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.ocadotechnology.sttp.oauth2.backend

import cats.Monad
import cats.data.OptionT
import cats.effect.Clock
import cats.effect.Concurrent
import cats.effect.concurrent.Semaphore
import cats.implicits._
import com.ocadotechnology.sttp.oauth2.ClientCredentialsProvider
import com.ocadotechnology.sttp.oauth2.ClientCredentialsToken.AccessTokenResponse
import com.ocadotechnology.sttp.oauth2.Secret
import com.ocadotechnology.sttp.oauth2.backend.SttpOauth2ClientCredentialsCatsBackend.TokenWithExpiryInstant
import com.ocadotechnology.sttp.oauth2.common.Scope
import eu.timepit.refined.types.string.NonEmptyString
import sttp.capabilities.Effect
import sttp.client3._
import sttp.model.Uri

import java.time.Instant

final class SttpOauth2ClientCredentialsCatsBackend[F[_]: Monad: Clock, P] private (
delegate: SttpBackend[F, P],
fetchTokenAction: F[AccessTokenResponse],
cache: Cache[F, TokenWithExpiryInstant],
semaphore: Semaphore[F]
) extends DelegateSttpBackend(delegate) {

override def send[T, R >: P with Effect[F]](request: Request[T, R]): F[Response[T]] = for {
token <- semaphore.withPermit(resolveToken)
response <- delegate.send(request.auth.bearer(token.value))
} yield response

private val resolveToken: F[Secret[String]] =
OptionT(cache.get)
.product(OptionT.liftF(Clock[F].instantNow))
.filter { case (TokenWithExpiryInstant(_, expiryInstant), currentInstant) => currentInstant isBefore expiryInstant }
majk-p marked this conversation as resolved.
Show resolved Hide resolved
.map(_._1)
.getOrElseF(fetchAndSaveToken)
.map(_.token)

private def fetchAndSaveToken: F[TokenWithExpiryInstant] =
fetchTokenAction.flatMap(calculateExpiryInstant).flatTap(cache.set)

private def calculateExpiryInstant(response: AccessTokenResponse): F[TokenWithExpiryInstant] =
Clock[F].instantNow.map(_ plusMillis response.expiresIn.toMillis).map(TokenWithExpiryInstant(response.accessToken, _))

}

object SttpOauth2ClientCredentialsCatsBackend {
final case class TokenWithExpiryInstant(token: Secret[String], expiryInstant: Instant)

def apply[F[_]: Concurrent: Clock, P](
kubukoz marked this conversation as resolved.
Show resolved Hide resolved
tokenUrl: Uri,
tokenIntrospectionUrl: Uri,
clientId: NonEmptyString,
clientSecret: Secret[String]
)(
scope: Scope
)(
implicit backend: SttpBackend[F, P]
): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] = {
kubukoz marked this conversation as resolved.
Show resolved Hide resolved
val clientCredentialsProvider = ClientCredentialsProvider.instance(tokenUrl, tokenIntrospectionUrl, clientId, clientSecret)
usingClientCredentialsProvider(clientCredentialsProvider)(scope)
}

/** Keep in mind that the given implicit `backend` may be different than this one used by `clientCredentialsProvider`
*/
def usingClientCredentialsProvider[F[_]: Concurrent: Clock, P](
clientCredentialsProvider: ClientCredentialsProvider[F]
)(
scope: Scope
)(
implicit backend: SttpBackend[F, P]
): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] =
Cache.refCache[F, TokenWithExpiryInstant].flatMap(usingClientCredentialsProviderAndCache(clientCredentialsProvider, _)(scope))

def usingCache[F[_]: Concurrent: Clock, P](
cache: Cache[F, TokenWithExpiryInstant]
)(
tokenUrl: Uri,
tokenIntrospectionUrl: Uri,
clientId: NonEmptyString,
clientSecret: Secret[String]
)(
scope: Scope
)(
implicit backend: SttpBackend[F, P]
): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] = {
val clientCredentialsProvider = ClientCredentialsProvider.instance(tokenUrl, tokenIntrospectionUrl, clientId, clientSecret)
usingClientCredentialsProviderAndCache(clientCredentialsProvider, cache)(scope)
}

/** Keep in mind that the given implicit `backend` may be different than this one used by `clientCredentialsProvider`
*/
def usingClientCredentialsProviderAndCache[F[_]: Concurrent: Clock, P](
clientCredentialsProvider: ClientCredentialsProvider[F],
cache: Cache[F, TokenWithExpiryInstant]
)(
scope: Scope
)(
implicit backend: SttpBackend[F, P]
): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] =
usingFetchTokenActionAndCache(clientCredentialsProvider.requestToken(scope), cache)

/** Keep in mind that the given implicit `backend` may be different than this one used by `fetchTokenAction`
*/
def usingFetchTokenActionAndCache[F[_]: Concurrent: Clock, P](
fetchTokenAction: F[AccessTokenResponse],
cache: Cache[F, TokenWithExpiryInstant]
)(
implicit backend: SttpBackend[F, P]
): F[SttpOauth2ClientCredentialsCatsBackend[F, P]] =
Semaphore(n = 1).map(new SttpOauth2ClientCredentialsCatsBackend(backend, fetchTokenAction, cache, _))

}
Loading