From f3dcb862e3d4d7198b2bfc372b3bb58e8cc9869b Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 1 Apr 2025 16:19:15 -0700 Subject: [PATCH 01/21] Add FeatureKey This generalizes and polishes up a pattern that I've seen in our internal adoption of LaunchDarkly - define a key and it's default once, to make any updates easy to manage. It follows that the type of each key will be mostly static as well, so a way to define this once in the code is provided. --- .../org.typelevel/catapult/FeatureKey.scala | 131 ++++++++++++++++++ .../catapult/LaunchDarklyClient.scala | 27 +++- .../catapult/mtl/LaunchDarklyMTLClient.scala | 23 +++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 core/src/main/scala/org.typelevel/catapult/FeatureKey.scala 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..2a602ca --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/FeatureKey.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 + +import cats.Show +import cats.syntax.all.* +import com.launchdarkly.sdk.LDValue + +/** Defines a Launch Darkly key, it's expected type, and a default value + */ +sealed trait FeatureKey { + type Type + def key: String + def default: Type + + def variation[F[_], Ctx: ContextEncoder](client: LaunchDarklyClient[F], ctx: Ctx): F[Type] +} +object FeatureKey { + type Aux[T] = FeatureKey { + type Type = T + } + + /** 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] = + new Impl[Boolean](key, default, "Boolean") { + override def variation[F[_], Ctx: ContextEncoder]( + client: LaunchDarklyClient[F], + ctx: Ctx, + ): F[Boolean] = + client.boolVariation(key, ctx, 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] = + new Impl[String](key, default, "String") { + override def variation[F[_], Ctx: ContextEncoder]( + client: LaunchDarklyClient[F], + ctx: Ctx, + ): F[String] = + client.stringVariation(key, ctx, 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): FeatureKey.Aux[Int] = + new Impl[Int](key, default, "Int") { + override def variation[F[_], Ctx: ContextEncoder]( + client: LaunchDarklyClient[F], + ctx: Ctx, + ): F[Int] = + client.intVariation(key, ctx, 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] = + new Impl[Double](key, default, "Double") { + override def variation[F[_], Ctx: ContextEncoder]( + client: LaunchDarklyClient[F], + ctx: Ctx, + ): F[Double] = + client.doubleVariation(key, ctx, 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, "LDValue")(ldValueShow) { + override def variation[F[_], Ctx: ContextEncoder]( + client: LaunchDarklyClient[F], + ctx: Ctx, + ): F[LDValue] = + client.jsonValueVariation(key, ctx, default) + } + + private val ldValueShow: Show[LDValue] = Show.show(_.toJsonString) + private abstract class Impl[A: Show](_key: String, _default: A, typeName: String) + extends FeatureKey { + override type Type = A + override val key: String = _key + override val default: A = _default + + override def toString: String = s"FeatureKey($typeName, $key, ${default.show})" + + override def equals(obj: Any): Boolean = obj match { + case other: FeatureKey => key == other.key && default == other.default + case _ => false + } + + override def hashCode(): Int = ("FeatureKey", key.##, default.##).## + } +} diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index 36a4ff2..5aa297f 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -19,10 +19,10 @@ package org.typelevel.catapult import cats.effect.std.{Dispatcher, Queue} import cats.effect.{Async, Resource} import cats.~> +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 +94,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 + * [[LaunchDarklyClient.boolVariation()]] + * @see + * [[LaunchDarklyClient.stringVariation()]] + * @see + * [[LaunchDarklyClient.doubleVariation()]] + * @see + * [[LaunchDarklyClient.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. @@ -211,6 +228,12 @@ 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] = + featureKey.variation[F, Ctx](this, ctx) + override def flush: F[Unit] = unsafeWithJavaClient(_.flush()) override def mapK[G[_]](fk: F ~> G): LaunchDarklyClient[G] = new LaunchDarklyClient.Default[G] { 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 13c0123..47066a0 100644 --- a/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala +++ b/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala @@ -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 + * [[LaunchDarklyMTLClient.boolVariation()]] + * @see + * [[LaunchDarklyMTLClient.stringVariation()]] + * @see + * [[LaunchDarklyMTLClient.doubleVariation()]] + * @see + * [[LaunchDarklyMTLClient.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(featureKey.variation[F, LDContext](launchDarklyClient, _)) + override def trackFlagValueChanges( featureKey: String ): fs2.Stream[F, com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent] = @@ -157,6 +177,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) From 87c63a3ab4fb4fe5f0c45eb052a466b2b4fdfd0d Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 1 Apr 2025 16:22:21 -0700 Subject: [PATCH 02/21] Add a circe module This provides interop between LDValue and circe.Json, as well as helper methods to retrieve variations that are either JSON or encoded as JSON. --- build.sbt | 21 +++- .../catapult/circe/CirceFeatureKey.scala | 58 +++++++++ .../catapult/circe/LDValueCodec.scala | 116 ++++++++++++++++++ .../catapult/circe/syntax/client.scala | 62 ++++++++++ .../catapult/circe/syntax/mtlClient.scala | 56 +++++++++ .../catapult/circe/LDValueCodecTest.scala | 85 +++++++++++++ 6 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala create mode 100644 circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala create mode 100644 circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala create mode 100644 circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala create mode 100644 circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala diff --git a/build.sbt b/build.sbt index 04226db..c83d069 100644 --- a/build.sbt +++ b/build.sbt @@ -17,11 +17,11 @@ ThisBuild / tlSonatypeUseLegacyHost := false // publish website from this branch ThisBuild / tlSitePublishBranch := Some("main") -val Scala213 = "2.13.14" +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..aa78c23 --- /dev/null +++ b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala @@ -0,0 +1,58 @@ +/* + * 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 com.launchdarkly.sdk.LDValue +import io.circe.syntax.* +import io.circe.{Decoder, Encoder, Json} +import org.typelevel.catapult.FeatureKey +import org.typelevel.catapult.circe.LDValueCodec.* + +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, + ): Decoder.Result[FeatureKey.Aux[LDValue]] = + default.as[LDValue].map(FeatureKey.ldValue(key, _)) + + /** 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]( + key: String, + default: A, + ): Decoder.Result[FeatureKey.Aux[LDValue]] = + featureKey(key, default.asJson) +} diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala new file mode 100644 index 0000000..530d98a --- /dev/null +++ b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala @@ -0,0 +1,116 @@ +/* + * 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.json.{JsonSerialization, SerializationException} +import com.launchdarkly.sdk.{LDValue, LDValueType} +import io.circe.syntax.EncoderOps +import io.circe.{Decoder, DecodingFailure, Encoder, Json} + +object LDValueCodec { + + /** Decode a `circe` [[Json]] value as a `launchdarkly` [[LDValue]] + * + * The primary failure path is due to the encoding of JSON numbers as doubles + * in the [[LDValue]] type hierarchy + */ + implicit val catapultLDValueDecoder: Decoder[LDValue] = Decoder.instance { cursor => + cursor.focus.fold(LDValue.ofNull().asRight[DecodingFailure]) { circeValue => + circeValue.fold( + jsonNull = LDValue.ofNull().asRight, + jsonBoolean = LDValue.of(_).asRight, + jsonNumber = jNumber => + // 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. + Either + .catchOnly[SerializationException] { + LDValue.normalize( + JsonSerialization.deserialize( + Json.fromJsonNumber(jNumber).noSpaces, + classOf[LDValue], + ) + ) + } + .leftMap { _: SerializationException => + DecodingFailure("JSON value is not supported by LaunchDarkly LDValue", cursor.history) + }, + jsonString = LDValue.of(_).asRight, + jsonArray = _.traverse(catapultLDValueDecoder.decodeJson).map { values => + val builder = LDValue.buildArray() + values.foreach(builder.add) + builder.build() + }, + jsonObject = _.toVector + .traverse { case (key, value) => + catapultLDValueDecoder.decodeJson(value).tupleLeft(key) + } + .map { entries => + val builder = LDValue.buildObject() + entries.foreach { case (key, ldValue) => + builder.put(key, ldValue) + } + builder.build() + }, + ) + } + } + + implicit val catapultLDValueEncoder: Encoder[LDValue] = Encoder.instance { ldValue => + // So we don't have to deal with JVM nulls + val normalized = LDValue.normalize(ldValue) + normalized.getType match { + case LDValueType.NULL => Json.Null + case LDValueType.BOOLEAN => + Json.fromBoolean(normalized.booleanValue()) + case LDValueType.NUMBER => + // This is a bit of a hack because LDValue number support is lacking. + // + // LDValueNumber encodes everything as Double and that introduces encoding + // issues like negative/positive/unsigned zero and rounding when we try to do + // the conversion ourselves with the raw. + // + // Here, we're basically deferring to circe for the right thing to do with a + // Double by using the default Encoder[Double] (current behavior is to encode + // invalid values as `null`). + normalized.doubleValue().asJson + case LDValueType.STRING => + Json.fromString(normalized.stringValue()) + case LDValueType.ARRAY => + normalized.values().iterator() + val builder = Vector.newBuilder[LDValue] + normalized.values().forEach { ldValue => + builder.addOne(ldValue) + } + Json.fromValues(builder.result().map(catapultLDValueEncoder(_))) + case LDValueType.OBJECT => + val builder = Vector.newBuilder[(String, LDValue)] + normalized.keys().forEach { key => + builder.addOne(key -> normalized.get(key)) + } + Json.fromFields(builder.result().map { case (key, value) => + key -> catapultLDValueEncoder(value) + }) + } + } +} 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..96ce53f --- /dev/null +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala @@ -0,0 +1,62 @@ +/* + * 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 com.launchdarkly.sdk.LDValue +import io.circe.syntax.* +import io.circe.{Decoder, Json} +import org.typelevel.catapult.{ContextEncoder, FeatureKey, LaunchDarklyClient} +import org.typelevel.catapult.circe.LDValueCodec.* + +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 + .as[LDValue] + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, ctx, _)) + .map(_.asJson) + + def circeVariation[Ctx: ContextEncoder](featureKey: FeatureKey.Aux[LDValue], ctx: Ctx)(implicit + F: MonadThrow[F] + ): F[Json] = + client.variation(featureKey, ctx).map(_.asJson) + + def circeVariationAs[A: Decoder, Ctx: ContextEncoder]( + featureKey: String, + ctx: Ctx, + defaultValue: Json, + )(implicit F: MonadThrow[F]): F[A] = + defaultValue + .as[LDValue] + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, ctx, _)) + .flatMap(_.asJson.as[A].liftTo[F]) + + def circeVariationAs[A: Decoder, Ctx: ContextEncoder]( + featureKey: FeatureKey.Aux[LDValue], + ctx: Ctx, + )(implicit F: MonadThrow[F]): F[A] = + client.variation(featureKey, ctx).flatMap(_.asJson.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..fe59720 --- /dev/null +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala @@ -0,0 +1,56 @@ +/* + * 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 com.launchdarkly.sdk.LDValue +import io.circe.syntax.* +import io.circe.{Decoder, Json} +import org.typelevel.catapult.FeatureKey +import org.typelevel.catapult.circe.LDValueCodec.* +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 + .as[LDValue] + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, _)) + .map(_.asJson) + + def circeVariation(featureKey: FeatureKey.Aux[LDValue])(implicit F: MonadThrow[F]): F[Json] = + client.variation(featureKey).map(_.asJson) + + def circeVariationAs[A: Decoder](featureKey: String, defaultValue: Json)(implicit + F: MonadThrow[F] + ): F[A] = + defaultValue + .as[LDValue] + .liftTo[F] + .flatMap(client.jsonValueVariation(featureKey, _)) + .flatMap(_.asJson.as[A].liftTo[F]) + + def circeVariationAs[A: Decoder](featureKey: FeatureKey.Aux[LDValue])(implicit + F: MonadThrow[F] + ): F[A] = + client.variation(featureKey).flatMap(_.asJson.as[A].liftTo[F]) + } +} diff --git a/circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala b/circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala new file mode 100644 index 0000000..f7d8534 --- /dev/null +++ b/circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala @@ -0,0 +1,85 @@ +/* + * 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 io.circe.syntax._ +import munit.ScalaCheckSuite +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Prop.* +import org.scalacheck.{Arbitrary, Gen} +import org.typelevel.catapult.circe.LDValueCodec._ + +class LDValueCodecTest 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("LDValueSerde round trip")(forAll { (input: LDValue) => + input.asJson + .as[LDValue] + .map { output => + if (input == output) proved + else + falsified :| s"Expected ${input.toJsonString} but was ${output.toJsonString}" + } + .valueOr(fail("Conversion failed", _)) + }) +} From ee8541478764288c4ff9d88c284e2338cac83dbd Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 1 Apr 2025 16:23:38 -0700 Subject: [PATCH 03/21] Regenerate ci.yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee178fb..b275ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,11 +76,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') From 6eabb5db2bbcb3b5f6223ec0b66f519f4904519e Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 7 Apr 2025 12:10:41 -0700 Subject: [PATCH 04/21] Make Scala 3 happy --- .../catapult/circe/LDValueCodec.scala | 2 +- .../org.typelevel/catapult/FeatureKey.scala | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala index 530d98a..547e849 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala @@ -52,7 +52,7 @@ object LDValueCodec { ) ) } - .leftMap { _: SerializationException => + .leftMap { (_: SerializationException) => DecodingFailure("JSON value is not supported by LaunchDarkly LDValue", cursor.history) }, jsonString = LDValue.of(_).asRight, diff --git a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala index 2a602ca..6caf445 100644 --- a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala +++ b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala @@ -46,7 +46,7 @@ object FeatureKey { client: LaunchDarklyClient[F], ctx: Ctx, ): F[Boolean] = - client.boolVariation(key, ctx, default) + client.boolVariation(this.key, ctx, this.default) } /** Define a feature key that is expected to return a string value. @@ -61,7 +61,7 @@ object FeatureKey { client: LaunchDarklyClient[F], ctx: Ctx, ): F[String] = - client.stringVariation(key, ctx, default) + client.stringVariation(this.key, ctx, this.default) } /** Define a feature key that is expected to return a integer value. @@ -76,7 +76,7 @@ object FeatureKey { client: LaunchDarklyClient[F], ctx: Ctx, ): F[Int] = - client.intVariation(key, ctx, default) + client.intVariation(this.key, ctx, this.default) } /** Define a feature key that is expected to return a double value. @@ -91,7 +91,7 @@ object FeatureKey { client: LaunchDarklyClient[F], ctx: Ctx, ): F[Double] = - client.doubleVariation(key, ctx, default) + client.doubleVariation(this.key, ctx, this.default) } /** Define a feature key that is expected to return a JSON value. @@ -109,7 +109,7 @@ object FeatureKey { client: LaunchDarklyClient[F], ctx: Ctx, ): F[LDValue] = - client.jsonValueVariation(key, ctx, default) + client.jsonValueVariation(this.key, ctx, this.default) } private val ldValueShow: Show[LDValue] = Show.show(_.toJsonString) @@ -120,12 +120,5 @@ object FeatureKey { override val default: A = _default override def toString: String = s"FeatureKey($typeName, $key, ${default.show})" - - override def equals(obj: Any): Boolean = obj match { - case other: FeatureKey => key == other.key && default == other.default - case _ => false - } - - override def hashCode(): Int = ("FeatureKey", key.##, default.##).## } } From a45691d489e11b4e6359568aaaac02d9cffe563c Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 7 Apr 2025 13:04:54 -0700 Subject: [PATCH 05/21] Bump version because of new methods --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 73c5da6..4bab976 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" From 586124c6b3be956e7fc040cc7f49de7557a05dbf Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 7 Apr 2025 15:24:29 -0700 Subject: [PATCH 06/21] Make scaladoc happy --- .../catapult/circe/LDValueCodec.scala | 5 +++-- .../catapult/LaunchDarklyClient.scala | 8 ++++---- .../catapult/mtl/LaunchDarklyMTLClient.scala | 16 ++++++++-------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala index 547e849..59ba617 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala @@ -24,10 +24,11 @@ import io.circe.{Decoder, DecodingFailure, Encoder, Json} object LDValueCodec { - /** Decode a `circe` [[Json]] value as a `launchdarkly` [[LDValue]] + /** Decode a `circe` [[io.circe.Json]] value as a `com.launchdarkly.sdk.LDValue` * * The primary failure path is due to the encoding of JSON numbers as doubles - * in the [[LDValue]] type hierarchy + * in the `com.launchdarkly.sdk.LDValue` type hierarchy + * @see [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/LDValue.html LDValue]] */ implicit val catapultLDValueDecoder: Decoder[LDValue] = Decoder.instance { cursor => cursor.focus.fold(LDValue.ofNull().asRight[DecodingFailure]) { circeValue => diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index dff41c1..72a2851 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -101,13 +101,13 @@ trait LaunchDarklyClient[F[_]] { * @see * [[FeatureKey]] * @see - * [[LaunchDarklyClient.boolVariation()]] + * [[boolVariation()]] * @see - * [[LaunchDarklyClient.stringVariation()]] + * [[stringVariation()]] * @see - * [[LaunchDarklyClient.doubleVariation()]] + * [[doubleVariation()]] * @see - * [[LaunchDarklyClient.jsonValueVariation()]] + * [[jsonValueVariation()]] */ def variation[Ctx: ContextEncoder](featureKey: FeatureKey, ctx: Ctx): F[featureKey.Type] 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 4e3d0d4..737274b 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[_]] { @@ -84,13 +84,13 @@ trait LaunchDarklyMTLClient[F[_]] { * @see * [[FeatureKey]] * @see - * [[LaunchDarklyMTLClient.boolVariation()]] + * [[boolVariation]] * @see - * [[LaunchDarklyMTLClient.stringVariation()]] + * [[stringVariation]] * @see - * [[LaunchDarklyMTLClient.doubleVariation()]] + * [[doubleVariation]] * @see - * [[LaunchDarklyMTLClient.jsonValueVariation()]] + * [[jsonValueVariation]] */ def variation(featureKey: FeatureKey): F[featureKey.Type] From c211f899d1e351b8b746edb931fb5429da09d427 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Fri, 11 Apr 2025 09:35:36 -0700 Subject: [PATCH 07/21] Fix scaladoc --- .../scala/org.typelevel/catapult/LaunchDarklyClient.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index 72a2851..99b6442 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -101,13 +101,13 @@ trait LaunchDarklyClient[F[_]] { * @see * [[FeatureKey]] * @see - * [[boolVariation()]] + * [[boolVariation] * @see - * [[stringVariation()]] + * [[stringVariation] * @see - * [[doubleVariation()]] + * [[doubleVariation] * @see - * [[jsonValueVariation()]] + * [[jsonValueVariation] */ def variation[Ctx: ContextEncoder](featureKey: FeatureKey, ctx: Ctx): F[featureKey.Type] From 92791ea59c0429e140004d5257cccd0aec4079d1 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Fri, 11 Apr 2025 09:39:25 -0700 Subject: [PATCH 08/21] I think I dislike scaladoc --- .../scala/org.typelevel/catapult/LaunchDarklyClient.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index 99b6442..dc32a6e 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -101,13 +101,13 @@ trait LaunchDarklyClient[F[_]] { * @see * [[FeatureKey]] * @see - * [[boolVariation] + * [[boolVariation]] * @see - * [[stringVariation] + * [[stringVariation]] * @see - * [[doubleVariation] + * [[doubleVariation]] * @see - * [[jsonValueVariation] + * [[jsonValueVariation]] */ def variation[Ctx: ContextEncoder](featureKey: FeatureKey, ctx: Ctx): F[featureKey.Type] From 133c6d176b628bfe0a1ccbbd805f8e0dac8697c7 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 22 Apr 2025 10:42:38 -0700 Subject: [PATCH 09/21] Switch to to using codec for implementation --- .../org.typelevel/catapult/FeatureKey.scala | 72 ++--- .../org.typelevel/catapult/LDCodec.scala | 270 ++++++++++++++++++ .../org.typelevel/catapult/LDCursor.scala | 193 +++++++++++++ .../org.typelevel/catapult/LDKeyCodec.scala | 34 +++ .../catapult/LaunchDarklyClient.scala | 20 +- .../catapult/mtl/LaunchDarklyMTLClient.scala | 2 +- 6 files changed, 540 insertions(+), 51 deletions(-) create mode 100644 core/src/main/scala/org.typelevel/catapult/LDCodec.scala create mode 100644 core/src/main/scala/org.typelevel/catapult/LDCursor.scala create mode 100644 core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala diff --git a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala index 6caf445..1da7c80 100644 --- a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala +++ b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala @@ -22,32 +22,34 @@ import com.launchdarkly.sdk.LDValue /** Defines a Launch Darkly key, it's expected type, and a default value */ -sealed trait FeatureKey { +trait FeatureKey { type Type def key: String def default: Type - - def variation[F[_], Ctx: ContextEncoder](client: LaunchDarklyClient[F], ctx: Ctx): F[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: Show: LDCodec](key: String, default: A): FeatureKey.Aux[A] = + 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] = - new Impl[Boolean](key, default, "Boolean") { - override def variation[F[_], Ctx: ContextEncoder]( - client: LaunchDarklyClient[F], - ctx: Ctx, - ): F[Boolean] = - client.boolVariation(this.key, ctx, this.default) - } + 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 @@ -55,14 +57,7 @@ object FeatureKey { * @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] = - new Impl[String](key, default, "String") { - override def variation[F[_], Ctx: ContextEncoder]( - client: LaunchDarklyClient[F], - ctx: Ctx, - ): F[String] = - client.stringVariation(this.key, ctx, this.default) - } + 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 @@ -70,14 +65,7 @@ object FeatureKey { * @param default * a value to return if the retrieval fails or the type is not expected */ - def int(key: String, default: Int): FeatureKey.Aux[Int] = - new Impl[Int](key, default, "Int") { - override def variation[F[_], Ctx: ContextEncoder]( - client: LaunchDarklyClient[F], - ctx: Ctx, - ): F[Int] = - client.intVariation(this.key, ctx, this.default) - } + def int(key: String, default: Int): FeatureKey.Aux[Int] = instance[Int](key, default) /** Define a feature key that is expected to return a double value. * @param key @@ -85,14 +73,7 @@ object FeatureKey { * @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] = - new Impl[Double](key, default, "Double") { - override def variation[F[_], Ctx: ContextEncoder]( - client: LaunchDarklyClient[F], - ctx: Ctx, - ): F[Double] = - client.doubleVariation(this.key, ctx, this.default) - } + 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. * @@ -103,22 +84,19 @@ object FeatureKey { * @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, "LDValue")(ldValueShow) { - override def variation[F[_], Ctx: ContextEncoder]( - client: LaunchDarklyClient[F], - ctx: Ctx, - ): F[LDValue] = - client.jsonValueVariation(this.key, ctx, this.default) - } + def ldValue(key: String, default: LDValue): FeatureKey.Aux[LDValue] = { + implicit def ldValueShow: Show[LDValue] = FeatureKey.ldValueShow + new Impl[LDValue](key, default) + } private val ldValueShow: Show[LDValue] = Show.show(_.toJsonString) - private abstract class Impl[A: Show](_key: String, _default: A, typeName: String) - extends FeatureKey { + private final case class Impl[A: Show: LDCodec](_key: String, _default: A) extends FeatureKey { override type Type = A override val key: String = _key - override val default: A = _default + override val codec: LDCodec[A] = LDCodec[A] + override val ldValueDefault: LDValue = codec.encode(_default) + override def default: A = _default - override def toString: String = s"FeatureKey($typeName, $key, ${default.show})" + override def toString: String = s"FeatureKey($key, ${_default.show})" } } diff --git a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala new file mode 100644 index 0000000..b51d05e --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala @@ -0,0 +1,270 @@ +package org.typelevel.catapult + +import cats.Show +import cats.data.{Chain, NonEmptyChain, NonEmptyList, NonEmptyVector, Validated, ValidatedNec} +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDValue, LDValueType} +import org.typelevel.catapult.LDCodec.DecodingFailed +import org.typelevel.catapult.LDCodec.DecodingFailed.Reason +import org.typelevel.catapult.LDCursor.LDCursorHistory + +import scala.collection.Factory + +trait LDCodec[A] { self => + def encode(a: A): LDValue + def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] + + def decode(ld: LDValue): ValidatedNec[DecodingFailed, A] = decode(LDCursor.root(ld)) + + def imap[B](bToA: B => A, aToB: A => B): LDCodec[B] = new LDCodec[B] { + override def encode(a: B): LDValue = self.encode(bToA(a)) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = + self.decode(c).map(aToB) + } + + def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDCodec[B] = new LDCodec[B] { + override def encode(a: B): LDValue = self.encode(bToA(a)) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = + self.decode(c).andThen { a => + aToB(a).leftMap(_.map(DecodingFailed(_, c.history))) + } + } + + def imapVFull[B]( + bToA: B => A, + aToB: (A, LDCursorHistory) => ValidatedNec[DecodingFailed, B], + ): LDCodec[B] = new LDCodec[B] { + override def encode(a: B): LDValue = self.encode(bToA(a)) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = + self.decode(c).andThen(a => aToB(a, c.history)) + } +} +object LDCodec { + def apply[A](implicit LDC: LDCodec[A]): LDC.type = LDC + + def instance[A]( + _encode: A => LDValue, + _decode: LDCursor => ValidatedNec[DecodingFailed, A], + ): LDCodec[A] = + new LDCodec[A] { + override def encode(a: A): LDValue = _encode(a) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] = _decode(c) + } + + sealed trait DecodingFailed { + def reason: DecodingFailed.Reason + + def history: LDCursorHistory + } + + object DecodingFailed { + def apply(reason: Reason, history: LDCursorHistory): DecodingFailed = new Impl(reason, history) + + def failed[A](reason: Reason, history: LDCursorHistory): ValidatedNec[DecodingFailed, A] = + apply(reason, history).invalidNec + + sealed trait Reason { + def explain: String + def explain(history: String): String + } + object Reason { + case object MissingField extends Reason { + override def explain: String = "Missing expected field" + override def explain(history: String): String = s"Missing expected field at $history" + } + + case object IndexOutOfBounds extends Reason { + override def explain: String = "Out of bounds" + override def explain(history: String): String = s"$history is out of bounds" + } + + final case class WrongType(expected: LDValueType, actual: LDValueType) extends Reason { + override def explain: String = s"Expected ${expected.name()}, but was ${actual.name()}" + override def explain(history: String): String = + s"Expected ${expected.name()} at $history, but was ${actual.name()}" + } + + final case class UnableToDecodeKey(reason: Reason) extends Reason { + override def explain: String = s"Unable to decode key (${reason.explain}" + override def explain(history: String): String = + s"Unable to decode key at $history (${reason.explain})" + } + + final case class Other(reason: String) extends Reason { + override def explain: String = reason + override def explain(history: String): String = s"Failure at $history: $reason" + } + } + + implicit val show: Show[DecodingFailed] = Show.show { df => + df.reason.explain(df.history.show) + } + + private final class Impl(val reason: Reason, val history: LDCursorHistory) + extends DecodingFailed { + override def toString: String = s"DecodingFailed($reason, ${history.show})" + + override def equals(obj: Any): Boolean = obj match { + case that: DecodingFailed => this.reason == that.reason && this.history == that.history + case _ => false + } + + override def hashCode(): Int = ("DecodingFailure", reason, history).## + } + } + + implicit val ldValueInstance: LDCodec[LDValue] = new LDCodec[LDValue] { + override def encode(a: LDValue): LDValue = a + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, LDValue] = c.value.valid + } + + implicit val booleanInstance: LDCodec[Boolean] = new LDCodec[Boolean] { + override def encode(a: Boolean): LDValue = LDValue.of(a) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Boolean] = + c.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()) + } + + implicit val stringInstance: LDCodec[String] = new LDCodec[String] { + override def encode(a: String): LDValue = LDValue.of(a) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, String] = + c.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: LDCodec[Double] = new LDCodec[Double] { + override def encode(a: Double): LDValue = LDValue.of(a) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Double] = + c.checkType(LDValueType.NUMBER).andThen { numCur => + val d = numCur.value.doubleValue() + Validated.condNec( + !d.isNaN, + d, + DecodingFailed(Reason.Other("Value is not a valid double"), numCur.history), + ) + } + } + + implicit val intInstance: LDCodec[Int] = LDCodec[Double].imapV[Int]( + _.toDouble, + d => Validated.condNec(d.isValidInt, d.toInt, Reason.Other("Value is not a valid int")), + ) + + implicit val longInstance: LDCodec[Long] = LDCodec[Double].imapV[Long]( + _.toDouble, + d => { + val asLong = d.toLong + val dByWayOfL = asLong.toDouble + Validated.condNec( + // Borrowed from commented out code in RichDouble + dByWayOfL == dByWayOfL && asLong != Long.MaxValue, + asLong, + Reason.Other("Value is not a valid long"), + ) + }, + ) + + implicit val noneInstance: LDCodec[None.type] = + new LDCodec[None.type] { + override def encode(a: None.type): LDValue = LDValue.ofNull() + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, None.type] = + c.checkType(LDValueType.NULL).as(None) + } + + implicit def decodeSome[A: LDCodec]: LDCodec[Some[A]] = + LDCodec[A].imap[Some[A]](_.value, Some(_)) + + implicit def decodeOption[A: LDCodec]: LDCodec[Option[A]] = new LDCodec[Option[A]] { + override def encode(a: Option[A]): LDValue = + a.fold(LDValue.ofNull())(LDCodec[A].encode) + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Option[A]] = + noneInstance.decode(c).findValid(LDCodec[A].decode(c).map(_.some)) + } + + def ldArrayInstance[F[_], A: LDCodec]( + toIterator: F[A] => Iterator[A], + factory: Factory[A, F[A]], + ): LDCodec[F[A]] = + new LDCodec[F[A]] { + override def encode(fa: F[A]): LDValue = { + val builder = LDValue.buildArray() + toIterator(fa).foreach { elem => + builder.add(LDCodec[A].encode(elem)) + } + builder.build() + } + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, F[A]] = + c.asArray + .andThen(_.elements[A]) + .map(factory.fromSpecific(_)) + } + + implicit def vectorInstance[A: LDCodec]: LDCodec[Vector[A]] = + ldArrayInstance[Vector, A](_.iterator, Vector) + + implicit def listInstance[A: LDCodec]: LDCodec[List[A]] = + ldArrayInstance[List, A](_.iterator, List) + + implicit def chainInstance[A: LDCodec]: LDCodec[Chain[A]] = + vectorInstance[A].imap[Chain[A]](_.toVector, Chain.fromSeq) + + implicit def nonEmptyListInstance[A: LDCodec]: LDCodec[NonEmptyList[A]] = + listInstance[A].imapVFull[NonEmptyList[A]]( + _.toList, + (list, history) => + NonEmptyList.fromList(list).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def nonEmptyVectorInstance[A: LDCodec]: LDCodec[NonEmptyVector[A]] = + vectorInstance[A].imapVFull[NonEmptyVector[A]]( + _.toVector, + (vec, history) => + NonEmptyVector.fromVector(vec).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def nonEmptyChainInstance[A: LDCodec]: LDCodec[NonEmptyChain[A]] = + chainInstance[A].imapVFull[NonEmptyChain[A]]( + _.toChain, + (chain, history) => + NonEmptyChain.fromChain(chain).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) + + def ldObjectInstance[CC, K: LDKeyCodec, V: LDCodec]( + toIterator: CC => Iterator[(K, V)], + factory: Factory[(K, V), CC], + ): LDCodec[CC] = + new LDCodec[CC] { + override def encode(cc: CC): LDValue = { + val builder = LDValue.buildObject() + toIterator(cc).foreach { case (k, v) => + builder.put(LDKeyCodec[K].encode(k), LDCodec[V].encode(v)) + } + builder.build() + } + + override def decode(c: LDCursor): ValidatedNec[DecodingFailed, CC] = + c.asObject + .andThen(_.entries[K, V]) + .map(factory.fromSpecific(_)) + } + + implicit def mapInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Map[K, V]] = + ldObjectInstance[Map[K, V], K, V](_.iterator, Map) +} diff --git a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala new file mode 100644 index 0000000..35a61f9 --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala @@ -0,0 +1,193 @@ +package org.typelevel.catapult + +import cats.Show +import cats.data.{Chain, ValidatedNec} +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDValue, LDValueType} +import org.typelevel.catapult.LDCodec.DecodingFailed +import org.typelevel.catapult.LDCodec.DecodingFailed.Reason.{ + IndexOutOfBounds, + MissingField, + WrongType, +} +import org.typelevel.catapult.LDCursor.LDCursorHistory.Move +import org.typelevel.catapult.LDCursor.{LDArrayCursor, LDCursorHistory, LDObjectCursor} + +sealed trait LDCursor { + def value: LDValue + + def history: LDCursorHistory + + def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] + + def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] + + def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] + + def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] +} + +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) + + sealed trait LDArrayCursor extends LDCursor { + def at(index: Int): ValidatedNec[DecodingFailed, LDCursor] + + def get[A: LDCodec](index: Int): ValidatedNec[DecodingFailed, A] = + at(index).andThen(_.as[A]) + + def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[A]] + } + + sealed trait LDObjectCursor extends LDCursor { + def at(field: String): ValidatedNec[DecodingFailed, LDCursor] + + def get[A: LDCodec](field: String): ValidatedNec[DecodingFailed, A] = + at(field).andThen(_.as[A]) + + def entries[K: LDKeyCodec, V: LDCodec]: ValidatedNec[DecodingFailed, Vector[(K, V)]] + } + + sealed trait LDCursorHistory { + def moves: Chain[Move] + def at(field: String): LDCursorHistory + def at(index: Int): LDCursorHistory + } + object LDCursorHistory { + def root: LDCursorHistory = HistoryImpl(Chain.empty) + def of(moves: Chain[Move]): LDCursorHistory = HistoryImpl(moves) + + private final case class HistoryImpl(moves: Chain[Move]) extends LDCursorHistory { + override def at(field: String): LDCursorHistory = HistoryImpl(moves.append(Move.Field(field))) + + override def at(index: Int): LDCursorHistory = HistoryImpl(moves.append(Move.Index(index))) + } + + sealed trait Move + + object Move { + final case class Field(name: String) extends Move + + final case class Index(index: Int) extends Move + + implicit val show: Show[Move] = Show.show { + case Field(name) if name.forall(c => c.isLetterOrDigit || c == '_' || c == '-') => s".$name" + case Field(name) => s"[$name]" + case Index(index) => s"[$index]" + } + } + + implicit val show: Show[LDCursorHistory] = Show.show(_.moves.mkString_("$", "", "")) + } + + private final class Impl(override val value: LDValue, override val history: LDCursorHistory) + extends LDCursor { + override def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] = LDCodec[A].decode(value) + + override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = + value.getType match { + case actual if actual != expected => + DecodingFailed.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[DecodingFailed, LDArrayCursor] = + if (value.getType == LDValueType.ARRAY) new ArrayCursorImpl(value, history).valid + else DecodingFailed.failed(WrongType(LDValueType.ARRAY, value.getType), history) + + override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = + if (value.getType == LDValueType.OBJECT) new ObjectCursorImpl(value, history).valid + else DecodingFailed.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[DecodingFailed, A] = LDCodec[A].decode(this) + + override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = + if (expected == LDValueType.ARRAY) this.valid + else DecodingFailed.failed(WrongType(expected, value.getType), history) + + override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = + DecodingFailed.failed(WrongType(LDValueType.OBJECT, value.getType), history) + + override def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] = this.valid + + override def at(index: Int): ValidatedNec[DecodingFailed, LDCursor] = { + val updatedHistory = history.at(index) + if (index >= 0 && index < value.size()) + new Impl(LDValue.normalize(value.get(index)), updatedHistory).valid + else DecodingFailed.failed(IndexOutOfBounds, updatedHistory) + } + + override def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[A]] = { + val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, A]] + var idx = 0 + value.values().forEach { ldValue => + builder.addOne( + LDCodec[A].decode( + LDCursor.of( + LDValue.normalize(ldValue), + history.at(idx), + ) + ) + ) + idx = idx + 1 + } + builder.result().sequence + } + } + + private final class ObjectCursorImpl( + override val value: LDValue, + override val history: LDCursorHistory, + ) extends LDObjectCursor { + override def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] = LDCodec[A].decode(this) + + override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = + if (expected == LDValueType.OBJECT) this.valid + else DecodingFailed.failed(WrongType(expected, value.getType), history) + + override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = this.valid + + override def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] = + DecodingFailed.failed(WrongType(LDValueType.ARRAY, value.getType), history) + + override def at(field: String): ValidatedNec[DecodingFailed, 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 DecodingFailed.failed(MissingField, updatedHistory) + } + } + + override def entries[K: LDKeyCodec, V: LDCodec] + : ValidatedNec[DecodingFailed, Vector[(K, V)]] = { + val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, (K, V)]] + value.keys().forEach { field => + val updatedHistory = history.at(field) + val decodedEntry = ( + LDKeyCodec[K].decode(field).leftMap(_.map(DecodingFailed(_, updatedHistory))), + LDCodec[V].decode(LDCursor.of(LDValue.normalize(value.get(field)), updatedHistory)), + ).tupled + builder.addOne(decodedEntry) + } + builder.result().sequence + } + } +} diff --git a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala new file mode 100644 index 0000000..1016a52 --- /dev/null +++ b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala @@ -0,0 +1,34 @@ +package org.typelevel.catapult + +import cats.data.ValidatedNec +import cats.syntax.all.* +import org.typelevel.catapult.LDCodec.DecodingFailed.Reason + +trait LDKeyCodec[A] { self => + def encode(a: A): String + def decode(str: String): ValidatedNec[Reason, A] + + def imap[B](bToA: B => A, aToB: A => B): LDKeyCodec[B] = new LDKeyCodec[B] { + override def encode(a: B): String = self.encode(bToA(a)) + + override def decode(str: String): ValidatedNec[Reason, B] = + self.decode(str).map(aToB) + } + + def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDKeyCodec[B] = + new LDKeyCodec[B] { + override def encode(a: B): String = self.encode(bToA(a)) + + override def decode(str: String): ValidatedNec[Reason, B] = + self.decode(str).andThen(aToB(_)) + } +} +object LDKeyCodec { + def apply[A](implicit KC: LDKeyCodec[A]): KC.type = KC + + implicit val stringInstance: LDKeyCodec[String] = new LDKeyCodec[String] { + override def encode(a: String): String = a + + override def decode(str: String): ValidatedNec[Reason, String] = str.valid + } +} diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index dc32a6e..fb269ae 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -16,10 +16,12 @@ package org.typelevel.catapult +import cats.data.Validated import cats.effect.std.{Dispatcher, Queue} import cats.effect.{Async, Resource} +import cats.syntax.all.* +import cats.{Applicative, ~>} import com.launchdarkly.sdk.LDValue -import cats.{~>, Applicative} import com.launchdarkly.sdk.server.interfaces.{FlagValueChangeEvent, FlagValueChangeListener} import com.launchdarkly.sdk.server.{LDClient, LDConfig} import fs2.* @@ -160,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)) @@ -193,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]( @@ -232,7 +235,18 @@ object LaunchDarklyClient { featureKey: FeatureKey, ctx: Ctx, ): F[featureKey.Type] = - featureKey.variation[F, Ctx](this, ctx) + 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"), Nil *) + } + }.flatMap(_.as(featureKey.default)) + } + } override def flush: F[Unit] = unsafeWithJavaClient(_.flush()) 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 737274b..ffd2218 100644 --- a/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala +++ b/mtl/src/main/scala/org.typelevel/catapult/mtl/LaunchDarklyMTLClient.scala @@ -152,7 +152,7 @@ object LaunchDarklyMTLClient { contextAsk.ask.flatMap(launchDarklyClient.stringVariation(featureKey, _, defaultValue)) override def variation(featureKey: FeatureKey): F[featureKey.Type] = - contextAsk.ask.flatMap(featureKey.variation[F, LDContext](launchDarklyClient, _)) + contextAsk.ask.flatMap(launchDarklyClient.variation(featureKey, _)) override def trackFlagValueChanges( featureKey: String From 2f5eb79f2e8a511c9d792e7480dad85cb071a1aa Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 22 Apr 2025 11:45:54 -0700 Subject: [PATCH 10/21] Add headers --- .../scala/org.typelevel/catapult/LDCodec.scala | 16 ++++++++++++++++ .../scala/org.typelevel/catapult/LDCursor.scala | 16 ++++++++++++++++ .../org.typelevel/catapult/LDKeyCodec.scala | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala index b51d05e..cbd1aee 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala index 35a61f9..b9f0912 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala index 1016a52..ac153b4 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala @@ -1,3 +1,19 @@ +/* + * 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.data.ValidatedNec From 4f70db2abb684b16cfdfed99809b9a9cf41e561e Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Tue, 22 Apr 2025 11:59:49 -0700 Subject: [PATCH 11/21] Work around Java interop issue --- .../main/scala/org.typelevel/catapult/LaunchDarklyClient.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala index fb269ae..dea1a71 100644 --- a/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala +++ b/core/src/main/scala/org.typelevel/catapult/LaunchDarklyClient.scala @@ -242,7 +242,7 @@ object LaunchDarklyClient { unsafeWithJavaClient { client => val logger = client.getLogger async.blocking { - logger.error(errors.mkString_("Unable to decode LDValue\n", "\n", "\n"), Nil *) + logger.error("{}", errors.mkString_("Unable to decode LDValue\n", "\n", "\n")) } }.flatMap(_.as(featureKey.default)) } From 450aa6608d4aed21db9cdabda05913036e72be6e Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Wed, 23 Apr 2025 11:52:27 -0700 Subject: [PATCH 12/21] Add documentation to LDCodec and friends --- .../org.typelevel/catapult/LDCodec.scala | 103 +++++++++++++++--- .../org.typelevel/catapult/LDCursor.scala | 56 +++++++++- .../org.typelevel/catapult/LDKeyCodec.scala | 22 ++++ 3 files changed, 167 insertions(+), 14 deletions(-) diff --git a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala index cbd1aee..e264b1e 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala @@ -17,7 +17,7 @@ package org.typelevel.catapult import cats.Show -import cats.data.{Chain, NonEmptyChain, NonEmptyList, NonEmptyVector, Validated, ValidatedNec} +import cats.data.* import cats.syntax.all.* import com.launchdarkly.sdk.{LDValue, LDValueType} import org.typelevel.catapult.LDCodec.DecodingFailed @@ -25,13 +25,29 @@ import org.typelevel.catapult.LDCodec.DecodingFailed.Reason import org.typelevel.catapult.LDCursor.LDCursorHistory 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] { self => + + /** Encode a value to `LDValue` + */ def encode(a: A): LDValue + + /** Decode the given `LDCursor` + */ def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] + /** Decode the given `LDValue` + */ def decode(ld: LDValue): ValidatedNec[DecodingFailed, A] = decode(LDCursor.root(ld)) + /** Transform a `LDCodec[A]` into an `LDCodec[B]` by providing a transformation + * from `A` to `B` and one from `B` to `A` + * + * @see [[imapV]] or [[imapVFull]] if the transformations can fail + */ def imap[B](bToA: B => A, aToB: A => B): LDCodec[B] = new LDCodec[B] { override def encode(a: B): LDValue = self.encode(bToA(a)) @@ -39,6 +55,11 @@ trait LDCodec[A] { self => self.decode(c).map(aToB) } + /** A variant of [[imap]] which allows the transformation from `A` to `B` + * to fail + * + * @see [[imapVFull]] if the failure can affect the history + */ def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDCodec[B] = new LDCodec[B] { override def encode(a: B): LDValue = self.encode(bToA(a)) @@ -48,6 +69,9 @@ trait LDCodec[A] { self => } } + /** A variant of [[imap]] which allows the transformation from `A` to `B` + * to fail with an updated history + */ def imapVFull[B]( bToA: B => A, aToB: (A, LDCursorHistory) => ValidatedNec[DecodingFailed, B], @@ -71,12 +95,18 @@ object LDCodec { override def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] = _decode(c) } + /** Encodes a failure while decoding from an `LDValue` + */ sealed trait DecodingFailed { + + /** The reason for the decoding failure + */ def reason: DecodingFailed.Reason + /** The path to the point where the failure occurred + */ def history: LDCursorHistory } - object DecodingFailed { def apply(reason: Reason, history: LDCursorHistory): DecodingFailed = new Impl(reason, history) @@ -226,17 +256,20 @@ object LDCodec { .map(factory.fromSpecific(_)) } - implicit def vectorInstance[A: LDCodec]: LDCodec[Vector[A]] = + implicit def ldArrayToArrayInstance[A: LDCodec: ClassTag]: LDCodec[Array[A]] = + ldArrayInstance[Array, A](_.iterator, Array) + + implicit def ldArrayToVectorInstance[A: LDCodec]: LDCodec[Vector[A]] = ldArrayInstance[Vector, A](_.iterator, Vector) - implicit def listInstance[A: LDCodec]: LDCodec[List[A]] = + implicit def ldArrayToListInstance[A: LDCodec]: LDCodec[List[A]] = ldArrayInstance[List, A](_.iterator, List) - implicit def chainInstance[A: LDCodec]: LDCodec[Chain[A]] = - vectorInstance[A].imap[Chain[A]](_.toVector, Chain.fromSeq) + implicit def ldArrayToChainInstance[A: LDCodec]: LDCodec[Chain[A]] = + ldArrayToVectorInstance[A].imap[Chain[A]](_.toVector, Chain.fromSeq) - implicit def nonEmptyListInstance[A: LDCodec]: LDCodec[NonEmptyList[A]] = - listInstance[A].imapVFull[NonEmptyList[A]]( + implicit def ldArrayToNonEmptyListInstance[A: LDCodec]: LDCodec[NonEmptyList[A]] = + ldArrayToListInstance[A].imapVFull[NonEmptyList[A]]( _.toList, (list, history) => NonEmptyList.fromList(list).toValidNec { @@ -244,8 +277,8 @@ object LDCodec { }, ) - implicit def nonEmptyVectorInstance[A: LDCodec]: LDCodec[NonEmptyVector[A]] = - vectorInstance[A].imapVFull[NonEmptyVector[A]]( + implicit def ldArrayToNonEmptyVectorInstance[A: LDCodec]: LDCodec[NonEmptyVector[A]] = + ldArrayToVectorInstance[A].imapVFull[NonEmptyVector[A]]( _.toVector, (vec, history) => NonEmptyVector.fromVector(vec).toValidNec { @@ -253,8 +286,8 @@ object LDCodec { }, ) - implicit def nonEmptyChainInstance[A: LDCodec]: LDCodec[NonEmptyChain[A]] = - chainInstance[A].imapVFull[NonEmptyChain[A]]( + implicit def ldArrayToNonEmptyChainInstance[A: LDCodec]: LDCodec[NonEmptyChain[A]] = + ldArrayToChainInstance[A].imapVFull[NonEmptyChain[A]]( _.toChain, (chain, history) => NonEmptyChain.fromChain(chain).toValidNec { @@ -281,6 +314,50 @@ object LDCodec { .map(factory.fromSpecific(_)) } - implicit def mapInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Map[K, V]] = + implicit def ldObjectToPairsInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Map[K, V]] = ldObjectInstance[Map[K, V], K, V](_.iterator, Map) + + implicit def ldObjectToArrayInstance[K: LDKeyCodec, V: LDCodec](implicit + ct: ClassTag[(K, V)] + ): LDCodec[Array[(K, V)]] = + ldObjectInstance[Array[(K, V)], K, V](_.iterator, Array) + + implicit def ldObjectToVectorInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Vector[(K, V)]] = + ldObjectInstance[Vector[(K, V)], K, V](_.iterator, Vector) + + implicit def ldObjectToListInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[List[(K, V)]] = + ldObjectInstance[List[(K, V)], K, V](_.iterator, List) + + implicit def ldObjectToChainInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Chain[(K, V)]] = + ldObjectToVectorInstance[K, V].imap[Chain[(K, V)]](_.toVector, Chain.fromSeq) + + implicit def ldObjectToNonEmptyListInstance[K: LDKeyCodec, V: LDCodec] + : LDCodec[NonEmptyList[(K, V)]] = + ldObjectToListInstance[K, V].imapVFull[NonEmptyList[(K, V)]]( + _.toList, + (list, history) => + NonEmptyList.fromList(list).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def ldObjectToNonEmptyVectorInstance[K: LDKeyCodec, V: LDCodec] + : LDCodec[NonEmptyVector[(K, V)]] = + ldObjectToVectorInstance[K, V].imapVFull[NonEmptyVector[(K, V)]]( + _.toVector, + (vec, history) => + NonEmptyVector.fromVector(vec).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) + + implicit def ldObjectToNonEmptyChainInstance[K: LDKeyCodec, V: LDCodec] + : LDCodec[NonEmptyChain[(K, V)]] = + ldObjectToChainInstance[K, V].imapVFull[NonEmptyChain[(K, V)]]( + _.toChain, + (chain, history) => + NonEmptyChain.fromChain(chain).toValidNec { + DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) + }, + ) } diff --git a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala index b9f0912..76ac92c 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala @@ -24,45 +24,93 @@ import org.typelevel.catapult.LDCodec.DecodingFailed import org.typelevel.catapult.LDCodec.DecodingFailed.Reason.{ IndexOutOfBounds, MissingField, + UnableToDecodeKey, WrongType, } import org.typelevel.catapult.LDCursor.LDCursorHistory.Move import org.typelevel.catapult.LDCursor.{LDArrayCursor, LDCursorHistory, LDObjectCursor} +/** 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 + /** The path to the current value + */ def history: LDCursorHistory + /** Attempt to decode the current value to an `A` + */ def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] + /** Ensure the type of `value` matches the expected `LDValueType` + * + * @see [[asArray]] if the expected type is `ARRAY` + */ def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] + /** Ensure the type of `value` is `ARRAY` and return an `LDCursor` + * specialized to working with `LDValue` arrays + */ def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] + /** Ensure the type of `value` is `OBJECT` and return an `LDCursor` + * specialized to working with `LDValue` objects + */ def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] } 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) + /** 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[DecodingFailed, 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[DecodingFailed, A] = at(index).andThen(_.as[A]) + /** Attempt to decode all the entries as `A`s + * + * @note This will generally be more efficient than repeated calls to [[get]] + */ def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[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[DecodingFailed, LDCursor] + /** Attempt to decode the value the given field as an `A` + */ def get[A: LDCodec](field: String): ValidatedNec[DecodingFailed, A] = at(field).andThen(_.as[A]) + /** Attempt to decode the entire object as a vector of `(K, V)` entries + */ def entries[K: LDKeyCodec, V: LDCodec]: ValidatedNec[DecodingFailed, Vector[(K, V)]] } @@ -144,6 +192,7 @@ object LDCursor { override def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[A]] = { val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, A]] + builder.sizeHint(value.size()) var idx = 0 value.values().forEach { ldValue => builder.addOne( @@ -195,10 +244,15 @@ object LDCursor { override def entries[K: LDKeyCodec, V: LDCodec] : ValidatedNec[DecodingFailed, Vector[(K, V)]] = { val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, (K, V)]] + builder.sizeHint(value.size()) value.keys().forEach { field => val updatedHistory = history.at(field) val decodedEntry = ( - LDKeyCodec[K].decode(field).leftMap(_.map(DecodingFailed(_, updatedHistory))), + LDKeyCodec[K] + .decode(field) + .leftMap(_.map { reason => + DecodingFailed(UnableToDecodeKey(reason), updatedHistory) + }), LDCodec[V].decode(LDCursor.of(LDValue.normalize(value.get(field)), updatedHistory)), ).tupled builder.addOne(decodedEntry) diff --git a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala index ac153b4..ad0ec27 100644 --- a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala +++ b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala @@ -20,10 +20,29 @@ import cats.data.ValidatedNec import cats.syntax.all.* import org.typelevel.catapult.LDCodec.DecodingFailed.Reason +/** 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): 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[Reason, A] + /** Transform a `LDKeyCodec[A]` into an `LDKeyCodec[B]` by providing a transformation + * from `A` to `B` and one from `B` to `A` + * + * @see [[imapV]] if the transformations can fail + */ def imap[B](bToA: B => A, aToB: A => B): LDKeyCodec[B] = new LDKeyCodec[B] { override def encode(a: B): String = self.encode(bToA(a)) @@ -31,6 +50,9 @@ trait LDKeyCodec[A] { self => self.decode(str).map(aToB) } + /** A variant of [[imap]] which allows the transformation from `A` to `B` + * to fail + */ def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDKeyCodec[B] = new LDKeyCodec[B] { override def encode(a: B): String = self.encode(bToA(a)) From 5b325cb846020d4b943953c253619b17fd5199e6 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 12:56:23 -0700 Subject: [PATCH 13/21] Update circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala Co-authored-by: Darren Gibson --- .../scala/org.typelevel/catapult/circe/LDValueCodec.scala | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala index 59ba617..4a856bf 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala @@ -98,12 +98,11 @@ object LDValueCodec { case LDValueType.STRING => Json.fromString(normalized.stringValue()) case LDValueType.ARRAY => - normalized.values().iterator() - val builder = Vector.newBuilder[LDValue] + val builder = Vector.newBuilder[Json] normalized.values().forEach { ldValue => - builder.addOne(ldValue) + builder.addOne(catapultLDValueEncoder(ldValue)) } - Json.fromValues(builder.result().map(catapultLDValueEncoder(_))) + Json.fromValues(builder.result()) case LDValueType.OBJECT => val builder = Vector.newBuilder[(String, LDValue)] normalized.keys().forEach { key => From 8c577e7290b5f9489b0dad6be947b14d6bcc2f09 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 12:56:41 -0700 Subject: [PATCH 14/21] Update circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala Co-authored-by: Darren Gibson --- .../scala/org.typelevel/catapult/circe/LDValueCodec.scala | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala index 4a856bf..be25220 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala @@ -104,13 +104,11 @@ object LDValueCodec { } Json.fromValues(builder.result()) case LDValueType.OBJECT => - val builder = Vector.newBuilder[(String, LDValue)] + val builder = Vector.newBuilder[(String, Json)] normalized.keys().forEach { key => - builder.addOne(key -> normalized.get(key)) + builder.addOne(key -> catapultLDValueEncoder(normalized.get(key))) } - Json.fromFields(builder.result().map { case (key, value) => - key -> catapultLDValueEncoder(value) - }) + Json.fromFields(builder.result()) } } } From 34075db2145cd80c4e4337f07c13bd2cd530d4e7 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 12:57:58 -0700 Subject: [PATCH 15/21] Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala Co-authored-by: Darren Gibson --- .../scala/org/typelevel/catapult/circe/syntax/client.scala | 4 ---- 1 file changed, 4 deletions(-) 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 index 96ce53f..c0aeece 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala @@ -37,10 +37,6 @@ object client { .flatMap(client.jsonValueVariation(featureKey, ctx, _)) .map(_.asJson) - def circeVariation[Ctx: ContextEncoder](featureKey: FeatureKey.Aux[LDValue], ctx: Ctx)(implicit - F: MonadThrow[F] - ): F[Json] = - client.variation(featureKey, ctx).map(_.asJson) def circeVariationAs[A: Decoder, Ctx: ContextEncoder]( featureKey: String, From fcee09f5d9beb1501f0753dcb2b19a2624ceade1 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 12:58:44 -0700 Subject: [PATCH 16/21] Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala Co-authored-by: Darren Gibson --- .../scala/org/typelevel/catapult/circe/syntax/client.scala | 5 ----- 1 file changed, 5 deletions(-) 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 index c0aeece..3399bae 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala @@ -49,10 +49,5 @@ object client { .flatMap(client.jsonValueVariation(featureKey, ctx, _)) .flatMap(_.asJson.as[A].liftTo[F]) - def circeVariationAs[A: Decoder, Ctx: ContextEncoder]( - featureKey: FeatureKey.Aux[LDValue], - ctx: Ctx, - )(implicit F: MonadThrow[F]): F[A] = - client.variation(featureKey, ctx).flatMap(_.asJson.as[A].liftTo[F]) } } From 85c5b0cd2864f4918ab6d9d7a40cea3eb1fbd0fc Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 13:00:58 -0700 Subject: [PATCH 17/21] Update circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala Co-authored-by: Darren Gibson --- .../scala/org.typelevel/catapult/circe/CirceFeatureKey.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala index aa78c23..f2fd172 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala @@ -37,7 +37,7 @@ object CirceFeatureKey { def featureKey( key: String, default: Json, - ): Decoder.Result[FeatureKey.Aux[LDValue]] = + ): Decoder.Result[FeatureKey.Aux[Json]] = default.as[LDValue].map(FeatureKey.ldValue(key, _)) /** Define a feature key that is expected to return a JSON value. From 0f150d6adfdb6e36cbcd1fcce087365b42e42517 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 13:01:14 -0700 Subject: [PATCH 18/21] Update circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala Co-authored-by: Darren Gibson --- .../scala/org.typelevel/catapult/circe/CirceFeatureKey.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala index f2fd172..877d62d 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala @@ -53,6 +53,6 @@ object CirceFeatureKey { def featureKeyEncoded[A: Encoder]( key: String, default: A, - ): Decoder.Result[FeatureKey.Aux[LDValue]] = + ): Decoder.Result[FeatureKey.Aux[A]] = featureKey(key, default.asJson) } From 7ad5039d0f155baff7fc2c4031454d6361393968 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 13:01:24 -0700 Subject: [PATCH 19/21] Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala Co-authored-by: Darren Gibson --- .../scala/org/typelevel/catapult/circe/syntax/mtlClient.scala | 4 ---- 1 file changed, 4 deletions(-) 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 index fe59720..97acddd 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala @@ -48,9 +48,5 @@ object mtlClient { .flatMap(client.jsonValueVariation(featureKey, _)) .flatMap(_.asJson.as[A].liftTo[F]) - def circeVariationAs[A: Decoder](featureKey: FeatureKey.Aux[LDValue])(implicit - F: MonadThrow[F] - ): F[A] = - client.variation(featureKey).flatMap(_.asJson.as[A].liftTo[F]) } } From c7cfc05c05bbbd1eb6923d46e45244f17676a660 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 29 May 2025 13:01:48 -0700 Subject: [PATCH 20/21] Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala Co-authored-by: Darren Gibson --- .../scala/org/typelevel/catapult/circe/syntax/mtlClient.scala | 2 -- 1 file changed, 2 deletions(-) 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 index 97acddd..965e5fd 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala @@ -36,8 +36,6 @@ object mtlClient { .flatMap(client.jsonValueVariation(featureKey, _)) .map(_.asJson) - def circeVariation(featureKey: FeatureKey.Aux[LDValue])(implicit F: MonadThrow[F]): F[Json] = - client.variation(featureKey).map(_.asJson) def circeVariationAs[A: Decoder](featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F] From 70acaa71742106f08f8bf0ff409efc0b7aac3ab9 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Sat, 31 May 2025 18:20:48 -0700 Subject: [PATCH 21/21] Allow encoding to LDValue to fail Needed to support circe functionality --- .../catapult/circe/CirceFeatureKey.scala | 18 +- .../catapult/circe/JsonLDCodec.scala | 131 +++++ .../catapult/circe/LDValueCodec.scala | 114 ----- .../catapult/circe/syntax/client.scala | 27 +- .../catapult/circe/syntax/mtlClient.scala | 24 +- ...eCodecTest.scala => JsonLDCodecTest.scala} | 19 +- .../org.typelevel/catapult/FeatureKey.scala | 46 +- .../org.typelevel/catapult/LDCodec.scala | 363 -------------- .../org.typelevel/catapult/LDCursor.scala | 263 ---------- .../org.typelevel/catapult/LDKeyCodec.scala | 72 --- .../org.typelevel/catapult/instances.scala | 34 ++ .../typelevel/catapult/codec/LDCodec.scala | 454 ++++++++++++++++++ .../catapult/codec/LDCodecFailure.scala | 68 +++ .../codec/LDCodecWithInfallibleEncode.scala | 102 ++++ .../typelevel/catapult/codec/LDCursor.scala | 195 ++++++++ .../catapult/codec/LDCursorHistory.scala | 83 ++++ .../typelevel/catapult/codec/LDKeyCodec.scala | 88 ++++ .../typelevel/catapult/codec/LDReason.scala | 90 ++++ .../org/typelevel/catapult/codec/syntax.scala | 42 ++ 19 files changed, 1364 insertions(+), 869 deletions(-) create mode 100644 circe/src/main/scala/org.typelevel/catapult/circe/JsonLDCodec.scala delete mode 100644 circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala rename circe/src/test/scala/org.typelevel/catapult/circe/{LDValueCodecTest.scala => JsonLDCodecTest.scala} (81%) delete mode 100644 core/src/main/scala/org.typelevel/catapult/LDCodec.scala delete mode 100644 core/src/main/scala/org.typelevel/catapult/LDCursor.scala delete mode 100644 core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala create mode 100644 core/src/main/scala/org.typelevel/catapult/instances.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCodecFailure.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCursorHistory.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDKeyCodec.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDReason.scala create mode 100644 core/src/main/scala/org/typelevel/catapult/codec/syntax.scala diff --git a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala index 877d62d..1745725 100644 --- a/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala +++ b/circe/src/main/scala/org.typelevel/catapult/circe/CirceFeatureKey.scala @@ -16,11 +16,11 @@ package org.typelevel.catapult.circe -import com.launchdarkly.sdk.LDValue -import io.circe.syntax.* import io.circe.{Decoder, Encoder, Json} import org.typelevel.catapult.FeatureKey -import org.typelevel.catapult.circe.LDValueCodec.* +import org.typelevel.catapult.circe.JsonLDCodec.circeLDCodecForJSON +import org.typelevel.catapult.codec.LDCodec +import org.typelevel.catapult.codec.LDCodec.LDCodecResult object CirceFeatureKey { @@ -37,8 +37,8 @@ object CirceFeatureKey { def featureKey( key: String, default: Json, - ): Decoder.Result[FeatureKey.Aux[Json]] = - default.as[LDValue].map(FeatureKey.ldValue(key, _)) + ): LDCodecResult[FeatureKey.Aux[Json]] = + FeatureKey.instanceOrFailure(key, default) /** Define a feature key that is expected to return a JSON value. * @@ -50,9 +50,11 @@ object CirceFeatureKey { * @param default * a value to return if the retrieval fails or the type is not expected */ - def featureKeyEncoded[A: Encoder]( + def featureKeyEncoded[A: Encoder: Decoder]( key: String, default: A, - ): Decoder.Result[FeatureKey.Aux[A]] = - featureKey(key, default.asJson) + ): 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/LDValueCodec.scala b/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala deleted file mode 100644 index be25220..0000000 --- a/circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec.scala +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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.json.{JsonSerialization, SerializationException} -import com.launchdarkly.sdk.{LDValue, LDValueType} -import io.circe.syntax.EncoderOps -import io.circe.{Decoder, DecodingFailure, Encoder, Json} - -object LDValueCodec { - - /** Decode a `circe` [[io.circe.Json]] value as a `com.launchdarkly.sdk.LDValue` - * - * The primary failure path is due to the encoding of JSON numbers as doubles - * in the `com.launchdarkly.sdk.LDValue` type hierarchy - * @see [[https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk/latest/com/launchdarkly/sdk/LDValue.html LDValue]] - */ - implicit val catapultLDValueDecoder: Decoder[LDValue] = Decoder.instance { cursor => - cursor.focus.fold(LDValue.ofNull().asRight[DecodingFailure]) { circeValue => - circeValue.fold( - jsonNull = LDValue.ofNull().asRight, - jsonBoolean = LDValue.of(_).asRight, - jsonNumber = jNumber => - // 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. - Either - .catchOnly[SerializationException] { - LDValue.normalize( - JsonSerialization.deserialize( - Json.fromJsonNumber(jNumber).noSpaces, - classOf[LDValue], - ) - ) - } - .leftMap { (_: SerializationException) => - DecodingFailure("JSON value is not supported by LaunchDarkly LDValue", cursor.history) - }, - jsonString = LDValue.of(_).asRight, - jsonArray = _.traverse(catapultLDValueDecoder.decodeJson).map { values => - val builder = LDValue.buildArray() - values.foreach(builder.add) - builder.build() - }, - jsonObject = _.toVector - .traverse { case (key, value) => - catapultLDValueDecoder.decodeJson(value).tupleLeft(key) - } - .map { entries => - val builder = LDValue.buildObject() - entries.foreach { case (key, ldValue) => - builder.put(key, ldValue) - } - builder.build() - }, - ) - } - } - - implicit val catapultLDValueEncoder: Encoder[LDValue] = Encoder.instance { ldValue => - // So we don't have to deal with JVM nulls - val normalized = LDValue.normalize(ldValue) - normalized.getType match { - case LDValueType.NULL => Json.Null - case LDValueType.BOOLEAN => - Json.fromBoolean(normalized.booleanValue()) - case LDValueType.NUMBER => - // This is a bit of a hack because LDValue number support is lacking. - // - // LDValueNumber encodes everything as Double and that introduces encoding - // issues like negative/positive/unsigned zero and rounding when we try to do - // the conversion ourselves with the raw. - // - // Here, we're basically deferring to circe for the right thing to do with a - // Double by using the default Encoder[Double] (current behavior is to encode - // invalid values as `null`). - normalized.doubleValue().asJson - case LDValueType.STRING => - Json.fromString(normalized.stringValue()) - case LDValueType.ARRAY => - val builder = Vector.newBuilder[Json] - normalized.values().forEach { ldValue => - builder.addOne(catapultLDValueEncoder(ldValue)) - } - Json.fromValues(builder.result()) - case LDValueType.OBJECT => - val builder = Vector.newBuilder[(String, Json)] - normalized.keys().forEach { key => - builder.addOne(key -> catapultLDValueEncoder(normalized.get(key))) - } - Json.fromFields(builder.result()) - } - } -} 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 index 3399bae..f21651d 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/client.scala @@ -18,11 +18,12 @@ package org.typelevel.catapult.circe.syntax import cats.MonadThrow import cats.syntax.all.* -import com.launchdarkly.sdk.LDValue import io.circe.syntax.* -import io.circe.{Decoder, Json} -import org.typelevel.catapult.{ContextEncoder, FeatureKey, LaunchDarklyClient} -import org.typelevel.catapult.circe.LDValueCodec.* +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[_]]( @@ -32,22 +33,18 @@ object client { implicit F: MonadThrow[F] ): F[Json] = defaultValue - .as[LDValue] + .asLDValueOrFailure(LDCursorHistory.root) + .asEncodingFailure .liftTo[F] .flatMap(client.jsonValueVariation(featureKey, ctx, _)) - .map(_.asJson) + .flatMap(_.decode[Json].asDecodingFailure.liftTo[F]) - - def circeVariationAs[A: Decoder, Ctx: ContextEncoder]( + def circeVariationAs[A: Decoder: Encoder, Ctx: ContextEncoder]( featureKey: String, ctx: Ctx, - defaultValue: Json, + defaultValue: A, )(implicit F: MonadThrow[F]): F[A] = - defaultValue - .as[LDValue] - .liftTo[F] - .flatMap(client.jsonValueVariation(featureKey, ctx, _)) - .flatMap(_.asJson.as[A].liftTo[F]) - + 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 index 965e5fd..ea55fdd 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlClient.scala @@ -18,11 +18,11 @@ package org.typelevel.catapult.circe.syntax import cats.MonadThrow import cats.syntax.all.* -import com.launchdarkly.sdk.LDValue import io.circe.syntax.* -import io.circe.{Decoder, Json} -import org.typelevel.catapult.FeatureKey -import org.typelevel.catapult.circe.LDValueCodec.* +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 { @@ -31,20 +31,16 @@ object mtlClient { ) extends AnyVal { def circeVariation(featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F]): F[Json] = defaultValue - .as[LDValue] + .asLDValueOrFailure(LDCursorHistory.root) + .asEncodingFailure .liftTo[F] .flatMap(client.jsonValueVariation(featureKey, _)) - .map(_.asJson) + .flatMap(_.decode[Json].asDecodingFailure.liftTo[F]) - - def circeVariationAs[A: Decoder](featureKey: String, defaultValue: Json)(implicit + def circeVariationAs[A: Decoder: Encoder](featureKey: String, defaultValue: A)(implicit F: MonadThrow[F] ): F[A] = - defaultValue - .as[LDValue] - .liftTo[F] - .flatMap(client.jsonValueVariation(featureKey, _)) - .flatMap(_.asJson.as[A].liftTo[F]) - + circeVariation(featureKey, defaultValue.asJson) + .flatMap(_.as[A].liftTo[F]) } } diff --git a/circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala b/circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala similarity index 81% rename from circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala rename to circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala index f7d8534..c082b51 100644 --- a/circe/src/test/scala/org.typelevel/catapult/circe/LDValueCodecTest.scala +++ b/circe/src/test/scala/org.typelevel/catapult/circe/JsonLDCodecTest.scala @@ -18,14 +18,14 @@ package org.typelevel.catapult.circe import cats.syntax.all.* import com.launchdarkly.sdk.LDValue -import io.circe.syntax._ import munit.ScalaCheckSuite import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Prop.* import org.scalacheck.{Arbitrary, Gen} -import org.typelevel.catapult.circe.LDValueCodec._ +import org.typelevel.catapult.circe.JsonLDCodec.* +import org.typelevel.catapult.instances.* -class LDValueCodecTest extends ScalaCheckSuite { +class JsonLDCodecTest extends ScalaCheckSuite { private val maxDepth = 3 private val maxEntries = 5 private val maxFields = 5 @@ -72,14 +72,17 @@ class LDValueCodecTest extends ScalaCheckSuite { implicit private val arbLDValue: Arbitrary[LDValue] = Arbitrary(genLDValue(0)) - property("LDValueSerde round trip")(forAll { (input: LDValue) => - input.asJson - .as[LDValue] + property("circeLDCodecForJSON round trip")(forAll { (input: LDValue) => + circeLDCodecForJSON + .decode(input) + .andThen(circeLDCodecForJSON.encode) + .leftMap(_.toNonEmptyVector.toVector) // Displays better in MUnit .map { output => - if (input == output) proved + // 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(fail("Conversion failed", _)) + .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 index 1da7c80..0f3feb5 100644 --- a/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala +++ b/core/src/main/scala/org.typelevel/catapult/FeatureKey.scala @@ -16,9 +16,13 @@ package org.typelevel.catapult -import cats.Show 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 */ @@ -40,8 +44,17 @@ object FeatureKey { * @param default * a value to return if the retrieval fails or the value cannot be decoded to an `A` */ - def instance[A: Show: LDCodec](key: String, default: A): FeatureKey.Aux[A] = - new Impl[A](key, default) + 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 @@ -65,7 +78,8 @@ object FeatureKey { * @param default * a value to return if the retrieval fails or the type is not expected */ - def int(key: String, default: Int): FeatureKey.Aux[Int] = instance[Int](key, default) + 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 @@ -84,19 +98,27 @@ object FeatureKey { * @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] = { - implicit def ldValueShow: Show[LDValue] = FeatureKey.ldValueShow - new Impl[LDValue](key, default) - } + def ldValue(key: String, default: LDValue): FeatureKey.Aux[LDValue] = + new Impl[LDValue](key, default, default) - private val ldValueShow: Show[LDValue] = Show.show(_.toJsonString) - private final case class Impl[A: Show: LDCodec](_key: String, _default: A) extends FeatureKey { + 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 = codec.encode(_default) + override val ldValueDefault: LDValue = _ldValueDefault override def default: A = _default - override def toString: String = s"FeatureKey($key, ${_default.show})" + 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/LDCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDCodec.scala deleted file mode 100644 index e264b1e..0000000 --- a/core/src/main/scala/org.typelevel/catapult/LDCodec.scala +++ /dev/null @@ -1,363 +0,0 @@ -/* - * 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.data.* -import cats.syntax.all.* -import com.launchdarkly.sdk.{LDValue, LDValueType} -import org.typelevel.catapult.LDCodec.DecodingFailed -import org.typelevel.catapult.LDCodec.DecodingFailed.Reason -import org.typelevel.catapult.LDCursor.LDCursorHistory - -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] { self => - - /** Encode a value to `LDValue` - */ - def encode(a: A): LDValue - - /** Decode the given `LDCursor` - */ - def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] - - /** Decode the given `LDValue` - */ - def decode(ld: LDValue): ValidatedNec[DecodingFailed, A] = decode(LDCursor.root(ld)) - - /** Transform a `LDCodec[A]` into an `LDCodec[B]` by providing a transformation - * from `A` to `B` and one from `B` to `A` - * - * @see [[imapV]] or [[imapVFull]] if the transformations can fail - */ - def imap[B](bToA: B => A, aToB: A => B): LDCodec[B] = new LDCodec[B] { - override def encode(a: B): LDValue = self.encode(bToA(a)) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = - self.decode(c).map(aToB) - } - - /** A variant of [[imap]] which allows the transformation from `A` to `B` - * to fail - * - * @see [[imapVFull]] if the failure can affect the history - */ - def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDCodec[B] = new LDCodec[B] { - override def encode(a: B): LDValue = self.encode(bToA(a)) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = - self.decode(c).andThen { a => - aToB(a).leftMap(_.map(DecodingFailed(_, c.history))) - } - } - - /** A variant of [[imap]] which allows the transformation from `A` to `B` - * to fail with an updated history - */ - def imapVFull[B]( - bToA: B => A, - aToB: (A, LDCursorHistory) => ValidatedNec[DecodingFailed, B], - ): LDCodec[B] = new LDCodec[B] { - override def encode(a: B): LDValue = self.encode(bToA(a)) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, B] = - self.decode(c).andThen(a => aToB(a, c.history)) - } -} -object LDCodec { - def apply[A](implicit LDC: LDCodec[A]): LDC.type = LDC - - def instance[A]( - _encode: A => LDValue, - _decode: LDCursor => ValidatedNec[DecodingFailed, A], - ): LDCodec[A] = - new LDCodec[A] { - override def encode(a: A): LDValue = _encode(a) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, A] = _decode(c) - } - - /** Encodes a failure while decoding from an `LDValue` - */ - sealed trait DecodingFailed { - - /** The reason for the decoding failure - */ - def reason: DecodingFailed.Reason - - /** The path to the point where the failure occurred - */ - def history: LDCursorHistory - } - object DecodingFailed { - def apply(reason: Reason, history: LDCursorHistory): DecodingFailed = new Impl(reason, history) - - def failed[A](reason: Reason, history: LDCursorHistory): ValidatedNec[DecodingFailed, A] = - apply(reason, history).invalidNec - - sealed trait Reason { - def explain: String - def explain(history: String): String - } - object Reason { - case object MissingField extends Reason { - override def explain: String = "Missing expected field" - override def explain(history: String): String = s"Missing expected field at $history" - } - - case object IndexOutOfBounds extends Reason { - override def explain: String = "Out of bounds" - override def explain(history: String): String = s"$history is out of bounds" - } - - final case class WrongType(expected: LDValueType, actual: LDValueType) extends Reason { - override def explain: String = s"Expected ${expected.name()}, but was ${actual.name()}" - override def explain(history: String): String = - s"Expected ${expected.name()} at $history, but was ${actual.name()}" - } - - final case class UnableToDecodeKey(reason: Reason) extends Reason { - override def explain: String = s"Unable to decode key (${reason.explain}" - override def explain(history: String): String = - s"Unable to decode key at $history (${reason.explain})" - } - - final case class Other(reason: String) extends Reason { - override def explain: String = reason - override def explain(history: String): String = s"Failure at $history: $reason" - } - } - - implicit val show: Show[DecodingFailed] = Show.show { df => - df.reason.explain(df.history.show) - } - - private final class Impl(val reason: Reason, val history: LDCursorHistory) - extends DecodingFailed { - override def toString: String = s"DecodingFailed($reason, ${history.show})" - - override def equals(obj: Any): Boolean = obj match { - case that: DecodingFailed => this.reason == that.reason && this.history == that.history - case _ => false - } - - override def hashCode(): Int = ("DecodingFailure", reason, history).## - } - } - - implicit val ldValueInstance: LDCodec[LDValue] = new LDCodec[LDValue] { - override def encode(a: LDValue): LDValue = a - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, LDValue] = c.value.valid - } - - implicit val booleanInstance: LDCodec[Boolean] = new LDCodec[Boolean] { - override def encode(a: Boolean): LDValue = LDValue.of(a) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Boolean] = - c.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()) - } - - implicit val stringInstance: LDCodec[String] = new LDCodec[String] { - override def encode(a: String): LDValue = LDValue.of(a) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, String] = - c.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: LDCodec[Double] = new LDCodec[Double] { - override def encode(a: Double): LDValue = LDValue.of(a) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Double] = - c.checkType(LDValueType.NUMBER).andThen { numCur => - val d = numCur.value.doubleValue() - Validated.condNec( - !d.isNaN, - d, - DecodingFailed(Reason.Other("Value is not a valid double"), numCur.history), - ) - } - } - - implicit val intInstance: LDCodec[Int] = LDCodec[Double].imapV[Int]( - _.toDouble, - d => Validated.condNec(d.isValidInt, d.toInt, Reason.Other("Value is not a valid int")), - ) - - implicit val longInstance: LDCodec[Long] = LDCodec[Double].imapV[Long]( - _.toDouble, - d => { - val asLong = d.toLong - val dByWayOfL = asLong.toDouble - Validated.condNec( - // Borrowed from commented out code in RichDouble - dByWayOfL == dByWayOfL && asLong != Long.MaxValue, - asLong, - Reason.Other("Value is not a valid long"), - ) - }, - ) - - implicit val noneInstance: LDCodec[None.type] = - new LDCodec[None.type] { - override def encode(a: None.type): LDValue = LDValue.ofNull() - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, None.type] = - c.checkType(LDValueType.NULL).as(None) - } - - implicit def decodeSome[A: LDCodec]: LDCodec[Some[A]] = - LDCodec[A].imap[Some[A]](_.value, Some(_)) - - implicit def decodeOption[A: LDCodec]: LDCodec[Option[A]] = new LDCodec[Option[A]] { - override def encode(a: Option[A]): LDValue = - a.fold(LDValue.ofNull())(LDCodec[A].encode) - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, Option[A]] = - noneInstance.decode(c).findValid(LDCodec[A].decode(c).map(_.some)) - } - - def ldArrayInstance[F[_], A: LDCodec]( - toIterator: F[A] => Iterator[A], - factory: Factory[A, F[A]], - ): LDCodec[F[A]] = - new LDCodec[F[A]] { - override def encode(fa: F[A]): LDValue = { - val builder = LDValue.buildArray() - toIterator(fa).foreach { elem => - builder.add(LDCodec[A].encode(elem)) - } - builder.build() - } - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, F[A]] = - c.asArray - .andThen(_.elements[A]) - .map(factory.fromSpecific(_)) - } - - implicit def ldArrayToArrayInstance[A: LDCodec: ClassTag]: LDCodec[Array[A]] = - ldArrayInstance[Array, A](_.iterator, Array) - - implicit def ldArrayToVectorInstance[A: LDCodec]: LDCodec[Vector[A]] = - ldArrayInstance[Vector, A](_.iterator, Vector) - - implicit def ldArrayToListInstance[A: LDCodec]: LDCodec[List[A]] = - ldArrayInstance[List, A](_.iterator, List) - - implicit def ldArrayToChainInstance[A: LDCodec]: LDCodec[Chain[A]] = - ldArrayToVectorInstance[A].imap[Chain[A]](_.toVector, Chain.fromSeq) - - implicit def ldArrayToNonEmptyListInstance[A: LDCodec]: LDCodec[NonEmptyList[A]] = - ldArrayToListInstance[A].imapVFull[NonEmptyList[A]]( - _.toList, - (list, history) => - NonEmptyList.fromList(list).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) - - implicit def ldArrayToNonEmptyVectorInstance[A: LDCodec]: LDCodec[NonEmptyVector[A]] = - ldArrayToVectorInstance[A].imapVFull[NonEmptyVector[A]]( - _.toVector, - (vec, history) => - NonEmptyVector.fromVector(vec).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) - - implicit def ldArrayToNonEmptyChainInstance[A: LDCodec]: LDCodec[NonEmptyChain[A]] = - ldArrayToChainInstance[A].imapVFull[NonEmptyChain[A]]( - _.toChain, - (chain, history) => - NonEmptyChain.fromChain(chain).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) - - def ldObjectInstance[CC, K: LDKeyCodec, V: LDCodec]( - toIterator: CC => Iterator[(K, V)], - factory: Factory[(K, V), CC], - ): LDCodec[CC] = - new LDCodec[CC] { - override def encode(cc: CC): LDValue = { - val builder = LDValue.buildObject() - toIterator(cc).foreach { case (k, v) => - builder.put(LDKeyCodec[K].encode(k), LDCodec[V].encode(v)) - } - builder.build() - } - - override def decode(c: LDCursor): ValidatedNec[DecodingFailed, CC] = - c.asObject - .andThen(_.entries[K, V]) - .map(factory.fromSpecific(_)) - } - - implicit def ldObjectToPairsInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Map[K, V]] = - ldObjectInstance[Map[K, V], K, V](_.iterator, Map) - - implicit def ldObjectToArrayInstance[K: LDKeyCodec, V: LDCodec](implicit - ct: ClassTag[(K, V)] - ): LDCodec[Array[(K, V)]] = - ldObjectInstance[Array[(K, V)], K, V](_.iterator, Array) - - implicit def ldObjectToVectorInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Vector[(K, V)]] = - ldObjectInstance[Vector[(K, V)], K, V](_.iterator, Vector) - - implicit def ldObjectToListInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[List[(K, V)]] = - ldObjectInstance[List[(K, V)], K, V](_.iterator, List) - - implicit def ldObjectToChainInstance[K: LDKeyCodec, V: LDCodec]: LDCodec[Chain[(K, V)]] = - ldObjectToVectorInstance[K, V].imap[Chain[(K, V)]](_.toVector, Chain.fromSeq) - - implicit def ldObjectToNonEmptyListInstance[K: LDKeyCodec, V: LDCodec] - : LDCodec[NonEmptyList[(K, V)]] = - ldObjectToListInstance[K, V].imapVFull[NonEmptyList[(K, V)]]( - _.toList, - (list, history) => - NonEmptyList.fromList(list).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) - - implicit def ldObjectToNonEmptyVectorInstance[K: LDKeyCodec, V: LDCodec] - : LDCodec[NonEmptyVector[(K, V)]] = - ldObjectToVectorInstance[K, V].imapVFull[NonEmptyVector[(K, V)]]( - _.toVector, - (vec, history) => - NonEmptyVector.fromVector(vec).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) - - implicit def ldObjectToNonEmptyChainInstance[K: LDKeyCodec, V: LDCodec] - : LDCodec[NonEmptyChain[(K, V)]] = - ldObjectToChainInstance[K, V].imapVFull[NonEmptyChain[(K, V)]]( - _.toChain, - (chain, history) => - NonEmptyChain.fromChain(chain).toValidNec { - DecodingFailed(Reason.IndexOutOfBounds, history.at(0)) - }, - ) -} diff --git a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala b/core/src/main/scala/org.typelevel/catapult/LDCursor.scala deleted file mode 100644 index 76ac92c..0000000 --- a/core/src/main/scala/org.typelevel/catapult/LDCursor.scala +++ /dev/null @@ -1,263 +0,0 @@ -/* - * 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.data.{Chain, ValidatedNec} -import cats.syntax.all.* -import com.launchdarkly.sdk.{LDValue, LDValueType} -import org.typelevel.catapult.LDCodec.DecodingFailed -import org.typelevel.catapult.LDCodec.DecodingFailed.Reason.{ - IndexOutOfBounds, - MissingField, - UnableToDecodeKey, - WrongType, -} -import org.typelevel.catapult.LDCursor.LDCursorHistory.Move -import org.typelevel.catapult.LDCursor.{LDArrayCursor, LDCursorHistory, LDObjectCursor} - -/** 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 - - /** The path to the current value - */ - def history: LDCursorHistory - - /** Attempt to decode the current value to an `A` - */ - def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] - - /** Ensure the type of `value` matches the expected `LDValueType` - * - * @see [[asArray]] if the expected type is `ARRAY` - */ - def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] - - /** Ensure the type of `value` is `ARRAY` and return an `LDCursor` - * specialized to working with `LDValue` arrays - */ - def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] - - /** Ensure the type of `value` is `OBJECT` and return an `LDCursor` - * specialized to working with `LDValue` objects - */ - def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] -} - -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) - - /** 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[DecodingFailed, 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[DecodingFailed, A] = - at(index).andThen(_.as[A]) - - /** Attempt to decode all the entries as `A`s - * - * @note This will generally be more efficient than repeated calls to [[get]] - */ - def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[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[DecodingFailed, LDCursor] - - /** Attempt to decode the value the given field as an `A` - */ - def get[A: LDCodec](field: String): ValidatedNec[DecodingFailed, A] = - at(field).andThen(_.as[A]) - - /** Attempt to decode the entire object as a vector of `(K, V)` entries - */ - def entries[K: LDKeyCodec, V: LDCodec]: ValidatedNec[DecodingFailed, Vector[(K, V)]] - } - - sealed trait LDCursorHistory { - def moves: Chain[Move] - def at(field: String): LDCursorHistory - def at(index: Int): LDCursorHistory - } - object LDCursorHistory { - def root: LDCursorHistory = HistoryImpl(Chain.empty) - def of(moves: Chain[Move]): LDCursorHistory = HistoryImpl(moves) - - private final case class HistoryImpl(moves: Chain[Move]) extends LDCursorHistory { - override def at(field: String): LDCursorHistory = HistoryImpl(moves.append(Move.Field(field))) - - override def at(index: Int): LDCursorHistory = HistoryImpl(moves.append(Move.Index(index))) - } - - sealed trait Move - - object Move { - final case class Field(name: String) extends Move - - final case class Index(index: Int) extends Move - - implicit val show: Show[Move] = Show.show { - case Field(name) if name.forall(c => c.isLetterOrDigit || c == '_' || c == '-') => s".$name" - case Field(name) => s"[$name]" - case Index(index) => s"[$index]" - } - } - - implicit val show: Show[LDCursorHistory] = Show.show(_.moves.mkString_("$", "", "")) - } - - private final class Impl(override val value: LDValue, override val history: LDCursorHistory) - extends LDCursor { - override def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] = LDCodec[A].decode(value) - - override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = - value.getType match { - case actual if actual != expected => - DecodingFailed.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[DecodingFailed, LDArrayCursor] = - if (value.getType == LDValueType.ARRAY) new ArrayCursorImpl(value, history).valid - else DecodingFailed.failed(WrongType(LDValueType.ARRAY, value.getType), history) - - override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = - if (value.getType == LDValueType.OBJECT) new ObjectCursorImpl(value, history).valid - else DecodingFailed.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[DecodingFailed, A] = LDCodec[A].decode(this) - - override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = - if (expected == LDValueType.ARRAY) this.valid - else DecodingFailed.failed(WrongType(expected, value.getType), history) - - override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = - DecodingFailed.failed(WrongType(LDValueType.OBJECT, value.getType), history) - - override def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] = this.valid - - override def at(index: Int): ValidatedNec[DecodingFailed, LDCursor] = { - val updatedHistory = history.at(index) - if (index >= 0 && index < value.size()) - new Impl(LDValue.normalize(value.get(index)), updatedHistory).valid - else DecodingFailed.failed(IndexOutOfBounds, updatedHistory) - } - - override def elements[A: LDCodec]: ValidatedNec[DecodingFailed, Vector[A]] = { - val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, A]] - builder.sizeHint(value.size()) - var idx = 0 - value.values().forEach { ldValue => - builder.addOne( - LDCodec[A].decode( - LDCursor.of( - LDValue.normalize(ldValue), - history.at(idx), - ) - ) - ) - idx = idx + 1 - } - builder.result().sequence - } - } - - private final class ObjectCursorImpl( - override val value: LDValue, - override val history: LDCursorHistory, - ) extends LDObjectCursor { - override def as[A: LDCodec]: ValidatedNec[DecodingFailed, A] = LDCodec[A].decode(this) - - override def checkType(expected: LDValueType): ValidatedNec[DecodingFailed, LDCursor] = - if (expected == LDValueType.OBJECT) this.valid - else DecodingFailed.failed(WrongType(expected, value.getType), history) - - override def asObject: ValidatedNec[DecodingFailed, LDObjectCursor] = this.valid - - override def asArray: ValidatedNec[DecodingFailed, LDArrayCursor] = - DecodingFailed.failed(WrongType(LDValueType.ARRAY, value.getType), history) - - override def at(field: String): ValidatedNec[DecodingFailed, 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 DecodingFailed.failed(MissingField, updatedHistory) - } - } - - override def entries[K: LDKeyCodec, V: LDCodec] - : ValidatedNec[DecodingFailed, Vector[(K, V)]] = { - val builder = Vector.newBuilder[ValidatedNec[DecodingFailed, (K, V)]] - builder.sizeHint(value.size()) - value.keys().forEach { field => - val updatedHistory = history.at(field) - val decodedEntry = ( - LDKeyCodec[K] - .decode(field) - .leftMap(_.map { reason => - DecodingFailed(UnableToDecodeKey(reason), updatedHistory) - }), - LDCodec[V].decode(LDCursor.of(LDValue.normalize(value.get(field)), updatedHistory)), - ).tupled - builder.addOne(decodedEntry) - } - builder.result().sequence - } - } -} diff --git a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala b/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala deleted file mode 100644 index ad0ec27..0000000 --- a/core/src/main/scala/org.typelevel/catapult/LDKeyCodec.scala +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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.data.ValidatedNec -import cats.syntax.all.* -import org.typelevel.catapult.LDCodec.DecodingFailed.Reason - -/** 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): 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[Reason, A] - - /** Transform a `LDKeyCodec[A]` into an `LDKeyCodec[B]` by providing a transformation - * from `A` to `B` and one from `B` to `A` - * - * @see [[imapV]] if the transformations can fail - */ - def imap[B](bToA: B => A, aToB: A => B): LDKeyCodec[B] = new LDKeyCodec[B] { - override def encode(a: B): String = self.encode(bToA(a)) - - override def decode(str: String): ValidatedNec[Reason, B] = - self.decode(str).map(aToB) - } - - /** A variant of [[imap]] which allows the transformation from `A` to `B` - * to fail - */ - def imapV[B](bToA: B => A, aToB: A => ValidatedNec[Reason, B]): LDKeyCodec[B] = - new LDKeyCodec[B] { - override def encode(a: B): String = self.encode(bToA(a)) - - override def decode(str: String): ValidatedNec[Reason, B] = - self.decode(str).andThen(aToB(_)) - } -} -object LDKeyCodec { - def apply[A](implicit KC: LDKeyCodec[A]): KC.type = KC - - implicit val stringInstance: LDKeyCodec[String] = new LDKeyCodec[String] { - override def encode(a: String): String = a - - override def decode(str: String): ValidatedNec[Reason, String] = str.valid - } -} 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(_)) + } +}