diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd9ccf3..aa74e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,11 +75,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 core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target + run: mkdir -p circe/.jvm/target core/.jvm/target mtl/.jvm/target testkit/.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 core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target + run: tar cf targets.tar circe/.jvm/target core/.jvm/target mtl/.jvm/target testkit/.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 2cb6242..8b907a9 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ // https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway -ThisBuild / tlBaseVersion := "0.6" // your current series x.y +ThisBuild / tlBaseVersion := "0.7" // your current series x.y ThisBuild / organization := "org.typelevel" ThisBuild / organizationName := "Typelevel" @@ -21,7 +21,7 @@ val Scala213 = "2.13.16" ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.3") ThisBuild / scalaVersion := Scala213 // the default Scala -lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit) +lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit, circe) lazy val testkit = crossProject(JVMPlatform) .crossType(CrossType.Pure) @@ -64,4 +64,21 @@ lazy val mtl = crossProject(JVMPlatform) ) .dependsOn(core) +lazy val circe = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .in(file("circe")) + .settings( + name := "catapult-circe", + libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % "0.14.12", + "io.circe" %% "circe-parser" % "0.14.12", + "org.scalameta" %% "munit-scalacheck" % "1.1.0" % Test, + ), + tlVersionIntroduced := Map( + "2.13" -> "0.7.0", + "3" -> "0.7.0", + ), + ) + .dependsOn(core, mtl) + lazy val docs = project.in(file("site")).enablePlugins(TypelevelSitePlugin) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala new file mode 100644 index 0000000..1745725 --- /dev/null +++ b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.circe + +import io.circe.{Decoder, Encoder, Json} +import org.typelevel.catapult.FeatureKey +import org.typelevel.catapult.circe.JsonLDCodec.circeLDCodecForJSON +import org.typelevel.catapult.codec.LDCodec +import org.typelevel.catapult.codec.LDCodec.LDCodecResult + +object CirceFeatureKey { + + /** Define a feature key that is expected to return a JSON value. + * + * This uses `circe` encoding for JSON and will fail if the default value + * cannot be represented by LaunchDarkly's `LDValue` + * + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def featureKey( + key: String, + default: Json, + ): LDCodecResult[FeatureKey.Aux[Json]] = + FeatureKey.instanceOrFailure(key, default) + + /** Define a feature key that is expected to return a JSON value. + * + * This uses `circe` encoding for JSON and will fail if the default value + * cannot be represented by LaunchDarkly's `LDValue` + * + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def featureKeyEncoded[A: Encoder: Decoder]( + key: String, + default: A, + ): LDCodecResult[FeatureKey.Aux[A]] = { + implicit val ldCodec: LDCodec[A] = JsonLDCodec.ldCodecFromCirceCodec[A] + FeatureKey.instanceOrFailure(key, default) + } +} diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/JsonLDCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/JsonLDCodec.scala new file mode 100644 index 0000000..475885b --- /dev/null +++ b/circe/src/main/scala/org.typelevel/catapult/circe/JsonLDCodec.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.circe + +import cats.Defer +import cats.data.{Chain, NonEmptyChain, Validated} +import cats.syntax.all.* +import com.launchdarkly.sdk.json.{JsonSerialization, SerializationException} +import com.launchdarkly.sdk.{LDValue, LDValueType} +import io.circe.* +import org.typelevel.catapult.codec.* +import org.typelevel.catapult.codec.LDCodec.LDCodecResult +import org.typelevel.catapult.codec.LDCursorHistory.Move +import org.typelevel.catapult.codec.syntax.* + +object JsonLDCodec { + implicit val circeLDCodecForJSON: LDCodec[Json] = + JsonLDCodecImplementation.jsonLDCodecRetainingNumberErrors + + def ldCodecFromCirceCodec[A: Encoder: Decoder]: LDCodec[A] = + circeLDCodecForJSON.imapVFull[A]( + (a, _) => Encoder[A].apply(a).valid, + (json, history) => + Decoder[A].decodeAccumulating(json.hcursor).leftMap { errors => + NonEmptyChain + .fromNonEmptyList(errors) + .map(JsonLDCodecImplementation.convertCirceFailureToLDFailure) + .map(lcf => lcf.updateHistory(history.combine(_))) + }, + ) +} + +// This is separate to keep the implicits from colliding during construction +private object JsonLDCodecImplementation { + val jsonLDCodecRetainingNumberErrors: LDCodec[Json] = Defer[LDCodec].fix { implicit recurse => + LDCodec.instance( + _encode = (json, history) => + json.fold[LDCodecResult[LDValue]]( + jsonNull = LDValue.ofNull().validNec, + jsonBoolean = _.asLDValue.valid, + jsonNumber = _.asLDValueOrFailure(history), + jsonString = _.asLDValue.valid, + jsonArray = _.asLDValueOrFailure(history), + jsonObject = _.toIterable.asLDValueOrFailure(history), + ), + _decode = cursor => + cursor.valueType match { + case LDValueType.NULL => Json.Null.valid + case LDValueType.BOOLEAN => cursor.as[Boolean].map(Json.fromBoolean) + case LDValueType.NUMBER => cursor.as[JsonNumber].map(Json.fromJsonNumber) + case LDValueType.STRING => cursor.as[String].map(Json.fromString) + case LDValueType.ARRAY => cursor.as[Iterable[Json]].map(Json.fromValues) + case LDValueType.OBJECT => cursor.as[Vector[(String, Json)]].map(Json.fromFields(_)) + }, + ) + } + + def convertCirceFailureToLDFailure(decodingFailure: DecodingFailure): LDCodecFailure = + LDCodecFailure( + decodingFailure.reason match { + case DecodingFailure.Reason.CustomReason(message) => LDReason.other(message) + case DecodingFailure.Reason.MissingField => LDReason.missingField + case DecodingFailure.Reason.WrongTypeExpectation(expectedJsonFieldType, jsonValue) => + LDReason.wrongType( + expectedJsonFieldType, + jsonValue.fold( + jsonNull = LDValueType.NULL, + jsonBoolean = _ => LDValueType.BOOLEAN, + jsonNumber = _ => LDValueType.NUMBER, + jsonString = _ => LDValueType.STRING, + jsonArray = _ => LDValueType.ARRAY, + jsonObject = _ => LDValueType.OBJECT, + ), + ) + }, + LDCursorHistory.of(Chain.fromSeq(decodingFailure.history).mapFilter { + case CursorOp.DownField(k) => Move(k).some + case CursorOp.DownN(n) => Move(n).some + case _ => none + }), + ) + + implicit private val jNumberCodec: LDCodec[JsonNumber] = LDCodec.instance( + (jNumber, history) => + Validated + .catchOnly[SerializationException] { + // This nasty hack is because LDValue number support is lacking. + // + // LDValueNumber encodes everything as Double and that introduces encoding + // issues like negative/positive/unsigned zero and rounding if we try to do + // the conversion ourselves with the raw. + // + // Here, we're basically hoping they've got their house in order and can + // parse a valid JSON number. + LDValue.normalize( + JsonSerialization.deserialize( + Json.fromJsonNumber(jNumber).noSpaces, + classOf[LDValue], + ) + ) + } + .leftMap { (_: SerializationException) => + LDCodecFailure(LDReason.unencodableValue(LDValueType.NUMBER, "JNumber"), history) + } + .toValidatedNec, + _.checkType(LDValueType.NUMBER).andThen { c => + // Less of a hack on the encoding side, because circe can handle (most) doubles. + // The JVM double doesn't map cleanly to the JSON number, so this can fail as well. + LDCodec[Double] + .decode(c) + .map(Json.fromDouble(_).flatMap(_.asNumber)) + .andThen(_.toValidNec { + c.fail(LDReason.undecodableValue(LDValueType.NUMBER, "JNumber")) + }) + }, + ) +} diff --git a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala new file mode 100644 index 0000000..f21651d --- /dev/null +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.circe.syntax + +import cats.MonadThrow +import cats.syntax.all.* +import io.circe.syntax.* +import io.circe.{Decoder, Encoder, Json} +import org.typelevel.catapult.circe.JsonLDCodec.* +import org.typelevel.catapult.codec.LDCursorHistory +import org.typelevel.catapult.codec.syntax.* +import org.typelevel.catapult.{ContextEncoder, LaunchDarklyClient} + +object client { + implicit final class CatapultLaunchDarklyClientCirceOps[F[_]]( + private val client: LaunchDarklyClient[F] + ) extends AnyVal { + def circeVariation[Ctx: ContextEncoder](featureKey: String, ctx: Ctx, defaultValue: Json)( + implicit F: MonadThrow[F] + ): F[Json] = + defaultValue + .asLDValueOrFailure(LDCursorHistory.root) + .asEncodingFailure + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, ctx, _)) + .flatMap(_.decode[Json].asDecodingFailure.liftTo[F]) + + def circeVariationAs[A: Decoder: Encoder, Ctx: ContextEncoder]( + featureKey: String, + ctx: Ctx, + defaultValue: A, + )(implicit F: MonadThrow[F]): F[A] = + circeVariation[Ctx](featureKey, ctx, defaultValue.asJson) + .flatMap(_.as[A].liftTo[F]) + } +} diff --git a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala new file mode 100644 index 0000000..ea55fdd --- /dev/null +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.circe.syntax + +import cats.MonadThrow +import cats.syntax.all.* +import io.circe.syntax.* +import io.circe.{Decoder, Encoder, Json} +import org.typelevel.catapult.circe.JsonLDCodec.* +import org.typelevel.catapult.codec.LDCursorHistory +import org.typelevel.catapult.codec.syntax.* +import org.typelevel.catapult.mtl.LaunchDarklyMTLClient + +object mtlClient { + implicit final class CatapultLaunchDarklyMTLClientCirceOps[F[_]]( + private val client: LaunchDarklyMTLClient[F] + ) extends AnyVal { + def circeVariation(featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F]): F[Json] = + defaultValue + .asLDValueOrFailure(LDCursorHistory.root) + .asEncodingFailure + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, _)) + .flatMap(_.decode[Json].asDecodingFailure.liftTo[F]) + + def circeVariationAs[A: Decoder: Encoder](featureKey: String, defaultValue: A)(implicit + F: MonadThrow[F] + ): F[A] = + circeVariation(featureKey, defaultValue.asJson) + .flatMap(_.as[A].liftTo[F]) + } +} diff --git a/circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala b/circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala new file mode 100644 index 0000000..c082b51 --- /dev/null +++ b/circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.circe + +import cats.syntax.all.* +import com.launchdarkly.sdk.LDValue +import munit.ScalaCheckSuite +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Prop.* +import org.scalacheck.{Arbitrary, Gen} +import org.typelevel.catapult.circe.JsonLDCodec.* +import org.typelevel.catapult.instances.* + +class JsonLDCodecTest extends ScalaCheckSuite { + private val maxDepth = 3 + private val maxEntries = 5 + private val maxFields = 5 + + private val genLDScalar = Gen.oneOf( + Gen.const(LDValue.ofNull()), + arbitrary[Boolean].map(LDValue.of), + arbitrary[String].map(LDValue.of), + // Important: all numeric constructors delegate to LDValue.of(Double), so + // we're only using this one. This means it will not exercise the full range + // of values supported by circe, but since the values coming in are LDValues, + // this will need to be good enough. + arbitrary[Double].map(LDValue.of), + ) + + private def genLDArray(depth: Int): Gen[LDValue] = + for { + len <- Gen.chooseNum(0, maxEntries) + entries <- Gen.listOfN(len, genLDValue(depth + 1)) + } yield { + val builder = LDValue.buildArray() + entries.foreach(builder.add) + builder.build() + } + + private def genLDObject(depth: Int): Gen[LDValue] = + for { + count <- Gen.chooseNum(0, maxFields) + fields <- Gen.listOfN( + count, + Gen.zip(arbitrary[String], genLDValue(depth + 1)), + ) + } yield { + val builder = LDValue.buildObject() + fields.foreach { case (key, value) => + builder.put(key, value) + } + builder.build() + } + + private def genLDValue(depth: Int): Gen[LDValue] = + if (depth < maxDepth) Gen.oneOf(genLDScalar, genLDArray(depth + 1), genLDObject(depth + 1)) + else genLDScalar + + implicit private val arbLDValue: Arbitrary[LDValue] = Arbitrary(genLDValue(0)) + + property("circeLDCodecForJSON round trip")(forAll { (input: LDValue) => + circeLDCodecForJSON + .decode(input) + .andThen(circeLDCodecForJSON.encode) + .leftMap(_.toNonEmptyVector.toVector) // Displays better in MUnit + .map { output => + // Important: do not swap out eqv for LDValue.equals (which is asymmetric) + if (input.eqv(output)) proved + else + falsified :| s"Expected ${input.toJsonString} but was ${output.toJsonString}" + } + .valueOr(failures => fail("Conversion failed", clues(failures))) + }) +} diff --git a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala new file mode 100644 index 0000000..0f3feb5 --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult + +import cats.syntax.all.* +import cats.Show +import cats.kernel.Hash +import com.launchdarkly.sdk.LDValue +import org.typelevel.catapult.codec.LDCodec.LDCodecResult +import org.typelevel.catapult.codec.{LDCodec, LDCodecWithInfallibleEncode, LDCursorHistory} +import org.typelevel.catapult.instances.* + +/** Defines a Launch Darkly key, it's expected type, and a default value + */ +trait FeatureKey { + type Type + def key: String + def default: Type + def ldValueDefault: LDValue + def codec: LDCodec[Type] +} +object FeatureKey { + type Aux[T] = FeatureKey { + type Type = T + } + + /** Define a feature key that is expected to return a value of type `A` + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the value cannot be decoded to an `A` + */ + def instance[A: LDCodecWithInfallibleEncode](key: String, default: A): FeatureKey.Aux[A] = + new Impl[A](key, default, LDCodecWithInfallibleEncode[A].safeEncode(default)) + + /** Define a feature key that is expected to return a value of type `A` + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the value cannot be decoded to an `A` + */ + def instanceOrFailure[A: LDCodec](key: String, default: A): LDCodecResult[FeatureKey.Aux[A]] = + LDCodec[A].encode(default, LDCursorHistory.root).map(new Impl[A](key, default, _)) + + /** Define a feature key that is expected to return a boolean value. + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def bool(key: String, default: Boolean): FeatureKey.Aux[Boolean] = instance[Boolean](key, default) + + /** Define a feature key that is expected to return a string value. + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def string(key: String, default: String): FeatureKey.Aux[String] = instance[String](key, default) + + /** Define a feature key that is expected to return a integer value. + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def int(key: String, default: Int): LDCodecResult[FeatureKey.Aux[Int]] = + instanceOrFailure[Int](key, default) + + /** Define a feature key that is expected to return a double value. + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def double(key: String, default: Double): FeatureKey.Aux[Double] = instance[Double](key, default) + + /** Define a feature key that is expected to return a JSON value. + * + * This uses the LaunchDarkly `LDValue` encoding for JSON + * + * @param key + * the key of the flag + * @param default + * a value to return if the retrieval fails or the type is not expected + */ + def ldValue(key: String, default: LDValue): FeatureKey.Aux[LDValue] = + new Impl[LDValue](key, default, default) + + implicit val show: Show[FeatureKey] = Show.fromToString + implicit val hash: Hash[FeatureKey] = Hash.by(fk => (fk.key, fk.ldValueDefault)) + + private final class Impl[A: LDCodec](_key: String, _default: A, _ldValueDefault: LDValue) + extends FeatureKey { + override type Type = A + override val key: String = _key + override val codec: LDCodec[A] = LDCodec[A] + override val ldValueDefault: LDValue = _ldValueDefault + override def default: A = _default + + override def toString: String = s"FeatureKey($key, ${ldValueDefault.show})" + + override def hashCode(): Int = FeatureKey.hash.hash(this) + + override def equals(obj: Any): Boolean = obj match { + case that: FeatureKey => FeatureKey.hash.eqv(this, that) + case _ => false + } + } +} diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index a5595fd..dea1a71 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -16,13 +16,15 @@ package org.typelevel.catapult +import cats.data.Validated import cats.effect.std.{Dispatcher, Queue} import cats.effect.{Async, Resource} -import cats.{~>, Applicative} +import cats.syntax.all.* +import cats.{Applicative, ~>} +import com.launchdarkly.sdk.LDValue import com.launchdarkly.sdk.server.interfaces.{FlagValueChangeEvent, FlagValueChangeListener} import com.launchdarkly.sdk.server.{LDClient, LDConfig} -import com.launchdarkly.sdk.LDValue -import fs2._ +import fs2.* trait LaunchDarklyClient[F[_]] { @@ -94,6 +96,23 @@ trait LaunchDarklyClient[F[_]] { defaultValue: LDValue, ): F[LDValue] = jsonValueVariation(featureKey, context, defaultValue) + /** Retrieve the flag value, suspended in the `F` effect + * + * @param featureKey + * Defines the key, type, and default value + * @see + * [[FeatureKey]] + * @see + * [[boolVariation]] + * @see + * [[stringVariation]] + * @see + * [[doubleVariation]] + * @see + * [[jsonValueVariation]] + */ + def variation[Ctx: ContextEncoder](featureKey: FeatureKey, ctx: Ctx): F[featureKey.Type] + /** @param featureKey the key of the flag to be evaluated * @param context the context against which the flag is being evaluated * @tparam Ctx the type representing the context; this can be [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/LDContext.html LDContext]], [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/LDUser.html LDUser]], or any type with a [[ContextEncoder]] instance in scope. @@ -143,7 +162,7 @@ object LaunchDarklyClient { ldClient: LDClient )(implicit F: Async[F]): LaunchDarklyClient[F] = new LaunchDarklyClient.Default[F] { - + override protected def async: Async[F] = Async[F] override def unsafeWithJavaClient[A](f: LDClient => A): F[A] = F.blocking(f(ldClient)) @@ -176,6 +195,7 @@ object LaunchDarklyClient { private trait Default[F[_]] extends LaunchDarklyClient[F] { self => + implicit protected def async: Async[F] protected def unsafeWithJavaClient[A](f: LDClient => A): F[A] override def boolVariation[Ctx]( @@ -211,6 +231,23 @@ object LaunchDarklyClient { )(implicit ctxEncoder: ContextEncoder[Ctx]): F[LDValue] = unsafeWithJavaClient(_.jsonValueVariation(featureKey, ctxEncoder.encode(context), default)) + override def variation[Ctx: ContextEncoder]( + featureKey: FeatureKey, + ctx: Ctx, + ): F[featureKey.Type] = + jsonValueVariation[Ctx](featureKey.key, ctx, featureKey.ldValueDefault).flatMap { lv => + featureKey.codec.decode(lv) match { + case Validated.Valid(a) => a.pure[F] + case Validated.Invalid(errors) => + unsafeWithJavaClient { client => + val logger = client.getLogger + async.blocking { + logger.error("{}", errors.mkString_("Unable to decode LDValue\n", "\n", "\n")) + } + }.flatMap(_.as(featureKey.default)) + } + } + override def flush: F[Unit] = unsafeWithJavaClient(_.flush()) // TODO: Remove on next bin changing release, had to keep this for bin-compat. @@ -250,6 +287,12 @@ object LaunchDarklyClient { ): fs2.Stream[F, FlagValueChangeEvent] = fs2.Stream.empty override def flush: F[Unit] = Applicative[F].unit + + override def variation[Ctx: ContextEncoder]( + featureKey: FeatureKey, + ctx: Ctx, + ): F[featureKey.Type] = + F.pure(featureKey.default) } def mapK[F[_], G[_]](self: LaunchDarklyClient[F])(fk: F ~> G): LaunchDarklyClient[G] = @@ -284,6 +327,12 @@ object LaunchDarklyClient { defaultValue: String, ): G[String] = fk(self.stringVariation(featureKey, context, defaultValue)) + override def variation[Ctx: ContextEncoder]( + featureKey: FeatureKey, + ctx: Ctx, + ): G[featureKey.Type] = + fk(self.variation(featureKey, ctx)) + override def trackFlagValueChanges[Ctx: ContextEncoder]( featureKey: String, context: Ctx, diff --git a/core/src/main/scala/org.typelevel/catapult/instances.scala b/core/src/main/scala/org.typelevel/catapult/instances.scala new file mode 100644 index 0000000..aafc226 --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/instances.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult + +import cats.Show +import cats.kernel.Hash +import com.launchdarkly.sdk.LDValue + +object instances { + implicit val catapultCatsInstancesForLDValue: Hash[LDValue] & Show[LDValue] = new Hash[LDValue] + with Show[LDValue] { + override def hash(x: LDValue): Int = x.## + + override def eqv(x: LDValue, y: LDValue): Boolean = + // Need to do both directions because LDValue.equals is asymmetric for objects + x == y && y == x + + override def show(t: LDValue): String = t.toJsonString + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala new file mode 100644 index 0000000..febaee5 --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala @@ -0,0 +1,454 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.{Defer, Invariant} +import cats.data.* +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDValue, LDValueType} +import org.typelevel.catapult.codec.LDCodec.LDCodecResult +import org.typelevel.catapult.codec.LDReason.{unableToDecodeKey, unableToEncodeKey} + +import scala.annotation.tailrec +import scala.collection.Factory +import scala.reflect.ClassTag + +/** A type class that provides a way to convert a value of type `A` to and from `LDValue` + */ +trait LDCodec[A] { + + /** Encode a value to `LDValue` + * + * This may fail + */ + def encode(a: A, history: LDCursorHistory): LDCodecResult[LDValue] + + /** Encode a value to `LDValue` + * + * This may fail + */ + def encode(a: A): LDCodecResult[LDValue] = encode(a, LDCursorHistory.root) + + /** Decode the given `LDCursor` + */ + def decode(c: LDCursor): LDCodecResult[A] + + /** Decode the given `LDValue` + */ + def decode(ld: LDValue): LDCodecResult[A] = decode(LDCursor.root(ld)) + + /** A variant of `imap`` which allows the transformation from `A` to `B` + * to fail + * + * @see [[imapVFull]] if the failure can affect the history + * @note `imap` is provided by [[cats.Invariant]] + */ + def imapV[B]( + bToA: B => ValidatedNec[LDReason, A], + aToB: A => ValidatedNec[LDReason, B], + ): LDCodec[B] = + imapVFull( + (b, history) => bToA(b).leftMap(_.map(LDCodecFailure(_, history))), + (a, history) => aToB(a).leftMap(_.map(LDCodecFailure(_, history))), + ) + + /** A variant of `imap` which allows the transformation from A` to `B` + * to fail with an updated history + * + * @see [[imapV]] for a simpler API when updating the history is not needed + * @note `imap` is provided by [[cats.Invariant]] + */ + def imapVFull[B]( + bToA: (B, LDCursorHistory) => LDCodecResult[A], + aToB: (A, LDCursorHistory) => LDCodecResult[B], + ): LDCodec[B] = LDCodec.imapVFull(this, bToA, aToB) +} +object LDCodec { + def apply[A](implicit LDC: LDCodec[A]): LDC.type = LDC + + type LDCodecResult[A] = ValidatedNec[LDCodecFailure, A] + + def instance[A]( + _encode: (A, LDCursorHistory) => LDCodecResult[LDValue], + _decode: LDCursor => LDCodecResult[A], + ): LDCodec[A] = + new LDCodec[A] { + override def encode(a: A, history: LDCursorHistory): LDCodecResult[LDValue] = + _encode(a, history) + override def decode(c: LDCursor): LDCodecResult[A] = _decode(c) + } + + def withInfallibleEncode[A]( + encode: A => LDValue, + decode: LDCursor => A, + ): LDCodecWithInfallibleEncode[A] = + LDCodecWithInfallibleEncode.instance(encode, decode) + + def withInfallibleEncodeFull[A]( + encode: A => LDValue, + decode: LDCursor => LDCodecResult[A], + ): LDCodecWithInfallibleEncode[A] = + LDCodecWithInfallibleEncode.instanceFull(encode, decode) + + final class DecodingFailure(val failures: NonEmptyChain[LDCodecFailure]) + extends IllegalArgumentException { + override def getMessage: String = + failures.mkString_("Failed to decode from LDValue:\n", "\n", "\n") + } + + final class EncodingFailure(val failures: NonEmptyChain[LDCodecFailure]) + extends IllegalArgumentException { + override def getMessage: String = + failures.mkString_("Failed to encode to LDValue:\n", "\n", "\n") + } + + implicit def promoteTheSubclass[A](implicit LA: LDCodecWithInfallibleEncode[A]): LDCodec[A] = LA + + private def imapVFull[A, B]( + codec: LDCodec[A], + bToA: (B, LDCursorHistory) => LDCodecResult[A], + aToB: (A, LDCursorHistory) => LDCodecResult[B], + ): LDCodec[B] = + new LDCodec[B] { + override def encode(b: B, history: LDCursorHistory): LDCodecResult[LDValue] = + bToA(b, history).andThen(codec.encode(_, history)) + + override def decode(c: LDCursor): LDCodecResult[B] = + codec.decode(c).andThen(aToB(_, c.history)) + } + + implicit val invariant: Invariant[LDCodec] = new Invariant[LDCodec] { + override def imap[A, B](codec: LDCodec[A])(aToB: A => B)(bToA: B => A): LDCodec[B] = + new LDCodec[B] { + override def encode(b: B, history: LDCursorHistory): LDCodecResult[LDValue] = + codec.encode(bToA(b), history) + override def decode(c: LDCursor): LDCodecResult[B] = codec.decode(c).map(aToB) + } + } + + private final case class Deferred[A](fa: () => LDCodec[A]) extends LDCodec[A] { + private lazy val resolved: LDCodec[A] = { + @tailrec + def loop(f: () => LDCodec[A]): LDCodec[A] = + f() match { + case Deferred(f) => loop(f) + case next => next + } + + loop(fa) + } + + override def encode(a: A, history: LDCursorHistory): LDCodecResult[LDValue] = + resolved.encode(a, history) + + override def decode(c: LDCursor): LDCodecResult[A] = resolved.decode(c) + } + implicit val defer: Defer[LDCodec] = new Defer[LDCodec] { + override def defer[A](fa: => LDCodec[A]): LDCodec[A] = { + lazy val cachedFa = fa + Deferred(() => cachedFa) + } + } + + def numericInstance[N]( + typeName: String, + toDouble: N => Double, + fromDouble: Double => N, + ): LDCodec[N] = LDCodec[Double].imapV[N]( + n => { + val d = toDouble(n) + Validated + .condNec(toDouble(n) == d, d, LDReason.undecodableValue(LDValueType.NUMBER, typeName)) + }, + d => { + val n = fromDouble(d) + Validated.condNec( + toDouble(n) == d, + n, + LDReason.unencodableValue(LDValueType.NUMBER, typeName), + ) + }, + ) + + implicit val floatInstance: LDCodec[Float] = numericInstance("Float", _.toDouble, _.toFloat) + + implicit val intInstance: LDCodec[Int] = numericInstance("Int", _.toDouble, _.toInt) + + implicit val longInstance: LDCodec[Long] = numericInstance("Long", _.toDouble, _.toLong) + + implicit val noneInstance: LDCodecWithInfallibleEncode[None.type] = withInfallibleEncodeFull( + _ => LDValue.ofNull(), + _.checkType(LDValueType.NULL).as(None), + ) + + implicit def decodeSome[A, C[_] <: LDCodec[?]](implicit CA: C[A], I: Invariant[C]): C[Some[A]] = + CA.imap[Some[A]](Some(_))(_.value) + + implicit def decodeOption[A: LDCodec]: LDCodec[Option[A]] = + LDCodec.instance[Option[A]]( + (opt, history) => + opt.fold(LDValue.ofNull().validNec[LDCodecFailure])(LDCodec[A].encode(_, history)), + c => noneInstance.decode(c).findValid(LDCodec[A].decode(c).map(_.some)), + ) + + private def decodeIterableShaped[CC, A](factory: Factory[A, CC])(cursor: LDCursor)(implicit + CA: LDCodec[A] + ): LDCodecResult[CC] = cursor.checkType(LDValueType.ARRAY).andThen { c => + val builder = factory.newBuilder + val failures = Vector.newBuilder[LDCodecFailure] + builder.sizeHint(c.value.size()) + var idx = 0 + c.value.values().forEach { ldValue => + CA.decode(LDCursor.of(LDValue.normalize(ldValue), c.history.at(idx))) match { + case Validated.Invalid(e) => failures.addAll(e.iterator) + case Validated.Valid(value) => builder.addOne(value) + } + idx = idx + 1 + } + NonEmptyChain + .fromChain(Chain.fromSeq(failures.result())) + .toInvalid(builder.result()) + } + + def makeIterableWithInfallibleEncodeInstance[F[_], A]( + toIterator: F[A] => Iterator[A], + factory: Factory[A, F[A]], + )(implicit CA: LDCodecWithInfallibleEncode[A]): LDCodecWithInfallibleEncode[F[A]] = + LDCodecWithInfallibleEncode.instanceFull[F[A]]( + fa => { + val builder = LDValue.buildArray() + toIterator(fa).foreach { elem => + builder.add(CA.safeEncode(elem)) + } + builder.build() + }, + decodeIterableShaped(factory), + ) + + def makeIterableInstance[F[_], A](toIterator: F[A] => Iterator[A], factory: Factory[A, F[A]])( + implicit CA: LDCodec[A] + ): LDCodec[F[A]] = + LDCodec.instance[F[A]]( + (fa, history) => { + val builder = LDValue.buildArray() + val failures = Vector.newBuilder[LDCodecFailure] + toIterator(fa).zipWithIndex.foreach { case (elem, index) => + LDCodec[A].encode(elem, history.at(index)) match { + case Validated.Valid(a) => builder.add(a) + case Validated.Invalid(e) => failures.addAll(e.iterator) + } + } + NonEmptyChain + .fromChain(Chain.fromSeq(failures.result())) + .toInvalid(builder.build()) + }, + decodeIterableShaped(factory), + ) + + implicit def iterableWithInfallibleEncodeInstance[A: LDCodecWithInfallibleEncode] + : LDCodecWithInfallibleEncode[Iterable[A]] = + makeIterableWithInfallibleEncodeInstance[Iterable, A](_.iterator, Iterable) + + implicit def iterableInstance[A: LDCodec]: LDCodec[Iterable[A]] = + makeIterableInstance[Iterable, A](_.iterator, Iterable) + + implicit def arrayWithInfallibleEncodeInstance[A: ClassTag: LDCodecWithInfallibleEncode] + : LDCodecWithInfallibleEncode[Array[A]] = + makeIterableWithInfallibleEncodeInstance[Array, A](_.iterator, Array) + + implicit def arrayInstance[A: ClassTag: LDCodec]: LDCodec[Array[A]] = + makeIterableInstance[Array, A](_.iterator, Array) + + implicit def vectorWithInfallibleEncodeInstance[A: LDCodecWithInfallibleEncode] + : LDCodecWithInfallibleEncode[Vector[A]] = + makeIterableWithInfallibleEncodeInstance[Vector, A](_.iterator, Vector) + + implicit def vectorInstance[A: LDCodec]: LDCodec[Vector[A]] = + makeIterableInstance[Vector, A](_.iterator, Vector) + + implicit def listWithInfallibleEncodeInstance[A: LDCodecWithInfallibleEncode] + : LDCodecWithInfallibleEncode[List[A]] = + makeIterableWithInfallibleEncodeInstance[List, A](_.iterator, List) + + implicit def listInstance[A: LDCodec]: LDCodec[List[A]] = + makeIterableInstance[List, A](_.iterator, List) + + implicit def chainInstance[A, C[_] <: LDCodec[?]](implicit + CVA: C[Vector[A]], + I: Invariant[C], + ): C[Chain[A]] = + CVA.imap[Chain[A]](Chain.fromSeq)(_.toVector) + + implicit def nonEmptyListInstance[A](implicit CLA: LDCodec[List[A]]): LDCodec[NonEmptyList[A]] = + CLA.imapVFull[NonEmptyList[A]]( + (nel, _) => nel.toList.valid, + (list, history) => + NonEmptyList.fromList(list).toValidNec { + LDCodecFailure(LDReason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def nonEmptyVectorInstance[A](implicit + CVA: LDCodec[Vector[A]] + ): LDCodec[NonEmptyVector[A]] = + CVA.imapVFull[NonEmptyVector[A]]( + (nev, _) => nev.toVector.valid, + (vec, history) => + NonEmptyVector.fromVector(vec).toValidNec { + LDCodecFailure(LDReason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def nonEmptyChainInstance[A](implicit + CCA: LDCodec[Chain[A]] + ): LDCodec[NonEmptyChain[A]] = + CCA.imapVFull[NonEmptyChain[A]]( + (nec, _) => nec.toChain.valid, + (chain, history) => + NonEmptyChain.fromChain(chain).toValidNec { + LDCodecFailure(LDReason.IndexOutOfBounds, history.at(0)) + }, + ) + + private def decodeObjectShaped[CC, K, V](factory: Factory[(K, V), CC])(cursor: LDCursor)(implicit + CK: LDKeyCodec[K], + CV: LDCodec[V], + ): LDCodecResult[CC] = cursor.checkType(LDValueType.OBJECT).andThen { c => + val builder = factory.newBuilder + val failures = Vector.newBuilder[LDCodecFailure] + builder.sizeHint(c.value.size()) + c.value.keys().forEach { field => + val updatedHistory = c.history.at(field) + LDKeyCodec[K].decode(field) match { + case Validated.Invalid(reasons) => + failures.addAll { + reasons.map { reason => + LDCodecFailure(unableToDecodeKey(reason), updatedHistory) + }.iterator + } + case Validated.Valid(key) => + LDCodec[V].decode( + LDCursor.of(LDValue.normalize(c.value.get(field)), updatedHistory) + ) match { + case Validated.Invalid(e) => failures.addAll(e.iterator) + case Validated.Valid(value) => builder.addOne(key -> value) + } + } + } + NonEmptyChain + .fromChain(Chain.fromSeq(failures.result())) + .toInvalid(builder.result()) + } + + def makeObjectShapedWithInfallibleEncodeInstance[CC, K, V]( + toIterator: CC => Iterator[(K, V)], + factory: Factory[(K, V), CC], + )(implicit + CK: LDKeyCodec.WithInfallibleEncode[K], + CV: LDCodecWithInfallibleEncode[V], + ): LDCodecWithInfallibleEncode[CC] = + LDCodecWithInfallibleEncode.instanceFull[CC]( + cc => { + val builder = LDValue.buildObject() + toIterator(cc).foreach { case (k, v) => + builder.put(CK.safeEncode(k), CV.safeEncode(v)) + } + builder.build() + }, + decodeObjectShaped(factory), + ) + + def makeObjectShapedInstance[CC, K, V]( + toIterator: CC => Iterator[(K, V)], + factory: Factory[(K, V), CC], + )(implicit + CK: LDKeyCodec[K], + CV: LDCodec[V], + ): LDCodec[CC] = + LDCodec.instance[CC]( + (cc, history) => { + val builder = LDValue.buildObject() + val failures = Vector.newBuilder[LDCodecFailure] + toIterator(cc).foreach { case (k, v) => + LDKeyCodec[K].encode(k) match { + case Validated.Invalid(reasons) => + failures.addAll { + reasons.map { reason => + LDCodecFailure(unableToEncodeKey(reason), history) + }.iterator + } + case Validated.Valid(key) => + CV.encode(v, history.at(key)) match { + case Validated.Invalid(e) => failures.addAll(e.iterator) + case Validated.Valid(value) => builder.put(key, value) + } + } + } + NonEmptyChain + .fromChain(Chain.fromSeq(failures.result())) + .toInvalid(builder.build()) + }, + decodeObjectShaped(factory), + ) + + implicit def mapWithInfallibleEncodeInstance[ + K: LDKeyCodec.WithInfallibleEncode, + V: LDCodecWithInfallibleEncode, + ]: LDCodecWithInfallibleEncode[Map[K, V]] = + makeObjectShapedWithInfallibleEncodeInstance[Map[K, V], K, V](_.iterator, Map) + + implicit def mapInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Map[K, V]] = + makeObjectShapedInstance[Map[K, V], K, V](_.iterator, Map) + + implicit def iterablePairsWithInfallibleEncodeInstance[ + K: LDKeyCodec.WithInfallibleEncode, + V: LDCodecWithInfallibleEncode, + ]: LDCodecWithInfallibleEncode[Iterable[(K, V)]] = + makeObjectShapedWithInfallibleEncodeInstance[Iterable[(K, V)], K, V](_.iterator, Iterable) + + implicit def iterablePairsInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Iterable[(K, V)]] = + makeObjectShapedInstance[Iterable[(K, V)], K, V](_.iterator, Iterable) + + implicit def arrayPairsWithInfallibleEncodeInstance[ + K: LDKeyCodec.WithInfallibleEncode, + V: LDCodecWithInfallibleEncode, + ](implicit ct: ClassTag[(K, V)]): LDCodecWithInfallibleEncode[Array[(K, V)]] = + makeObjectShapedWithInfallibleEncodeInstance[Array[(K, V)], K, V](_.iterator, Array) + + implicit def arrayPairsInstance[K: LDKeyCodec, V: LDCodec](implicit + ct: ClassTag[(K, V)] + ): LDCodec[Array[(K, V)]] = + makeObjectShapedInstance[Array[(K, V)], K, V](_.iterator, Array) + + implicit def vectorPairsWithInfallibleEncodeInstance[ + K: LDKeyCodec.WithInfallibleEncode, + V: LDCodecWithInfallibleEncode, + ]: LDCodecWithInfallibleEncode[Vector[(K, V)]] = + makeObjectShapedWithInfallibleEncodeInstance[Vector[(K, V)], K, V](_.iterator, Vector) + + implicit def vectorPairsInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Vector[(K, V)]] = + makeObjectShapedInstance[Vector[(K, V)], K, V](_.iterator, Vector) + + implicit def listPairsWithInfallibleEncodeInstance[ + K: LDKeyCodec.WithInfallibleEncode, + V: LDCodecWithInfallibleEncode, + ]: LDCodecWithInfallibleEncode[List[(K, V)]] = + makeObjectShapedWithInfallibleEncodeInstance[List[(K, V)], K, V](_.iterator, List) + + implicit def listPairsInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[List[(K, V)]] = + makeObjectShapedInstance[List[(K, V)], K, V](_.iterator, List) +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodecFailure.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodecFailure.scala new file mode 100644 index 0000000..f63cf69 --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodecFailure.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Show +import cats.data.{NonEmptyChain, ValidatedNec} +import cats.kernel.Hash +import cats.syntax.all.* + +/** Encodes a failure while decoding from an `LDValue` or encoding to an `LDValue` + */ +sealed trait LDCodecFailure { + + /** The reason for the decoding failure + */ + def reason: LDReason + + /** The path to the point where the failure occurred + */ + def history: LDCursorHistory + + def updateHistory(f: LDCursorHistory => LDCursorHistory): LDCodecFailure +} +object LDCodecFailure { + def apply(reason: LDReason, history: LDCursorHistory): LDCodecFailure = new Impl(reason, history) + + def apply(history: LDCursorHistory)( + reasons: NonEmptyChain[LDReason] + ): NonEmptyChain[LDCodecFailure] = + reasons.map(apply(_, history)) + + def failed[A](reason: LDReason, history: LDCursorHistory): ValidatedNec[LDCodecFailure, A] = + apply(reason, history).invalidNec + + implicit val hash: Hash[LDCodecFailure] = Hash.by(lcf => (lcf.reason, lcf.history)) + implicit val show: Show[LDCodecFailure] = Show.show { df => + df.reason.explain(df.history.show) + } + + private final class Impl(val reason: LDReason, val history: LDCursorHistory) + extends LDCodecFailure { + override def updateHistory(f: LDCursorHistory => LDCursorHistory): LDCodecFailure = + new Impl(reason, f(history)) + + override def toString: String = s"LDCodecFailure($reason, ${history.show})" + + override def equals(obj: Any): Boolean = obj match { + case that: LDCodecFailure => LDCodecFailure.hash.eqv(this, that) + case _ => false + } + + override def hashCode(): Int = LDCodecFailure.hash.hash(this) + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala new file mode 100644 index 0000000..f2ac053 --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Invariant +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDValue, LDValueType} +import org.typelevel.catapult.codec.LDCodec.{ + LDCodecResult, + withInfallibleEncode, + withInfallibleEncodeFull, +} + +trait LDCodecWithInfallibleEncode[A] extends LDCodec[A] { + + /** Encode a value to `LDValue` + */ + def safeEncode(a: A): LDValue + + override def encode(a: A, history: LDCursorHistory): LDCodecResult[LDValue] = + safeEncode(a).valid + + override def imapVFull[B]( + bToA: (B, LDCursorHistory) => LDCodecResult[A], + aToB: (A, LDCursorHistory) => LDCodecResult[B], + ): LDCodec[B] = + LDCodecWithInfallibleEncode.imap(this, bToA, aToB) +} +object LDCodecWithInfallibleEncode { + def apply[A](implicit C: LDCodecWithInfallibleEncode[A]): C.type = C + + def instance[A](_encode: A => LDValue, _decode: LDCursor => A): LDCodecWithInfallibleEncode[A] = + instanceFull(_encode, _decode(_).valid) + + def instanceFull[A]( + _encode: A => LDValue, + _decode: LDCursor => LDCodecResult[A], + ): LDCodecWithInfallibleEncode[A] = + new LDCodecWithInfallibleEncode[A] { + override def safeEncode(a: A): LDValue = _encode(a) + override def decode(c: LDCursor): LDCodecResult[A] = _decode(c) + } + + implicit val ldValueInstance: LDCodecWithInfallibleEncode[LDValue] = + withInfallibleEncode(identity, _.value) + + implicit val booleanInstance: LDCodecWithInfallibleEncode[Boolean] = withInfallibleEncodeFull( + LDValue.of, + _.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()), + ) + + implicit val stringInstance: LDCodecWithInfallibleEncode[String] = withInfallibleEncodeFull( + LDValue.of, + _.checkType(LDValueType.STRING).map(_.value.stringValue()), + ) + + // This is the canonical encoding of numbers in an LDValue, other + // numerical types are derived from this because of this constraint. + implicit val doubleInstance: LDCodecWithInfallibleEncode[Double] = withInfallibleEncodeFull( + LDValue.of, + _.checkType(LDValueType.NUMBER).map(_.value.doubleValue()), + ) + + implicit val invariant: Invariant[LDCodecWithInfallibleEncode] = + new Invariant[LDCodecWithInfallibleEncode] { + override def imap[A, B]( + codec: LDCodecWithInfallibleEncode[A] + )(aToB: A => B)(bToA: B => A): LDCodecWithInfallibleEncode[B] = + new LDCodecWithInfallibleEncode[B] { + override def safeEncode(b: B): LDValue = codec.safeEncode(bToA(b)) + + override def decode(c: LDCursor): LDCodecResult[B] = codec.decode(c).map(aToB) + } + } + + private def imap[A, B]( + codec: LDCodecWithInfallibleEncode[A], + bToA: (B, LDCursorHistory) => LDCodecResult[A], + aToB: (A, LDCursorHistory) => LDCodecResult[B], + ): LDCodec[B] = + new LDCodec[B] { + override def encode(b: B, history: LDCursorHistory): LDCodecResult[LDValue] = + bToA(b, history).map(codec.safeEncode) + + override def decode(c: LDCursor): LDCodecResult[B] = + codec.decode(c).andThen(aToB(_, c.history)) + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala new file mode 100644 index 0000000..8df7d4a --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala @@ -0,0 +1,195 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Show +import cats.data.ValidatedNec +import cats.kernel.Hash +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDValue, LDValueType} +import org.typelevel.catapult.codec.LDCursor.{LDArrayCursor, LDObjectCursor} +import org.typelevel.catapult.codec.LDReason.{IndexOutOfBounds, missingField, wrongType} +import org.typelevel.catapult.instances.catapultCatsInstancesForLDValue + +/** A lens that represents a position in an `LDValue` that supports one-way navigation + * and decoding using `LDCodec` instances. + */ +sealed trait LDCursor { + + /** The current value pointed to by the cursor. + * + * @note This is guaranteed to be non-null + */ + def value: LDValue + + def valueType: LDValueType = value.getType + + /** The path to the current value + */ + def history: LDCursorHistory + + def fail(reason: LDReason): LDCodecFailure = LDCodecFailure(reason, history) + + /** Attempt to decode the current value to an `A` + */ + def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] + + /** Ensure the type of `value` matches the expected `LDValueType` + * + * @see [[asArray]] if the expected type is `ARRAY` + */ + def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] + + /** Ensure the type of `value` is `ARRAY` and return an `LDCursor` + * specialized to working with `LDValue` arrays + */ + def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] + + /** Ensure the type of `value` is `OBJECT` and return an `LDCursor` + * specialized to working with `LDValue` objects + */ + def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] + + override def toString: String = LDCursor.show.show(this) + + override def hashCode(): Int = LDCursor.hash.hash(this) + + override def equals(obj: Any): Boolean = obj match { + case that: LDCursor => LDCursor.hash.eqv(this, that) + case _ => false + } +} + +object LDCursor { + def root(value: LDValue): LDCursor = new Impl(LDValue.normalize(value), LDCursorHistory.root) + + def of(value: LDValue, history: LDCursorHistory): LDCursor = + new Impl(LDValue.normalize(value), history) + + implicit val show: Show[LDCursor] = Show.show(c => show"LDCursor(${c.value}, ${c.history}") + implicit val hash: Hash[LDCursor] = Hash.by(c => (c.value, c.history)) + + /** An [[LDCursor]] that is specialized to work with `LDValue` arrays + */ + sealed trait LDArrayCursor extends LDCursor { + + /** Descend to the given index + * + * @note Bounds checking will be done on `index` + */ + def at(index: Int): ValidatedNec[LDCodecFailure, LDCursor] + + /** Attempt to decode the value at the given index as an `A` + * + * @note Bounds checking will be done on `index` + */ + def get[A: LDCodec](index: Int): ValidatedNec[LDCodecFailure, A] = + at(index).andThen(_.as[A]) + } + + /** An [[LDCursor]] that is specialized to work with `LDValue` objects + */ + sealed trait LDObjectCursor extends LDCursor { + + /** Descend to value at the given field + */ + def at(field: String): ValidatedNec[LDCodecFailure, LDCursor] + + /** Attempt to decode the value the given field as an `A` + */ + def get[A: LDCodec](field: String): ValidatedNec[LDCodecFailure, A] = + at(field).andThen(_.as[A]) + } + + private final class Impl(override val value: LDValue, override val history: LDCursorHistory) + extends LDCursor { + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(value) + + override def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] = + value.getType match { + case actual if actual != expected => + LDCodecFailure.failed(wrongType(expected, value.getType), history) + case LDValueType.ARRAY => new ArrayCursorImpl(value, history).valid + case LDValueType.OBJECT => new ObjectCursorImpl(value, history).valid + case _ => new Impl(value, history).valid + } + + override def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] = + if (value.getType == LDValueType.ARRAY) new ArrayCursorImpl(value, history).valid + else LDCodecFailure.failed(wrongType(LDValueType.ARRAY, value.getType), history) + + override def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] = + if (value.getType == LDValueType.OBJECT) new ObjectCursorImpl(value, history).valid + else LDCodecFailure.failed(wrongType(LDValueType.OBJECT, value.getType), history) + } + + private final class ArrayCursorImpl( + override val value: LDValue, + override val history: LDCursorHistory, + ) extends LDArrayCursor { + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(this) + + override def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] = + if (expected == LDValueType.ARRAY) this.valid + else LDCodecFailure.failed(wrongType(expected, value.getType), history) + + override def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] = + LDCodecFailure.failed(wrongType(LDValueType.OBJECT, value.getType), history) + + override def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] = this.valid + + override def at(index: Int): ValidatedNec[LDCodecFailure, LDCursor] = { + val updatedHistory = history.at(index) + if (index >= 0 && index < value.size()) + new Impl(LDValue.normalize(value.get(index)), updatedHistory).valid + else LDCodecFailure.failed(IndexOutOfBounds, updatedHistory) + } + } + + private final class ObjectCursorImpl( + override val value: LDValue, + override val history: LDCursorHistory, + ) extends LDObjectCursor { + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(this) + + override def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] = + if (expected == LDValueType.OBJECT) this.valid + else LDCodecFailure.failed(wrongType(expected, value.getType), history) + + override def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] = this.valid + + override def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] = + LDCodecFailure.failed(wrongType(LDValueType.ARRAY, value.getType), history) + + override def at(field: String): ValidatedNec[LDCodecFailure, LDCursor] = { + val updatedHistory = history.at(field) + val result = LDValue.normalize(value.get(field)) + if (!result.isNull) new Impl(result, updatedHistory).valid + else { + // LDValue.get returns null when a field is missing, we can do better + var found = false + value.keys().iterator().forEachRemaining { key => + if (key == field) { + found = true + } + } + if (found) new Impl(result, updatedHistory).valid + else LDCodecFailure.failed(missingField, updatedHistory) + } + } + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCursorHistory.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCursorHistory.scala new file mode 100644 index 0000000..46afd46 --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCursorHistory.scala @@ -0,0 +1,83 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Show +import cats.data.Chain +import cats.kernel.{Hash, Monoid} +import cats.syntax.all.* +import org.typelevel.catapult.codec.LDCursorHistory.Move +import org.typelevel.catapult.codec.LDCursorHistory.Move.{Field, Index} + +sealed trait LDCursorHistory { + def moves: Chain[Move] + def at(field: String): LDCursorHistory + def at(index: Int): LDCursorHistory + + override def hashCode(): Int = LDCursorHistory.hash.hash(this) + override def equals(obj: Any): Boolean = obj match { + case that: LDCursorHistory => LDCursorHistory.hash.eqv(this, that) + case _ => false + } +} +object LDCursorHistory { + val root: LDCursorHistory = new HistoryImpl(Chain.empty) + def of(moves: Chain[Move]): LDCursorHistory = new HistoryImpl(moves) + + implicit val show: Show[LDCursorHistory] = Show.fromToString + implicit val hash: Hash[LDCursorHistory] = Hash.by(_.moves) + implicit val monoid: Monoid[LDCursorHistory] = + Monoid.instance(root, (a, b) => new HistoryImpl(a.moves.concat(b.moves))) + + private final class HistoryImpl(val moves: Chain[Move]) extends LDCursorHistory { + override def at(field: String): LDCursorHistory = new HistoryImpl(moves.append(Move(field))) + + override def at(index: Int): LDCursorHistory = new HistoryImpl(moves.append(Move(index))) + + override def toString: String = moves.mkString_("$", "", "") + } + + sealed trait Move { + override def toString: String = this match { + case f: Field if f.name.forall(c => c.isLetterOrDigit || c == '_' || c == '-') => + s".${f.name}" + case f: Field => s"[${f.name}]" + case i: Index => s"[${i.index}]" + } + + override def hashCode(): Int = Move.hash.hash(this) + + override def equals(obj: Any): Boolean = obj match { + case that: Move => Move.hash.eqv(this, that) + case _ => false + } + } + object Move { + def apply(name: String): Move = new Field(name) + def apply(index: Int): Move = new Index(index) + + final class Field(val name: String) extends Move + + final class Index(val index: Int) extends Move + + implicit val show: Show[Move] = Show.fromToString + implicit val hash: Hash[Move] = Hash.by { + case field: Field => ("field", field.name, 0) + case index: Index => ("index", "", index.index) + } + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDKeyCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDKeyCodec.scala new file mode 100644 index 0000000..37b5d7c --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDKeyCodec.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Invariant +import cats.data.ValidatedNec +import cats.syntax.all.* + +/** A type class that provides a conversion from a `String` to used as an `LDValue` object key to and from a value of type `A` + */ +trait LDKeyCodec[A] { self => + + /** Encode a given `A` to a `String` to use as an `LDValue` object key + * + * @note If more than one `A` maps to the same `String` the behavior of the duplication + * is determined by the `LValue` implementation. + */ + def encode(a: A): ValidatedNec[LDReason, String] + + /** Attempt to decode a `String` from an `LValue` object key as an `A` + * + * @note If more than one `String` maps to the same `A`, the resulting value may have + * fewer entries than the original `LDValue` + */ + def decode(str: String): ValidatedNec[LDReason, A] + + /** Transform a `LDKeyCodec[A]` into an `LDKeyCodec[B]` by providing a transformation + * from `A` to `B` and one from `B` to `A` + */ + def imapV[B]( + bToA: B => ValidatedNec[LDReason, A], + aToB: A => ValidatedNec[LDReason, B], + ): LDKeyCodec[B] = + new LDKeyCodec[B] { + override def encode(b: B): ValidatedNec[LDReason, String] = bToA(b).andThen(self.encode) + + override def decode(str: String): ValidatedNec[LDReason, B] = + self.decode(str).andThen(aToB(_)) + } +} +object LDKeyCodec { + def apply[A](implicit KC: LDKeyCodec[A]): KC.type = KC + + implicit def promoteTheSubclass[A](implicit KC: WithInfallibleEncode[A]): LDKeyCodec[A] = KC + + implicit val invariant: Invariant[LDKeyCodec] = new Invariant[LDKeyCodec] { + override def imap[A, B](codec: LDKeyCodec[A])(aToB: A => B)(bToA: B => A): LDKeyCodec[B] = + new LDKeyCodec[B] { + override def encode(a: B): ValidatedNec[LDReason, String] = codec.encode(bToA(a)) + override def decode(str: String): ValidatedNec[LDReason, B] = codec.decode(str).map(aToB) + } + } + + trait WithInfallibleEncode[A] extends LDKeyCodec[A] { + def safeEncode(a: A): String + override def encode(a: A): ValidatedNec[LDReason, String] = safeEncode(a).valid + } + object WithInfallibleEncode { + implicit val invariant: Invariant[WithInfallibleEncode] = new Invariant[WithInfallibleEncode] { + override def imap[A, B]( + codec: WithInfallibleEncode[A] + )(aToB: A => B)(bToA: B => A): WithInfallibleEncode[B] = + new WithInfallibleEncode[B] { + override def safeEncode(b: B): String = codec.safeEncode(bToA(b)) + override def decode(str: String): ValidatedNec[LDReason, B] = codec.decode(str).map(aToB) + } + } + + implicit val stringInstance: WithInfallibleEncode[String] = new WithInfallibleEncode[String] { + override def safeEncode(a: String): String = a + override def decode(str: String): ValidatedNec[LDReason, String] = str.valid + } + } +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDReason.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDReason.scala new file mode 100644 index 0000000..d08f315 --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDReason.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.Show +import cats.kernel.Hash +import com.launchdarkly.sdk.LDValueType + +/** Explains an encoding or decoding failure + */ +sealed trait LDReason { + def explain: String + def explain(history: String): String + + override def hashCode(): Int = LDReason.hash.hash(this) + + override def equals(obj: Any): Boolean = obj match { + case that: LDReason => LDReason.hash.eqv(this, that) + case _ => false + } + + override def toString: String = explain +} +object LDReason { + val missingField: LDReason = new LDReason { + override val explain: String = "Missing expected field" + override def explain(history: String): String = s"Missing expected field at $history" + } + val IndexOutOfBounds: LDReason = new LDReason { + override val explain: String = "Out of bounds" + override def explain(history: String): String = s"$history is out of bounds" + } + + def wrongType(expected: LDValueType, actual: LDValueType): LDReason = + wrongType(expected.name(), actual) + def wrongType(expected: String, actual: LDValueType): LDReason = new LDReason { + override val explain: String = + s"Expected value of type $expected, but was of type ${actual.name()}" + override def explain(history: String): String = + s"Expected value of type $expected at $history, but was of type ${actual.name()}" + } + + def unableToDecodeKey(reason: LDReason): LDReason = new LDReason { + override val explain: String = s"Unable to decode key (${reason.explain})" + override def explain(history: String): String = + s"Unable to decode key at $history (${reason.explain})" + } + + def unableToEncodeKey(reason: LDReason): LDReason = new LDReason { + override val explain: String = s"Unable to encode value as key (${reason.explain})" + override def explain(history: String): String = + s"Unable to encode value as key inside $history (${reason.explain})" + } + + def other(reason: String): LDReason = new LDReason { + override val explain: String = reason + override def explain(history: String): String = s"Failure at $history: $reason" + } + + def unencodableValue(ldValueType: LDValueType, sourceType: String): LDReason = new LDReason { + override val explain: String = + s"Value of type $sourceType cannot be encoded as a LDValueType.${ldValueType.name}" + override def explain(history: String): String = + s"Value of type $sourceType at $history cannot be encoded as a LDValueType.${ldValueType.name}" + } + + def undecodableValue(ldValueType: LDValueType, expectedType: String): LDReason = new LDReason { + override val explain: String = + s"Value of type LDValueType.${ldValueType.name} cannot be represented as a $expectedType" + override def explain(history: String): String = + s"Value of type LDValueType.${ldValueType.name} at $history cannot be represented as a $expectedType" + } + + implicit val hash: Hash[LDReason] = Hash.by(_.explain) + implicit val show: Show[LDReason] = Show.fromToString +} diff --git a/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala b/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala new file mode 100644 index 0000000..35729fe --- /dev/null +++ b/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2022 Typelevel + * + * 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 org.typelevel.catapult.codec + +import cats.data.Validated +import com.launchdarkly.sdk.LDValue +import org.typelevel.catapult.codec.LDCodec.LDCodecResult + +object syntax { + implicit final class LDCursorEncodeOps[A](private val a: A) extends AnyVal { + def asLDValue(implicit CA: LDCodecWithInfallibleEncode[A]): LDValue = CA.safeEncode(a) + def asLDValueOrFailure(history: LDCursorHistory)(implicit + CA: LDCodec[A] + ): LDCodecResult[LDValue] = CA.encode(a, history) + } + + implicit final class LDValueDecodeOps(private val ldValue: LDValue) extends AnyVal { + def decode[A: LDCodec]: LDCodecResult[A] = LDCodec[A].decode(ldValue) + } + + implicit final class LDCodecResultOps[A](private val result: LDCodecResult[A]) extends AnyVal { + def asDecodingFailure: Validated[LDCodec.DecodingFailure, A] = + result.leftMap(new LDCodec.DecodingFailure(_)) + + def asEncodingFailure: Validated[LDCodec.EncodingFailure, A] = + result.leftMap(new LDCodec.EncodingFailure(_)) + } +} diff --git a/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala b/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala index b946cfa..ffd2218 100644 --- a/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala +++ b/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala @@ -23,10 +23,10 @@ import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent import com.launchdarkly.sdk.server.LDConfig import com.launchdarkly.sdk.LDContext import com.launchdarkly.sdk.LDValue -import fs2._ -import cats._ -import cats.implicits._ -import cats.mtl._ +import fs2.* +import cats.* +import cats.implicits.* +import cats.mtl.* trait LaunchDarklyMTLClient[F[_]] { @@ -77,6 +77,23 @@ trait LaunchDarklyMTLClient[F[_]] { defaultValue: LDValue, ): F[LDValue] + /** Retrieve the flag value, suspended in the `F` effect + * + * @param featureKey + * Defines the key, type, and default value + * @see + * [[FeatureKey]] + * @see + * [[boolVariation]] + * @see + * [[stringVariation]] + * @see + * [[doubleVariation]] + * @see + * [[jsonValueVariation]] + */ + def variation(featureKey: FeatureKey): F[featureKey.Type] + /** @param featureKey the key of the flag to be evaluated * @return A `Stream` of [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.html FlagValueChangeEvent]] instances representing changes to the value of the flag in the provided context. Note: if the flag value changes multiple times in quick succession, some intermediate values may be missed; for example, a change from 1` to `2` to `3` may be represented only as a change from `1` to `3` * @see [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/server/interfaces/FlagTracker.html FlagTracker]] @@ -134,6 +151,9 @@ object LaunchDarklyMTLClient { override def stringVariation(featureKey: String, defaultValue: String): F[String] = contextAsk.ask.flatMap(launchDarklyClient.stringVariation(featureKey, _, defaultValue)) + override def variation(featureKey: FeatureKey): F[featureKey.Type] = + contextAsk.ask.flatMap(launchDarklyClient.variation(featureKey, _)) + override def trackFlagValueChanges( featureKey: String ): fs2.Stream[F, com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent] = @@ -160,6 +180,9 @@ object LaunchDarklyMTLClient { override def trackFlagValueChanges(featureKey: String): Stream[F, FlagValueChangeEvent] = Stream.empty + override def variation(featureKey: FeatureKey): F[featureKey.Type] = + F.pure(featureKey.default) + override val flush: F[Unit] = F.unit } @@ -180,6 +203,9 @@ object LaunchDarklyMTLClient { override def jsonValueVariation(featureKey: String, defaultValue: LDValue): G[LDValue] = fk(ldc.jsonValueVariation(featureKey, defaultValue)) + override def variation(featureKey: FeatureKey): G[featureKey.Type] = + fk(ldc.variation(featureKey)) + override def trackFlagValueChanges(featureKey: String): Stream[G, FlagValueChangeEvent] = ldc.trackFlagValueChanges(featureKey).translate(fk)