From c9f30bb2f21260a7f4f69d7ca427993a3bd0d0dc Mon Sep 17 00:00:00 2001 From: Alex Cardell <29524087+alexcardell@users.noreply.github.com> Date: Sun, 15 Sep 2024 17:44:59 +0100 Subject: [PATCH] OpenFeature: Add in-memory provider (#32) - **OpenFeature: Add in-memory provider** - **Move dependency versions out of build.sbt** --- .github/workflows/ci.yml | 4 +- build.sbt | 54 ++-- .../provider/memory/MemoryProvider.scala | 165 ++++++++++++ .../provider/memory/MemoryProviderTest.scala | 241 ++++++++++++++++++ project/Versions.scala | 12 + 5 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 openfeature/provider-memory/src/main/scala/io/cardell/openfeature/provider/memory/MemoryProvider.scala create mode 100644 openfeature/provider-memory/src/test/scala/io/cardell/openfeature/provider/memory/MemoryProviderTest.scala create mode 100644 project/Versions.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a85a28..f098081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,11 +88,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p openfeature/provider-flipt/.jvm/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target + run: mkdir -p openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar openfeature/provider-flipt/.jvm/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target + run: tar cf targets.tar openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/build.sbt b/build.sbt index 5dae235..c83959f 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,7 @@ +import build.V + +Global / onChangedBuildSource := ReloadOnSourceChanges + // https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway ThisBuild / tlBaseVersion := "0.3" // your current series x.y @@ -32,6 +36,7 @@ lazy val projects = Seq( `flipt-sdk-server-it`, `openfeature-sdk`, `openfeature-sdk-circe`, + `openfeature-provider-memory`, `openfeature-provider-flipt`, `openfeature-provider-flipt-it`, examples, @@ -40,10 +45,10 @@ lazy val projects = Seq( 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" % V.cats, + "org.typelevel" %%% "cats-effect" % V.catsEffect, + "org.scalameta" %%% "munit" % V.munit % Test, + "org.typelevel" %%% "munit-cats-effect" % V.munitCatsEffect % Test ) ) @@ -60,11 +65,11 @@ lazy val `flipt-sdk-server` = crossProject( .settings( name := "flipt-sdk-server", libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-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" + "org.http4s" %%% "http4s-client" % V.http4s, + "org.http4s" %%% "http4s-circe" % V.http4s, + "io.circe" %%% "circe-core" % V.circe, + "io.circe" %%% "circe-parser" % V.circe, + "io.circe" %%% "circe-generic" % V.circe ) ) @@ -75,8 +80,8 @@ lazy val `flipt-sdk-server-it` = crossProject(JVMPlatform) .settings(commonDependencies) .settings( libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-ember-client" % "0.23.26", - "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.3" % Test + "org.http4s" %%% "http4s-ember-client" % V.http4s, + "com.dimafeng" %% "testcontainers-scala-munit" % V.testcontainers % Test ) ) .dependsOn(`flipt-sdk-server`) @@ -104,12 +109,25 @@ lazy val `openfeature-sdk-circe` = crossProject( .settings( name := "openfeature-sdk-circe", libraryDependencies ++= Seq( - "io.circe" %%% "circe-core" % "0.14.7", - "io.circe" %%% "circe-parser" % "0.14.7" + "io.circe" %%% "circe-core" % V.circe, + "io.circe" %%% "circe-parser" % V.circe ) ) .dependsOn(`openfeature-sdk`) +lazy val `openfeature-provider-memory` = crossProject( + JVMPlatform, + JSPlatform, + NativePlatform +) + .crossType(CrossType.Pure) + .in(file("openfeature/provider-memory")) + .settings(commonDependencies) + .settings( + name := "openfeature-provider-memory" + ) + .dependsOn(`openfeature-sdk`) + lazy val `openfeature-provider-flipt` = crossProject( JVMPlatform, JSPlatform, @@ -134,9 +152,9 @@ lazy val `openfeature-provider-flipt-it` = crossProject(JVMPlatform) .settings( name := "openfeature-provider-flipt-it", libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-ember-client" % "0.23.26" % Test, - "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.3" % Test, - "io.circe" %%% "circe-generic" % "0.14.7" % Test + "org.http4s" %%% "http4s-ember-client" % V.http4s % Test, + "com.dimafeng" %% "testcontainers-scala-munit" % V.testcontainers % Test, + "io.circe" %%% "circe-generic" % V.circe % Test ) ) .dependsOn( @@ -152,7 +170,7 @@ lazy val examples = crossProject(JVMPlatform) lazy val docs = project .in(file("site")) - .enablePlugins(TypelevelSitePlugin) + .enablePlugins(NoPublishPlugin, TypelevelSitePlugin) .settings( tlSiteHelium := { import laika.helium.config.IconLink @@ -163,7 +181,7 @@ lazy val docs = project ) }, libraryDependencies ++= Seq( - "org.http4s" %%% "http4s-ember-client" % "0.23.26" + "org.http4s" %%% "http4s-ember-client" % V.http4s ) ) .dependsOn( diff --git a/openfeature/provider-memory/src/main/scala/io/cardell/openfeature/provider/memory/MemoryProvider.scala b/openfeature/provider-memory/src/main/scala/io/cardell/openfeature/provider/memory/MemoryProvider.scala new file mode 100644 index 0000000..40aa299 --- /dev/null +++ b/openfeature/provider-memory/src/main/scala/io/cardell/openfeature/provider/memory/MemoryProvider.scala @@ -0,0 +1,165 @@ +/* + * 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.memory + +import cats.MonadThrow +import cats.effect.kernel.Ref +import cats.effect.kernel.Sync +import cats.syntax.all._ + +import io.cardell.openfeature.ErrorCode +import io.cardell.openfeature.EvaluationContext +import io.cardell.openfeature.EvaluationReason +import io.cardell.openfeature.StructureDecoder +import io.cardell.openfeature.provider.EvaluationProvider +import io.cardell.openfeature.provider.ProviderMetadata +import io.cardell.openfeature.provider.ResolutionDetails + +sealed trait MemoryFlagState + +object MemoryFlagState { + case class BooleanFlagState(value: Boolean) extends MemoryFlagState + case class StringFlagState(value: String) extends MemoryFlagState + case class IntFlagState(value: Int) extends MemoryFlagState + case class DoubleFlagState(value: Double) extends MemoryFlagState +} + +final class MemoryProvider[F[_]: MonadThrow]( + ref: Ref[F, Map[String, MemoryFlagState]] +) extends EvaluationProvider[F] { + + import MemoryFlagState._ + + override def metadata: ProviderMetadata = ProviderMetadata("memory") + + private def missing[A]( + flagKey: String, + defaultValue: A + ): ResolutionDetails[A] = ResolutionDetails( + value = defaultValue, + errorCode = Some(ErrorCode.FlagNotFound), + errorMessage = Some(s"${flagKey} not found"), + reason = Some(EvaluationReason.Error), + variant = None, + metadata = None + ) + + private def typeMismatch[A]( + flagKey: String, + defaultValue: A + ): ResolutionDetails[A] = ResolutionDetails( + value = defaultValue, + errorCode = Some(ErrorCode.TypeMismatch), + errorMessage = Some(s"${flagKey} was unexpected type"), + reason = Some(EvaluationReason.Error), + variant = None, + metadata = None + ) + + private def resolution[A](value: A): ResolutionDetails[A] = ResolutionDetails( + value = value, + errorCode = None, + errorMessage = None, + reason = Some(EvaluationReason.Static), + variant = None, + metadata = None + ) + + override def resolveBooleanValue( + flagKey: String, + defaultValue: Boolean, + context: EvaluationContext + ): F[ResolutionDetails[Boolean]] = ref.get.map { state => + state.get(flagKey) match { + case None => missing[Boolean](flagKey, defaultValue) + case Some(BooleanFlagState(value)) => resolution[Boolean](value) + case Some(_) => typeMismatch(flagKey, defaultValue) + } + } + + override def resolveStringValue( + flagKey: String, + defaultValue: String, + context: EvaluationContext + ): F[ResolutionDetails[String]] = ref.get.map { state => + state.get(flagKey) match { + case None => missing[String](flagKey, defaultValue) + case Some(StringFlagState(value)) => resolution[String](value) + case Some(_) => typeMismatch(flagKey, defaultValue) + } + } + + override def resolveIntValue( + flagKey: String, + defaultValue: Int, + context: EvaluationContext + ): F[ResolutionDetails[Int]] = ref.get.map { state => + state.get(flagKey) match { + case None => missing[Int](flagKey, defaultValue) + case Some(IntFlagState(value)) => resolution[Int](value) + case Some(_) => typeMismatch(flagKey, defaultValue) + } + } + + override def resolveDoubleValue( + flagKey: String, + defaultValue: Double, + context: EvaluationContext + ): F[ResolutionDetails[Double]] = ref.get.map { state => + state.get(flagKey) match { + case None => missing[Double](flagKey, defaultValue) + case Some(DoubleFlagState(value)) => resolution[Double](value) + case Some(_) => typeMismatch(flagKey, defaultValue) + } + } + + override def resolveStructureValue[A: StructureDecoder]( + flagKey: String, + defaultValue: A, + context: EvaluationContext + ): F[ResolutionDetails[A]] = MonadThrow[F].raiseError( + new NotImplementedError( + "Structure values not implemented in in-memory provider" + ) + ) + // { + // val resolved = ref.get.map { state => + // val x = state.get(flagKey) match { + // case None => missing[A](flagKey, defaultValue) + // case Some(StructureFlagState(value)) => resolution[A](value.asInstanceOf[A]) + // case Some(_) => typeMismatch(flagKey, defaultValue) + // } + // @nowarn + // val value: A = x.value + // x + // } + // + // resolved.handleError { case _ => missing[A](flagKey, defaultValue) } + // } + +} + +object MemoryProvider { + + def apply[F[_]: Sync]( + state: Map[String, MemoryFlagState] + ): F[MemoryProvider[F]] = + for { + ref <- Ref.of[F, Map[String, MemoryFlagState]](state) + } yield new MemoryProvider[F](ref) + +} diff --git a/openfeature/provider-memory/src/test/scala/io/cardell/openfeature/provider/memory/MemoryProviderTest.scala b/openfeature/provider-memory/src/test/scala/io/cardell/openfeature/provider/memory/MemoryProviderTest.scala new file mode 100644 index 0000000..fe5bdeb --- /dev/null +++ b/openfeature/provider-memory/src/test/scala/io/cardell/openfeature/provider/memory/MemoryProviderTest.scala @@ -0,0 +1,241 @@ +/* + * 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.memory + +import cats.effect.IO +import munit.CatsEffectSuite + +import io.cardell.openfeature.ErrorCode +import io.cardell.openfeature.EvaluationContext +import io.cardell.openfeature.StructureDecoder +import io.cardell.openfeature.StructureDecoderError + +class MemoryProviderTest extends CatsEffectSuite { + + case class TestStructure(s: String, i: Int) + case class OtherTestStructure(d: Double) + + implicit val a: StructureDecoder[TestStructure] = + new StructureDecoder[TestStructure] { + + def decodeStructure( + string: String + ): Either[StructureDecoderError, TestStructure] = ??? + + } + + test("can return boolean values") { + val expected = true + + val flag = MemoryFlagState.BooleanFlagState(expected) + val key = "boolean-flag-key" + val state = Map(key -> flag) + + val default = false + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveBooleanValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution.map(_.value) + } yield assertEquals(result, expected) + } + } + + test("can return string values") { + val expected = "string" + + val flag = MemoryFlagState.StringFlagState(expected) + val key = "string-flag-key" + val state = Map(key -> flag) + + val default = "default" + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveStringValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution.map(_.value) + } yield assertEquals(result, expected) + } + } + + test("can return int values when type is as expected") { + val expected = 33 + + val flag = MemoryFlagState.IntFlagState(expected) + val key = "int-flag-key" + val state = Map(key -> flag) + + val default = 0 + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveIntValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution.map(_.value) + } yield assertEquals(result, expected) + } + } + + test("can return double values when type is as expected") { + val expected = 40.0 + + val flag = MemoryFlagState.DoubleFlagState(expected) + val key = "double-flag-key" + val state = Map(key -> flag) + + val default = 0.0 + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveDoubleValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution.map(_.value) + } yield assertEquals(result, expected) + } + } + + test("receives type mismatch error when boolean not received") { + val expectedValue = false + val expectedErrorCode = Some(ErrorCode.TypeMismatch) + + val flag = MemoryFlagState.DoubleFlagState(0.0) + val key = "boolean-flag-key" + val state = Map(key -> flag) + + val default = expectedValue + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveBooleanValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution + resultValue = result.value + errorCode = result.errorCode + } yield { + assertEquals(resultValue, expectedValue) + assertEquals(errorCode, expectedErrorCode) + } + } + } + + test("receives type mismatch error when string not received") { + val expectedValue = "default" + val expectedErrorCode = Some(ErrorCode.TypeMismatch) + + val flag = MemoryFlagState.DoubleFlagState(0.0) + val key = "string-flag-key" + val state = Map(key -> flag) + + val default = expectedValue + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveStringValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution + resultValue = result.value + errorCode = result.errorCode + } yield { + assertEquals(resultValue, expectedValue) + assertEquals(errorCode, expectedErrorCode) + } + } + } + + test("receives type mismatch error when int not received") { + val expectedValue = 33 + val expectedErrorCode = Some(ErrorCode.TypeMismatch) + + val flag = MemoryFlagState.DoubleFlagState(0.0) + val key = "int-flag-key" + val state = Map(key -> flag) + + val default = expectedValue + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveIntValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution + resultValue = result.value + errorCode = result.errorCode + } yield { + assertEquals(resultValue, expectedValue) + assertEquals(errorCode, expectedErrorCode) + } + } + } + + test("receives type mismatch error when double not received") { + val expectedValue = 40.0 + val expectedErrorCode = Some(ErrorCode.TypeMismatch) + + val flag = MemoryFlagState.IntFlagState(0) + val key = "double-flag-key" + val state = Map(key -> flag) + + val default = expectedValue + + MemoryProvider[IO](state).flatMap { provider => + val resolution = provider.resolveDoubleValue( + key, + default, + EvaluationContext.empty + ) + + for { + result <- resolution + resultValue = result.value + errorCode = result.errorCode + } yield { + assertEquals(resultValue, expectedValue) + assertEquals(errorCode, expectedErrorCode) + } + } + } + +} diff --git a/project/Versions.scala b/project/Versions.scala new file mode 100644 index 0000000..5efe41f --- /dev/null +++ b/project/Versions.scala @@ -0,0 +1,12 @@ +package build + +object V { + val cats = "2.10.0" + val catsEffect = "3.5.4" + val http4s = "0.23.26" + val circe = "0.14.7" + + val munit = "1.0.0-RC1" + val munitCatsEffect = "2.0.0-M5" + val testcontainers = "0.41.3" +}