diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d631159..aec50cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: @@ -107,6 +111,10 @@ jobs: java: [temurin@8] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: @@ -211,13 +219,17 @@ jobs: dependency-submission: name: Submit Dependencies - if: github.event_name != 'pull_request' + if: github.event.repository.fork == false && github.event_name != 'pull_request' strategy: matrix: os: [ubuntu-latest] java: [temurin@8] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: @@ -239,7 +251,7 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: rootjs_2.13 rootjs_3 docs_2.13 docs_3 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 + modules-ignore: ff4s-open-feature-provider-flipt-it_2.13 ff4s-open-feature-provider-flipt-it_3 ff4s-open-feature-provider-flipt_native0.4_2.13 ff4s-open-feature-provider-flipt_native0.4_3 ff4s-open-feature-sdk_native0.4_2.13 ff4s-open-feature-sdk_native0.4_3 ff4s-open-feature-provider-flipt_2.13 ff4s-open-feature-provider-flipt_3 ff4s-open-feature-sdk_2.13 ff4s-open-feature-sdk_3 rootjs_2.13 rootjs_3 docs_2.13 docs_3 ff4s-open-feature-provider-flipt_sjs1_2.13 ff4s-open-feature-provider-flipt_sjs1_3 rootjvm_2.13 rootjvm_3 rootnative_2.13 rootnative_3 ff4s-open-feature-sdk_sjs1_2.13 ff4s-open-feature-sdk_sjs1_3 configs-ignore: test scala-tool scala-doc-tool test-internal site: @@ -250,6 +262,10 @@ jobs: java: [temurin@11] runs-on: ${{ matrix.os }} steps: + - name: Install sbt + if: contains(runner.os, 'macos') + run: brew install sbt + - name: Checkout current branch (full) uses: actions/checkout@v4 with: @@ -286,7 +302,7 @@ jobs: - name: Publish site if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v3.9.3 + uses: peaceiris/actions-gh-pages@v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: site/target/docs/site diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..a17552d --- /dev/null +++ b/.sbtopts @@ -0,0 +1,2 @@ +-J-Xmx4G +-J-Xss2M diff --git a/.scalafix-base.conf b/.scalafix-base.conf deleted file mode 100644 index cce9476..0000000 --- a/.scalafix-base.conf +++ /dev/null @@ -1,35 +0,0 @@ -# run when explicitly called -rules = [ - DisableSyntax - NoAutoTupling - NoValInForComprehension - ProcedureSyntax - RedundantSyntax - OrganizeImports - RemoveUnused -] - -# run on compile (not explicitly) -# doesn't organize imports -triggered.rules = [ - DisableSyntax - NoAutoTupling - NoValInForComprehension - ProcedureSyntax - RedundantSyntax -] - -# clashes with OrganizeImports -RemoveUnused.imports = false - -OrganizeImports { - blankLines = Auto - groups = [ - "re:javax?\\." - "re:scala\\." - "re:^(?!(io.cardell)).*$" # anything except mine - "*" - ] - expandRelative = true - removeUnused = true -} diff --git a/.scalafix.conf b/.scalafix.conf index fb8030a..a9be9d2 100644 --- a/.scalafix.conf +++ b/.scalafix.conf @@ -5,8 +5,8 @@ rules = [ NoValInForComprehension # ProcedureSyntax RedundantSyntax -# OrganizeImports -# RemoveUnused + # OrganizeImports + # RemoveUnused ] # run on compile (not explicitly) diff --git a/.scalafmt.conf b/.scalafmt.conf index 450ce94..94e67d3 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,2 +1,4 @@ version = 3.7.1 +include ".scalafmt-base.conf" + runner.dialect = scala3 diff --git a/build.sbt b/build.sbt index ca3e5af..3024fe6 100644 --- a/build.sbt +++ b/build.sbt @@ -1,10 +1,11 @@ // https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway ThisBuild / tlBaseVersion := "0.0" // your current series x.y -ThisBuild / organization := "io.cardell" +ThisBuild / organization := "io.cardell" ThisBuild / organizationName := "Alex Cardell" -ThisBuild / startYear := Some(2023) -ThisBuild / licenses := Seq(License.Apache2) +ThisBuild / startYear := Some(2023) +ThisBuild / licenses := Seq(License.Apache2) + ThisBuild / developers := List( // your GitHub handle and name tlGitHubDev("alexcardell", "Alex Cardell") @@ -15,70 +16,128 @@ ThisBuild / tlSonatypeUseLegacyHost := false // publish website from this branch ThisBuild / tlSitePublishBranch := Some("main") -ThisBuild / tlSiteKeepFiles := false +ThisBuild / tlSiteKeepFiles := false val Scala213 = "2.13.12" -val Scala33 = "3.3.3" +val Scala33 = "3.3.3" ThisBuild / crossScalaVersions := Seq(Scala213, Scala33) -ThisBuild / scalaVersion := Scala213 // the default Scala +ThisBuild / scalaVersion := Scala213 // the default Scala + +// hack until integration tests can run in parallel +// ThisBuild / Test / parallelExecution := false +Global / concurrentRestrictions += Tags.limit(Tags.Test, 1) lazy val projects = Seq( `flipt-sdk-server`, - `flipt-sdk-server-it` + `flipt-sdk-server-it`, + `open-feature-sdk`, + `open-feature-provider-flipt`, + `open-feature-provider-flipt-it` ) lazy val commonDependencies = Seq( libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-core" % "2.10.0", - "org.typelevel" %%% "cats-effect" % "3.5.3", - "org.scalameta" %%% "munit" % "1.0.0-RC1" % Test, - "org.typelevel" %%% "munit-cats-effect" % "2.0.0-M5" % Test + "org.typelevel" %%% "cats-core" % "2.10.0", + "org.typelevel" %%% "cats-effect" % "3.5.3", + "org.scalameta" %%% "munit" % "1.0.0-RC1" % Test, + "org.typelevel" %%% "munit-cats-effect" % "2.0.0-M5" % Test ) ) lazy val root = tlCrossRootProject.aggregate(projects: _*) -lazy val `flipt-sdk-server` = - crossProject(JVMPlatform, JSPlatform, NativePlatform) - .crossType(CrossType.Full) - .in(file("flipt/sdk-server")) - .settings(commonDependencies) - .settings( - name := "ff4s-flipt-sdk-server", - libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-client" % "0.23.26", - "org.http4s" %%% "http4s-ember-client" % "0.23.26", - "org.http4s" %%% "http4s-circe" % "0.23.26", - "io.circe" %%% "circe-core" % "0.14.7", - "io.circe" %%% "circe-parser" % "0.14.7", - "io.circe" %%% "circe-generic" % "0.14.7" - ) +lazy val `flipt-sdk-server` = crossProject( + JVMPlatform, + JSPlatform, + NativePlatform +) + .crossType(CrossType.Full) + .in(file("flipt/sdk-server")) + .settings(commonDependencies) + .settings( + name := "ff4s-flipt-sdk-server", + libraryDependencies ++= Seq( + "org.http4s" %%% "http4s-client" % "0.23.26", + "org.http4s" %%% "http4s-ember-client" % "0.23.26", + "org.http4s" %%% "http4s-circe" % "0.23.26", + "io.circe" %%% "circe-core" % "0.14.7", + "io.circe" %%% "circe-parser" % "0.14.7", + "io.circe" %%% "circe-generic" % "0.14.7" ) + ) -lazy val `flipt-sdk-server-it` = - crossProject(JVMPlatform) - .crossType(CrossType.Pure) - .in(file("flipt/sdk-server-it")) - .dependsOn(`flipt-sdk-server`) - .settings(commonDependencies) - .settings( - libraryDependencies ++= Seq( - "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.3" % Test - ) +lazy val `flipt-sdk-server-it` = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .in(file("flipt/sdk-server-it")) + .settings(commonDependencies) + .settings( + libraryDependencies ++= Seq( + "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.3" % Test ) + ) + .dependsOn(`flipt-sdk-server`) -lazy val docs = - project - .in(file("site")) - .enablePlugins(TypelevelSitePlugin) - .settings( - tlSiteHelium := { - import laika.helium.config.IconLink - import laika.helium.config.HeliumIcon - import laika.ast.Path.Root - tlSiteHelium.value.site.topNavigationBar( - homeLink = IconLink.internal(Root / "index.md", HeliumIcon.home) - ) - } +lazy val `open-feature-sdk` = crossProject( + JVMPlatform, + JSPlatform, + NativePlatform +) + .crossType(CrossType.Pure) + .in(file("open-feature/sdk")) + .enablePlugins(NoPublishPlugin) + .settings(commonDependencies) + .settings( + name := "ff4s-open-feature-sdk", + libraryDependencies ++= Seq( + "io.circe" %%% "circe-generic" % "0.14.7" ) - .dependsOn(`flipt-sdk-server`.jvm) + ) + +lazy val `open-feature-provider-flipt` = crossProject( + JVMPlatform, + JSPlatform, + NativePlatform +) + .crossType(CrossType.Pure) + .in(file("open-feature/provider-flipt")) + .enablePlugins(NoPublishPlugin) + .settings(commonDependencies) + .settings( + name := "ff4s-open-feature-provider-flipt" + ) + .dependsOn( + `open-feature-sdk`, + `flipt-sdk-server` + ) + +lazy val `open-feature-provider-flipt-it` = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .in(file("open-feature/provider-flipt-it")) + .enablePlugins(NoPublishPlugin) + .settings(commonDependencies) + .settings( + name := "ff4s-open-feature-provider-flipt-it", + libraryDependencies ++= Seq( + "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.3" % Test + ) + ) + .dependsOn( + `open-feature-provider-flipt` + ) + +lazy val docs = project + .in(file("site")) + .enablePlugins(TypelevelSitePlugin) + .settings( + tlSiteHelium := { + import laika.helium.config.IconLink + import laika.helium.config.HeliumIcon + import laika.ast.Path.Root + tlSiteHelium.value.site.topNavigationBar( + homeLink = IconLink.internal(Root / "index.md", HeliumIcon.home) + ) + } + ) + .dependsOn(`flipt-sdk-server`.jvm) + +addCommandAlias("fix", "headerCreateAll;scalafixAll;scalafmtAll;scalafmtSbt") diff --git a/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala b/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala index 8d42c27..3829260 100644 --- a/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala +++ b/flipt/sdk-server-it/src/test/scala/io/cardell/ff4s/flipt/FliptApiImplItTest.scala @@ -22,15 +22,15 @@ import com.dimafeng.testcontainers.ContainerDef import com.dimafeng.testcontainers.DockerComposeContainer import com.dimafeng.testcontainers.ExposedService import com.dimafeng.testcontainers.munit.TestContainerForAll -import io.cardell.ff4s.flipt.auth.AuthenticationStrategy import io.circe.Decoder import io.circe.generic.semiauto.deriveDecoder +import java.io.File import munit.CatsEffectSuite import org.http4s.Uri import org.http4s.ember.client.EmberClientBuilder import org.testcontainers.containers.wait.strategy.Wait -import java.io.File +import io.cardell.ff4s.flipt.auth.AuthenticationStrategy class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll { @@ -43,10 +43,11 @@ class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll { ) def api(containers: Containers): Resource[IO, FliptApi[IO]] = { - val flipt = containers - .asInstanceOf[DockerComposeContainer] - .getContainerByServiceName("flipt") - .get + val flipt = + containers + .asInstanceOf[DockerComposeContainer] + .getContainerByServiceName("flipt") + .get val url = Uri .fromString( @@ -119,6 +120,7 @@ class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll { } case class TestVariant(field: String, intField: Int) + object TestVariant { implicit val decoder: Decoder[TestVariant] = deriveDecoder } @@ -166,4 +168,5 @@ class FliptApiImplItTest extends CatsEffectSuite with TestContainerForAll { } } } + } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala index 8832b9b..58b72da 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApi.scala @@ -17,6 +17,10 @@ package io.cardell.ff4s.flipt import cats.effect.Concurrent +import io.circe.Decoder +import org.http4s.Uri +import org.http4s.client.Client + import io.cardell.ff4s.flipt.auth.AuthMiddleware import io.cardell.ff4s.flipt.auth.AuthenticationStrategy import io.cardell.ff4s.flipt.model.AttachmentDecodingError @@ -25,14 +29,13 @@ import io.cardell.ff4s.flipt.model.BatchEvaluationResponse import io.cardell.ff4s.flipt.model.BooleanEvaluationResponse import io.cardell.ff4s.flipt.model.StructuredVariantEvaluationResponse import io.cardell.ff4s.flipt.model.VariantEvaluationResponse -import io.circe.Decoder -import org.http4s.Uri -import org.http4s.client.Client trait FliptApi[F[_]] { + def evaluateBoolean( request: EvaluationRequest ): F[BooleanEvaluationResponse] + def evaluateVariant( request: EvaluationRequest ): F[VariantEvaluationResponse] @@ -45,16 +48,19 @@ trait FliptApi[F[_]] { def evaluateStructuredVariant[A: Decoder]( request: EvaluationRequest ): F[Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]]] + def evaluateBatch( request: BatchEvaluationRequest ): F[BatchEvaluationResponse] + } object FliptApi { + def apply[F[_]: Concurrent]( client: Client[F], uri: Uri, strategy: AuthenticationStrategy - ): FliptApi[F] = - new FliptApiImpl[F](AuthMiddleware(client, strategy), uri) + ): FliptApi[F] = new FliptApiImpl[F](AuthMiddleware(client, strategy), uri) + } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala index 7f6161c..1c1fbd9 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/FliptApiImpl.scala @@ -17,13 +17,7 @@ package io.cardell.ff4s.flipt import cats.effect.Concurrent -import cats.syntax.all.* -import io.cardell.ff4s.flipt.model.AttachmentDecodingError -import io.cardell.ff4s.flipt.model.BatchEvaluationRequest -import io.cardell.ff4s.flipt.model.BatchEvaluationResponse -import io.cardell.ff4s.flipt.model.BooleanEvaluationResponse -import io.cardell.ff4s.flipt.model.StructuredVariantEvaluationResponse -import io.cardell.ff4s.flipt.model.VariantEvaluationResponse +import cats.syntax.all._ import io.circe.Decoder import org.http4s.Method import org.http4s.Request @@ -31,6 +25,13 @@ import org.http4s.Uri import org.http4s.circe.CirceEntityCodec._ import org.http4s.client.Client +import io.cardell.ff4s.flipt.model.AttachmentDecodingError +import io.cardell.ff4s.flipt.model.BatchEvaluationRequest +import io.cardell.ff4s.flipt.model.BatchEvaluationResponse +import io.cardell.ff4s.flipt.model.BooleanEvaluationResponse +import io.cardell.ff4s.flipt.model.StructuredVariantEvaluationResponse +import io.cardell.ff4s.flipt.model.VariantEvaluationResponse + protected[flipt] class FliptApiImpl[F[_]: Concurrent]( client: Client[F], baseUri: Uri @@ -64,9 +65,7 @@ protected[flipt] class FliptApiImpl[F[_]: Concurrent]( request: EvaluationRequest ): F[ Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]] - ] = { - evaluateVariant(request).map(StructuredVariantEvaluationResponse[A](_)) - } + ] = evaluateVariant(request).map(StructuredVariantEvaluationResponse[A](_)) override def evaluateBatch( request: BatchEvaluationRequest diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthMiddleware.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthMiddleware.scala index d8a7c68..aa2c11a 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthMiddleware.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthMiddleware.scala @@ -16,14 +16,15 @@ package io.cardell.ff4s.flipt.auth -import org.http4s.headers.Authorization import cats.effect.kernel.MonadCancelThrow -import org.typelevel.ci._ -import org.http4s.client.Client -import org.http4s.Credentials import org.http4s.AuthScheme +import org.http4s.Credentials +import org.http4s.client.Client +import org.http4s.headers.Authorization +import org.typelevel.ci._ object AuthMiddleware { + def apply[F[_]: MonadCancelThrow]( client: Client[F], strategy: AuthenticationStrategy @@ -35,13 +36,15 @@ object AuthMiddleware { private def authHeader( strategy: AuthenticationStrategy ): Authorization = { - val credentials = strategy match { - case AuthenticationStrategy.ClientToken(token) => - Credentials.Token(AuthScheme.Bearer, token) - case AuthenticationStrategy.JWT(token) => - Credentials.Token(ci"JWT", token) - } + val credentials = + strategy match { + case AuthenticationStrategy.ClientToken(token) => + Credentials.Token(AuthScheme.Bearer, token) + case AuthenticationStrategy.JWT(token) => + Credentials.Token(ci"JWT", token) + } Authorization(credentials) } + } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthenticationStrategy.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthenticationStrategy.scala index 9786cd3..555bbdb 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthenticationStrategy.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/auth/AuthenticationStrategy.scala @@ -20,5 +20,5 @@ sealed trait AuthenticationStrategy object AuthenticationStrategy { case class ClientToken(token: String) extends AuthenticationStrategy - case class JWT(token: String) extends AuthenticationStrategy + case class JWT(token: String) extends AuthenticationStrategy } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/BatchEvaluationRequest.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/BatchEvaluationRequest.scala index 1095e85..2ac0c7a 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/BatchEvaluationRequest.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/BatchEvaluationRequest.scala @@ -16,9 +16,10 @@ package io.cardell.ff4s.flipt.model -import io.cardell.ff4s.flipt.EvaluationRequest -import io.circe.generic.semiauto.deriveEncoder import io.circe.Encoder +import io.circe.generic.semiauto.deriveEncoder + +import io.cardell.ff4s.flipt.EvaluationRequest case class BatchEvaluationRequest( requestId: Option[String], diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/ErrorEvaluationReason.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/ErrorEvaluationReason.scala index 9771dff..ad16847 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/ErrorEvaluationReason.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/ErrorEvaluationReason.scala @@ -24,7 +24,7 @@ sealed trait ErrorEvaluationReason object ErrorEvaluationReason { case object NotFound extends ErrorEvaluationReason - case object Unknown extends ErrorEvaluationReason + case object Unknown extends ErrorEvaluationReason implicit val d: Decoder[ErrorEvaluationReason] = Decoder.instance { cursor => val json = cursor.value @@ -32,8 +32,7 @@ object ErrorEvaluationReason { json.asString match { case Some(v) if v == "NOT_FOUND_ERROR_EVALUATION_REASON" => Right(NotFound) - case Some(v) if v == "UNKNOWN_ERROR_EVALUATION_REASON" => - Right(Unknown) + case Some(v) if v == "UNKNOWN_ERROR_EVALUATION_REASON" => Right(Unknown) case Some(other) => Left( DecodingFailure( @@ -47,4 +46,5 @@ object ErrorEvaluationReason { ) } } + } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationReason.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationReason.scala index 9418503..12fa2d1 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationReason.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationReason.scala @@ -23,23 +23,20 @@ import io.circe.DecodingFailure.Reason sealed trait EvaluationReason object EvaluationReason { - case object Unknown extends EvaluationReason + case object Unknown extends EvaluationReason case object FlagDisabled extends EvaluationReason - case object Match extends EvaluationReason - case object Default extends EvaluationReason + case object Match extends EvaluationReason + case object Default extends EvaluationReason implicit val d: Decoder[EvaluationReason] = Decoder.instance { cursor => val json = cursor.value json.asString match { - case Some(v) if v == "UNKNOWN_EVALUATION_REASON" => - Right(Unknown) + case Some(v) if v == "UNKNOWN_EVALUATION_REASON" => Right(Unknown) case Some(v) if v == "FLAG_DISABLED_EVALUATION_REASON" => Right(FlagDisabled) - case Some(v) if v == "MATCH_EVALUATION_REASON" => - Right(Match) - case Some(v) if v == "DEFAULT_EVALUATION_REASON" => - Right(Default) + case Some(v) if v == "MATCH_EVALUATION_REASON" => Right(Match) + case Some(v) if v == "DEFAULT_EVALUATION_REASON" => Right(Default) case Some(other) => Left( DecodingFailure( @@ -53,4 +50,5 @@ object EvaluationReason { ) } } + } diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationRequest.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationRequest.scala index 022dc4c..6c657d1 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationRequest.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationRequest.scala @@ -16,8 +16,8 @@ package io.cardell.ff4s.flipt -import io.circe.generic.semiauto.deriveEncoder import io.circe.Encoder +import io.circe.generic.semiauto.deriveEncoder case class EvaluationRequest( namespaceKey: String, diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationResponse.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationResponse.scala index 99d9cab..8a8df98 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationResponse.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/EvaluationResponse.scala @@ -23,12 +23,14 @@ import io.circe.generic.semiauto.deriveDecoder sealed trait EvaluationResponse object EvaluationResponse { + implicit def decoder: Decoder[EvaluationResponse] = List[Decoder[EvaluationResponse]]( Decoder[BooleanEvaluationResponse].widen, Decoder[VariantEvaluationResponse].widen, Decoder[ErrorEvaluationResponse].widen ).reduceLeft(_ or _) + } case class BooleanEvaluationResponse( diff --git a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala index 45feea0..a8c483c 100644 --- a/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala +++ b/flipt/sdk-server/shared/src/main/scala/io/cardell/ff4s/flipt/model/StructuredEvaluationResponse.scala @@ -16,13 +16,14 @@ package io.cardell.ff4s.flipt.model -import cats.syntax.all.* +import cats.syntax.all._ import io.circe.Decoder import io.circe.parser sealed trait AttachmentDecodingError + object AttachmentDecodingError { - case object AttachmentJsonParsingError extends AttachmentDecodingError + case object AttachmentJsonParsingError extends AttachmentDecodingError case object AttachmentDeserialisationError extends AttachmentDecodingError } @@ -38,14 +39,16 @@ case class StructuredVariantEvaluationResponse[A]( ) object StructuredVariantEvaluationResponse { + def apply[A: Decoder]( variant: VariantEvaluationResponse ): Either[AttachmentDecodingError, StructuredVariantEvaluationResponse[A]] = { - val maybeAttachment = if (variant.`match`) { - decodeJsonAttachment(variant.variantAttachment).map(Some(_)) - } else { - Option.empty[A].asRight[AttachmentDecodingError] - } + val maybeAttachment = + if (variant.`match`) { + decodeJsonAttachment(variant.variantAttachment).map(Some(_)) + } else { + Option.empty[A].asRight[AttachmentDecodingError] + } maybeAttachment.map { attachment => StructuredVariantEvaluationResponse[A]( diff --git a/open-feature/provider-flipt-it/src/test/scala/io/cardell/openfeature/provider/flipt/FliptProviderItTest.scala b/open-feature/provider-flipt-it/src/test/scala/io/cardell/openfeature/provider/flipt/FliptProviderItTest.scala new file mode 100644 index 0000000..392c0f8 --- /dev/null +++ b/open-feature/provider-flipt-it/src/test/scala/io/cardell/openfeature/provider/flipt/FliptProviderItTest.scala @@ -0,0 +1,186 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider.flipt + +import cats.effect.IO +import cats.effect.kernel.Resource +import com.dimafeng.testcontainers.ContainerDef +import com.dimafeng.testcontainers.DockerComposeContainer +import com.dimafeng.testcontainers.ExposedService +import com.dimafeng.testcontainers.munit.TestContainerForAll +import java.io.File +import munit.CatsEffectSuite +import org.http4s.Uri +import org.http4s.ember.client.EmberClientBuilder +import org.testcontainers.containers.wait.strategy.Wait + +import io.cardell.ff4s.flipt.FliptApi +import io.cardell.ff4s.flipt.auth.AuthenticationStrategy +import io.cardell.openfeature.EvaluationContext + +class FliptProviderItTest extends CatsEffectSuite with TestContainerForAll { + + override val containerDef: ContainerDef = DockerComposeContainer.Def( + new File("docker-compose.yaml"), + exposedServices = Seq( + ExposedService("flipt", 8080, Wait.forLogMessage("^UI: http.*", 1)) + ), + tailChildContainers = true + ) + + def api(containers: Containers): Resource[IO, FliptProvider[IO]] = { + val flipt = + containers + .asInstanceOf[DockerComposeContainer] + .getContainerByServiceName("flipt") + .get + + val url = Uri + .fromString( + s"http://${flipt.getHost()}:${flipt.getMappedPort(8080)}" + ) + .getOrElse(throw new Exception("invalid url")) + + EmberClientBuilder + .default[IO] + .build + .map { client => + val flipt = FliptApi[IO]( + client, + url, + AuthenticationStrategy.ClientToken("token") + ) + + new FliptProvider(flipt, "default") + } + } + + test("can fetch boolean flag") { + withContainers { containers => + api(containers).use { flipt => + for { + res <- flipt.resolveBooleanValue( + "boolean-flag-1", + false, + EvaluationContext.empty + ) + } yield assertEquals(res.value, true) + } + } + } + + // test("uses default when boolean flag missing") { + // withContainers { containers => + // api(containers).use { flipt => + // for { + // res <- flipt.resolveBooleanValue( + // "no-flag", + // false, + // EvaluationContext.empty + // ) + // } yield assertEquals(res.value, false) + // } + // } + // } + + // test("receives variant match when in segment rule") { + // withContainers { containers => + // api(containers).use { flipt => + // val segmentContext = Map("test-property" -> "matched-property-value") + // for { + // res <- flipt.evaluateVariant( + // EvaluationRequest( + // "default", + // "variant-flag-1", + // None, + // segmentContext, + // None + // ) + // ) + // } yield assertEquals(res.`match`, true) + // } + // } + // } + // + // test("receives no variant match when not in segment rule") { + // withContainers { containers => + // api(containers).use { flipt => + // val segmentContext = Map("test-property" -> "unmatched-property-value") + // for { + // res <- flipt.evaluateVariant( + // EvaluationRequest( + // "default", + // "variant-flag-1", + // None, + // segmentContext, + // None + // ) + // ) + // } yield assertEquals(res.`match`, false) + // } + // } + // } + // + // case class TestVariant(field: String, intField: Int) + // object TestVariant { + // implicit val decoder: Decoder[TestVariant] = deriveDecoder + // } + // + // test("can deserialise variant match") { + // withContainers { containers => + // api(containers).use { flipt => + // val segmentContext = Map("test-property" -> "matched-property-value") + // + // for { + // res <- flipt.evaluateStructuredVariant[TestVariant]( + // EvaluationRequest( + // "default", + // "variant-flag-1", + // None, + // segmentContext, + // None + // ) + // ) + // _ <- IO.println(res) + // result = res.map(_.variantAttachment) + // } yield assertEquals(result, Right(Some(TestVariant("string", 33)))) + // } + // } + // } + // + // test("does not attempt variant deserialisation without a match") { + // withContainers { containers => + // api(containers).use { flipt => + // val segmentContext = Map("test-property" -> "unmatched-property-value") + // + // for { + // res <- flipt.evaluateStructuredVariant[TestVariant]( + // EvaluationRequest( + // "default", + // "variant-flag-1", + // None, + // segmentContext, + // None + // ) + // ) + // _ <- IO.println(res) + // result = res.map(_.variantAttachment) + // } yield assertEquals(result, Right(None)) + // } + // } + // } +} diff --git a/open-feature/provider-flipt/src/main/scala/io/cardell/openfeature/provider/flipt/FliptProvider.scala b/open-feature/provider-flipt/src/main/scala/io/cardell/openfeature/provider/flipt/FliptProvider.scala new file mode 100644 index 0000000..4b53268 --- /dev/null +++ b/open-feature/provider-flipt/src/main/scala/io/cardell/openfeature/provider/flipt/FliptProvider.scala @@ -0,0 +1,113 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider.flipt + +import cats.MonadThrow +import cats.syntax.all._ + +import io.cardell.ff4s.flipt.EvaluationRequest +import io.cardell.ff4s.flipt.FliptApi +import io.cardell.ff4s.flipt.model.{EvaluationReason => FliptReason} +import io.cardell.openfeature.ErrorCode +import io.cardell.openfeature.EvaluationContext +import io.cardell.openfeature.EvaluationReason +import io.cardell.openfeature.provider.Provider +import io.cardell.openfeature.provider.ProviderMetadata +import io.cardell.openfeature.provider.ResolutionDetails + +class FliptProvider[F[_]: MonadThrow](flipt: FliptApi[F], namespace: String) + extends Provider[F] { + + override def metadata: ProviderMetadata = ProviderMetadata(name = "flipt") + + private def mapReason(evalReason: FliptReason): EvaluationReason = + evalReason match { + case FliptReason.Default => EvaluationReason.Default + case FliptReason.FlagDisabled => EvaluationReason.Disabled + case FliptReason.Match => EvaluationReason.TargetingMatch + case FliptReason.Unknown => EvaluationReason.Unknown + } + + private def mapContext(context: EvaluationContext): Map[String, String] = + context.values.map { case (k, v) => (k, v.stringValue) } + + override def resolveBooleanValue( + flagKey: String, + defaultValue: Boolean, + context: EvaluationContext + ): F[ResolutionDetails[Boolean]] = { + val evalContext = mapContext(context) + + val req: EvaluationRequest = EvaluationRequest( + namespaceKey = namespace, + flagKey = flagKey, + entityId = context.targetingKey, + context = evalContext, + reference = None + ) + + val resolution = flipt.evaluateBoolean(req).map { evaluation => + ResolutionDetails[Boolean]( + value = evaluation.enabled, + errorCode = None, + errorMessage = None, + reason = mapReason(evaluation.reason).some, + variant = None, + metadata = None + ) + } + + def default(t: Throwable) = ResolutionDetails[Boolean]( + value = defaultValue, + errorCode = Some(ErrorCode.General), + errorMessage = Some(t.getMessage()), + reason = Some(EvaluationReason.Default), + variant = None, + metadata = None + ) + + resolution.attempt.map { + case Right(value) => value + case Left(error) => default(error) + } + } + + // override def resolveStringValue( + // flagKey: String, + // defaultValue: String, + // context: EvaluationContext + // ): F[ResolutionDetails[String]] = ??? + // + // override def resolveIntValue( + // flagKey: String, + // defaultValue: Int, + // context: EvaluationContext + // ): F[ResolutionDetails[Int]] = ??? + // + // override def resolveDoubleValue( + // flagKey: String, + // defaultValue: Double, + // context: EvaluationContext + // ): F[ResolutionDetails[Double]] = ??? + // + // override def resolveStructureValue[A: StructureDecoder]( + // flagKey: String, + // defaultValue: A, + // context: EvaluationContext + // ): F[ResolutionDetails[A]] = ??? + +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/ErrorCode.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/ErrorCode.scala new file mode 100644 index 0000000..f9ac750 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/ErrorCode.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +sealed trait ErrorCode + +object ErrorCode { + + /** The value was resolved before the provider was initialized. */ + case object ProviderNotReady extends ErrorCode + + /** The flag could not be found. */ + case object FlagNotFound extends ErrorCode + + /** An error was encountered parsing data, such as a flag configuration. */ + case object ParseError extends ErrorCode + + /** The type of the flag value does not match the expected type. */ + case object TypeMismatch extends ErrorCode + + /** The provider requires a targeting key and one was not provided in the + * evaluation context. + */ + case object TargetingKeyMissing extends ErrorCode + + /** The evaluation context does not meet provider requirements. */ + case object InvalidContext extends ErrorCode + + /** The provider has entered an irrecoverable error state. */ + case object ProviderFatal extends ErrorCode + + /** The error was for a reason not enumerated above. */ + case object General extends ErrorCode +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationContext.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationContext.scala new file mode 100644 index 0000000..f865e11 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationContext.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +import io.cardell.openfeature.ContextValue.BooleanValue +import io.cardell.openfeature.ContextValue.DoubleValue +import io.cardell.openfeature.ContextValue.IntValue +import io.cardell.openfeature.ContextValue.StringValue + +sealed trait ContextValue { + + def stringValue: String = + this match { + case StringValue(value) => value + case BooleanValue(value) => value.toString() + case IntValue(value) => value.toString() + case DoubleValue(value) => value.toString() + } + +} + +object ContextValue { + case class BooleanValue(value: Boolean) extends ContextValue + case class StringValue(value: String) extends ContextValue + case class IntValue(value: Int) extends ContextValue + case class DoubleValue(value: Double) extends ContextValue +} + +case class EvaluationContext( + targetingKey: Option[String], + values: Map[String, ContextValue] +) { + + def ++(other: EvaluationContext): EvaluationContext = EvaluationContext( + other.targetingKey.orElse(targetingKey), + // TODO check override order in spec + values ++ other.values + ) + +} + +object EvaluationContext { + def empty: EvaluationContext = EvaluationContext(None, Map.empty) +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationDetails.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationDetails.scala new file mode 100644 index 0000000..bdf9431 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationDetails.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +import io.cardell.openfeature.provider.FlagMetadata +import io.cardell.openfeature.provider.ResolutionDetails + +case class EvaluationDetails[A]( + flagKey: String, + value: A, + errorCode: Option[ErrorCode], + errorMessage: Option[String], + reason: Option[EvaluationReason], + variant: Option[String], + metadata: Option[FlagMetadata] +) + +object EvaluationDetails { + + def apply[A]( + flagKey: String, + resolution: ResolutionDetails[A] + ): EvaluationDetails[A] = EvaluationDetails[A]( + flagKey = flagKey, + value = resolution.value, + errorCode = resolution.errorCode, + errorMessage = resolution.errorMessage, + reason = resolution.reason, + variant = resolution.variant, + metadata = resolution.metadata + ) + +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationReason.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationReason.scala new file mode 100644 index 0000000..1824815 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/EvaluationReason.scala @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +sealed trait EvaluationReason + +object EvaluationReason { + + /** The evaluated value is static (no dynamic evaluation). */ + case object Static extends EvaluationReason + + /** The evaluated value fell back to a pre-configured value (no dynamic + * evaluation occurred or dynamic evaluation yielded no result). + */ + case object Default extends EvaluationReason + + /** The evaluated value was the result of a dynamic evaluation, such as a rule + * or specific user-targeting. + */ + case object TargetingMatch extends EvaluationReason + + /** The evaluated value was the result of pseudorandom assignment. */ + case object Split extends EvaluationReason + + /** The evaluated value was retrieved from cache. */ + case object Cached extends EvaluationReason + + /** The evaluated value was the result of the flag being disabled in the + * management system. + */ + case object Disabled extends EvaluationReason + + /** The reason for the evaluated value could not be determined. */ + case object Unknown extends EvaluationReason + + /** The evaluated value is non-authoritative or possibly out of date */ + case object Stale extends EvaluationReason + + /** The evaluated value was the result of an error. */ + case object Error extends EvaluationReason + + /** Any other provider-defined reason */ + case class Other(reason: String) extends EvaluationReason +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClient.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClient.scala new file mode 100644 index 0000000..087c516 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClient.scala @@ -0,0 +1,189 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +import io.cardell.openfeature.provider.ProviderMetadata + +// TODO implement circe +// trait StructureDecoder[A] + +case class EvaluationOptions() + +object EvaluationOptions { + val Defaults: EvaluationOptions = EvaluationOptions() +} + +trait FeatureClient[F[_]] { + + def providerMetadata: ProviderMetadata + + def evaluationContext: EvaluationContext + def withEvaluationContext(context: EvaluationContext): FeatureClient[F] + + // def hooks: List[Hook] + // def withHook(hook: Hook): FeatureClient[F] + // def withHooks(hooks: List[Hook]): FeatureClient[F] + + def getBooleanValue(flagKey: String, default: Boolean): F[Boolean] + + def getBooleanValue( + flagKey: String, + default: Boolean, + context: EvaluationContext + ): F[Boolean] + + def getBooleanValue( + flagKey: String, + default: Boolean, + context: EvaluationContext, + options: EvaluationOptions + ): F[Boolean] + + def getBooleanDetails( + flagKey: String, + default: Boolean + ): F[EvaluationDetails[Boolean]] + + def getBooleanDetails( + flagKey: String, + default: Boolean, + context: EvaluationContext + ): F[EvaluationDetails[Boolean]] + + def getBooleanDetails( + flagKey: String, + default: Boolean, + context: EvaluationContext, + options: EvaluationOptions + ): F[EvaluationDetails[Boolean]] + + // def getStringValue(flagKey: String, default: String): F[String] + // def getStringValue( + // flagKey: String, + // default: String, + // context: EvaluationContext + // ): F[String] + // def getStringValue( + // flagKey: String, + // default: String, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[String] + // + // def getStringDetails( + // flagKey: String, + // default: String + // ): F[EvaluationDetails[String]] + // def getStringDetails( + // flagKey: String, + // default: String, + // context: EvaluationContext + // ): F[EvaluationDetails[String]] + // def getStringDetails( + // flagKey: String, + // default: String, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[String]] + // + // def getIntValue(flagKey: String, default: Int): F[Int] + // def getIntValue( + // flagKey: String, + // default: Int, + // context: EvaluationContext + // ): F[Int] + // def getIntValue( + // flagKey: String, + // default: Int, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[Int] + // + // def getIntDetails(flagKey: String, default: Int): F[EvaluationDetails[Int]] + // def getIntDetails( + // flagKey: String, + // default: Int, + // context: EvaluationContext + // ): F[EvaluationDetails[Int]] + // def getIntDetails( + // flagKey: String, + // default: Int, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[Int]] + // + // def getDoubleValue(flagKey: String, default: Double): F[Double] + // def getDoubleValue( + // flagKey: String, + // default: Double, + // context: EvaluationContext + // ): F[Double] + // def getDoubleValue( + // flagKey: String, + // default: Double, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[Double] + // + // def getDoubleDetails( + // flagKey: String, + // default: Double + // ): F[EvaluationDetails[Double]] + // def getDoubleDetails( + // flagKey: String, + // default: Double, + // context: EvaluationContext + // ): F[EvaluationDetails[Double]] + // def getDoubleDetails( + // flagKey: String, + // default: Double, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[Double]] + // + // def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A + // ): F[A] + // def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext + // ): F[A] + // def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[A] + // + // def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A + // ): F[EvaluationDetails[A]] + // def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext + // ): F[EvaluationDetails[A]] + // def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[A]] +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClientImpl.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClientImpl.scala new file mode 100644 index 0000000..ea68066 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/FeatureClientImpl.scala @@ -0,0 +1,282 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +import cats.Monad +import cats.syntax.all._ + +import io.cardell.openfeature.provider.Provider +import io.cardell.openfeature.provider.ProviderMetadata + +protected[openfeature] final class FeatureClientImpl[F[_]: Monad]( + provider: Provider[F], + clientEvaluationContext: EvaluationContext +) extends FeatureClient[F] { + + override def providerMetadata: ProviderMetadata = provider.metadata + + override def evaluationContext: EvaluationContext = clientEvaluationContext + + override def withEvaluationContext( + context: EvaluationContext + ): FeatureClient[F] = + new FeatureClientImpl[F](provider, clientEvaluationContext ++ context) + + // override def hooks: List[Hook] = ??? + // override def withHook(hook: Hook): FeatureClient[F] = ??? + // override def withHooks(hooks: List[Hook]): FeatureClient[F] = ??? + + override def getBooleanValue(flagKey: String, default: Boolean): F[Boolean] = + getBooleanValue(flagKey, default, EvaluationContext.empty) + + override def getBooleanValue( + flagKey: String, + default: Boolean, + context: EvaluationContext + ): F[Boolean] = getBooleanValue( + flagKey, + default, + context, + EvaluationOptions.Defaults + ) + + override def getBooleanValue( + flagKey: String, + default: Boolean, + context: EvaluationContext, + options: EvaluationOptions // TODO handle options + ): F[Boolean] = getBooleanDetails(flagKey, default, context, options) + .map(_.value) + + override def getBooleanDetails( + flagKey: String, + default: Boolean + ): F[EvaluationDetails[Boolean]] = getBooleanDetails( + flagKey, + default, + EvaluationContext.empty + ) + + override def getBooleanDetails( + flagKey: String, + default: Boolean, + context: EvaluationContext + ): F[EvaluationDetails[Boolean]] = getBooleanDetails( + flagKey, + default, + context, + EvaluationOptions.Defaults + ) + + override def getBooleanDetails( + flagKey: String, + default: Boolean, + context: EvaluationContext, + options: EvaluationOptions + ): F[EvaluationDetails[Boolean]] = provider + .resolveBooleanValue( + flagKey, + default, + clientEvaluationContext ++ context + ) + .map(EvaluationDetails[Boolean](flagKey, _)) + + // override def getStringValue(flagKey: String, default: String): F[String] = + // getStringValue(flagKey, default, EvaluationContext.empty) + // + // override def getStringValue( + // flagKey: String, + // default: String, + // context: EvaluationContext + // ): F[String] = + // getStringValue( + // flagKey, + // default, + // context, + // EvaluationOptions.Defaults + // ) + // + // override def getStringValue( + // flagKey: String, + // default: String, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[String] = + // getStringDetails(flagKey, default, context, EvaluationOptions.Defaults) + // .map(_.value) + // + // override def getStringDetails( + // flagKey: String, + // default: String + // ): F[EvaluationDetails[String]] = + // getStringDetails(flagKey, default, EvaluationContext.empty) + // + // override def getStringDetails( + // flagKey: String, + // default: String, + // context: EvaluationContext + // ): F[EvaluationDetails[String]] = + // getStringDetails(flagKey, default, context, EvaluationOptions.Defaults) + // + // override def getStringDetails( + // flagKey: String, + // default: String, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[String]] = + // provider + // .resolveStringValue(flagKey, default, clientEvaluationContext ++ context) + // .map(EvaluationDetails[String](flagKey, _)) + // + // override def getIntValue(flagKey: String, default: Int): F[Int] = + // getIntValue(flagKey, default, EvaluationContext.empty) + // + // override def getIntValue( + // flagKey: String, + // default: Int, + // context: EvaluationContext + // ): F[Int] = getIntValue(flagKey, default, context, EvaluationOptions.Defaults) + // + // override def getIntValue( + // flagKey: String, + // default: Int, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[Int] = + // getIntDetails(flagKey, default, context, options) + // .map(_.value) + // + // override def getIntDetails( + // flagKey: String, + // default: Int + // ): F[EvaluationDetails[Int]] = + // getIntDetails(flagKey, default, EvaluationContext.empty) + // + // override def getIntDetails( + // flagKey: String, + // default: Int, + // context: EvaluationContext + // ): F[EvaluationDetails[Int]] = + // getIntDetails(flagKey, default, context, EvaluationOptions.Defaults) + // + // override def getIntDetails( + // flagKey: String, + // default: Int, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[Int]] = + // provider + // .resolveIntValue( + // flagKey, + // default, + // clientEvaluationContext ++ context + // ) + // .map(EvaluationDetails[Int](flagKey, _)) + // + // override def getDoubleValue(flagKey: String, default: Double): F[Double] = + // getDoubleValue(flagKey, default, EvaluationContext.empty) + // + // override def getDoubleValue( + // flagKey: String, + // default: Double, + // context: EvaluationContext + // ): F[Double] = + // getDoubleValue(flagKey, default, context, EvaluationOptions.Defaults) + // + // override def getDoubleValue( + // flagKey: String, + // default: Double, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[Double] = + // provider + // .resolveDoubleValue( + // flagKey, + // default, + // clientEvaluationContext ++ context + // ) + // .map(_.value) + // + // override def getDoubleDetails( + // flagKey: String, + // default: Double + // ): F[EvaluationDetails[Double]] = ??? + // override def getDoubleDetails( + // flagKey: String, + // default: Double, + // context: EvaluationContext + // ): F[EvaluationDetails[Double]] = ??? + // override def getDoubleDetails( + // flagKey: String, + // default: Double, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[Double]] = ??? + // + // override def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A + // ): F[A] = + // getStructureValue[A](flagKey, default, EvaluationContext.empty) + // + // override def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext + // ): F[A] = + // getStructureValue[A](flagKey, default, context, EvaluationOptions.Defaults) + // + // override def getStructureValue[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[A] = + // getStructureDetails[A](flagKey, default, context) + // .map(_.value) + // + // override def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A + // ): F[EvaluationDetails[A]] = + // getStructureDetails[A](flagKey, default, EvaluationContext.empty) + // override def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext + // ): F[EvaluationDetails[A]] = + // getStructureDetails[A]( + // flagKey, + // default, + // context, + // EvaluationOptions.Defaults + // ) + // + // override def getStructureDetails[A: StructureDecoder]( + // flagKey: String, + // default: A, + // context: EvaluationContext, + // options: EvaluationOptions + // ): F[EvaluationDetails[A]] = + // provider + // .resolveStructureValue[A]( + // flagKey, + // default, + // clientEvaluationContext ++ context + // ) + // .map(EvaluationDetails(flagKey, _)) +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/OpenFeature.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/OpenFeature.scala new file mode 100644 index 0000000..e155351 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/OpenFeature.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +import cats.Monad +import cats.syntax.all._ + +import io.cardell.openfeature.provider.Provider + +trait OpenFeature[F[_]] { + + /** Create a client using the default provider + */ + def client: F[FeatureClient[F]] +} + +protected[openfeature] class OpenFeatureSdk[F[_]: Monad](provider: Provider[F]) + extends OpenFeature[F] { + + def client: F[FeatureClient[F]] = + new FeatureClientImpl[F](provider, EvaluationContext.empty).pure[F].widen + +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/package.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/package.scala new file mode 100644 index 0000000..58bb1a6 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/package.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell + +package object openfeature {} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/Provider.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/Provider.scala new file mode 100644 index 0000000..2fa35f8 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/Provider.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider + +import io.cardell.openfeature.EvaluationContext +// import io.cardell.openfeature.StructureDecoder + +trait Provider[F[_]] { + def metadata: ProviderMetadata + + def resolveBooleanValue( + flagKey: String, + defaultValue: Boolean, + context: EvaluationContext + ): F[ResolutionDetails[Boolean]] + + // def resolveStringValue( + // flagKey: String, + // defaultValue: String, + // context: EvaluationContext + // ): F[ResolutionDetails[String]] + // + // def resolveIntValue( + // flagKey: String, + // defaultValue: Int, + // context: EvaluationContext + // ): F[ResolutionDetails[Int]] + // + // def resolveDoubleValue( + // flagKey: String, + // defaultValue: Double, + // context: EvaluationContext + // ): F[ResolutionDetails[Double]] + // + // def resolveStructureValue[A: StructureDecoder]( + // flagKey: String, + // defaultValue: A, + // context: EvaluationContext + // ): F[ResolutionDetails[A]] +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderMetadata.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderMetadata.scala new file mode 100644 index 0000000..cb14d4c --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderMetadata.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider + +case class ProviderMetadata(name: String) diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderStatus.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderStatus.scala new file mode 100644 index 0000000..0fc466b --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ProviderStatus.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider + +sealed trait ProviderStatus + +object ProviderStatus { + + /** The provider has not been initialized. */ + case object NotReady extends ProviderStatus + + /** The provider has been initialized, and is able to reliably resolve flag + * values. + */ + case object Ready extends ProviderStatus + + /** The provider is initialized but is not able to reliably resolve flag + * values. + */ + case object Error extends ProviderStatus + + /** The provider's cached state is no longer valid and may not be up-to-date + * with the source of truth. + */ + case object Stale extends ProviderStatus + + /** The provider has entered an irrecoverable error state. */ + case object Fatal extends ProviderStatus + + /* The provider is reconciling its state with a context change. */ + // client-side only + // case object Reconciling extends ProviderStatus +} diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ResolutionDetails.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ResolutionDetails.scala new file mode 100644 index 0000000..b1bb9ce --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/ResolutionDetails.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature.provider + +import io.cardell.openfeature.ErrorCode +import io.cardell.openfeature.EvaluationReason + +sealed trait FlagMetadataValue + +object FlagMetadataValue { + case class Boolean(value: Boolean) extends FlagMetadataValue + case class String(value: String) extends FlagMetadataValue + case class Int(value: String) extends FlagMetadataValue + case class Double(value: Double) extends FlagMetadataValue + // TODO circe unwrapped codecs +} + +case class ResolutionDetails[A]( + value: A, + errorCode: Option[ErrorCode], + errorMessage: Option[String], + reason: Option[EvaluationReason], + variant: Option[String], + metadata: Option[FlagMetadata] +) diff --git a/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/package.scala b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/package.scala new file mode 100644 index 0000000..f0b1b98 --- /dev/null +++ b/open-feature/sdk/src/main/scala/io/cardell/openfeature/provider/package.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2023 Alex Cardell + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.cardell.openfeature + +package object provider { + + type FlagMetadata = Map[String, FlagMetadataValue] + +} diff --git a/project/plugins.sbt b/project/plugins.sbt index d313796..fcd01cd 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.6.7") -addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.6.7") -addSbtPlugin("org.typelevel" % "sbt-typelevel-scalafix" % "0.6.7") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17") +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.7.3") +addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.7.3") +addSbtPlugin("org.typelevel" % "sbt-typelevel-scalafix" % "0.7.3") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17")