From be299545964d69ace3f855cd8bf611e96545d6a3 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 5 Oct 2026 13:57:16 -0700 Subject: [PATCH 1/6] Remove LDCodecWithInfallibleEncode We're papering over something that probably shouldn't be papered over, and it makes the whole thing messy. Much better to be open about LDValue's inability to encode certain values, and keep the type hierarchy clean. --- .../catapult/circe/CirceFeatureKey.scala | 4 +- .../catapult/circe/JsonLDCodec.scala | 10 +- .../catapult/circe/syntax/client.scala | 2 +- .../catapult/circe/syntax/mtlClient.scala | 2 +- .../org/typelevel/catapult/FeatureKey.scala | 60 +++++++-- .../typelevel/catapult/codec/LDCodec.scala | 117 ++++-------------- .../org/typelevel/catapult/codec/syntax.scala | 6 +- 7 files changed, 84 insertions(+), 117 deletions(-) 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 1745725..8c5f75e 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/CirceFeatureKey.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/CirceFeatureKey.scala @@ -38,7 +38,7 @@ object CirceFeatureKey { key: String, default: Json, ): LDCodecResult[FeatureKey.Aux[Json]] = - FeatureKey.instanceOrFailure(key, default) + FeatureKey.instance(key, default) /** Define a feature key that is expected to return a JSON value. * @@ -55,6 +55,6 @@ object CirceFeatureKey { default: A, ): LDCodecResult[FeatureKey.Aux[A]] = { implicit val ldCodec: LDCodec[A] = JsonLDCodec.ldCodecFromCirceCodec[A] - FeatureKey.instanceOrFailure(key, default) + FeatureKey.instance(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 index 475885b..a982e80 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala @@ -51,11 +51,11 @@ private object JsonLDCodecImplementation { _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), + jsonBoolean = _.asLDValue(history), + jsonNumber = _.asLDValue(history), + jsonString = _.asLDValue(history), + jsonArray = _.asLDValue(history), + jsonObject = _.toIterable.asLDValue(history), ), _decode = cursor => cursor.valueType match { 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 f21651d..3992070 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 @@ -33,7 +33,7 @@ object client { implicit F: MonadThrow[F] ): F[Json] = defaultValue - .asLDValueOrFailure(LDCursorHistory.root) + .asLDValue(LDCursorHistory.root) .asEncodingFailure .liftTo[F] .flatMap(client.jsonValueVariation(featureKey, ctx, _)) 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 ea55fdd..ee2542c 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 @@ -31,7 +31,7 @@ object mtlClient { ) extends AnyVal { def circeVariation(featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F]): F[Json] = defaultValue - .asLDValueOrFailure(LDCursorHistory.root) + .asLDValue(LDCursorHistory.root) .asEncodingFailure .liftTo[F] .flatMap(client.jsonValueVariation(featureKey, _)) diff --git a/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala b/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala index 0f3feb5..49df6c7 100644 --- a/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala +++ b/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala @@ -18,10 +18,11 @@ package org.typelevel.catapult import cats.syntax.all.* import cats.Show +import cats.data.NonEmptyChain 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.codec.{LDCodec, LDCodecFailure, LDCursorHistory} import org.typelevel.catapult.instances.* /** Defines a Launch Darkly key, it's expected type, and a default value @@ -38,23 +39,37 @@ object FeatureKey { type Type = T } + final case class InvalidDefault[A]( + key: String, + default: A, + encodingErrors: NonEmptyChain[LDCodecFailure], + ) extends IllegalArgumentException( + s"FeatureKey $key defined with default that cannot be encoded in an LDValue" + ) + /** 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` + * @return + * `LDCodecResult[FeatureKey.Aux[A]]` instead of `FeatureKey.Aux[A]` because many unremarkable + * values cannot be encoded in an `LDValue` */ - def instance[A: LDCodecWithInfallibleEncode](key: String, default: A): FeatureKey.Aux[A] = - new Impl[A](key, default, LDCodecWithInfallibleEncode[A].safeEncode(default)) + def instance[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 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` + * @throws InvalidDefault + * when `default` cannot be encoded in an `LDValue` */ - def instanceOrFailure[A: LDCodec](key: String, default: A): LDCodecResult[FeatureKey.Aux[A]] = - LDCodec[A].encode(default, LDCursorHistory.root).map(new Impl[A](key, default, _)) + def instanceOrDie[A: LDCodec](key: String, default: A): FeatureKey.Aux[A] = + instance(key, default).valueOr(errors => throw InvalidDefault(key, default, errors)) /** Define a feature key that is expected to return a boolean value. * @param key @@ -62,7 +77,14 @@ object FeatureKey { * @param default * a value to return if the retrieval fails or the type is not expected */ - def bool(key: String, default: Boolean): FeatureKey.Aux[Boolean] = instance[Boolean](key, default) + def bool(key: String, default: Boolean): LDCodecResult[FeatureKey.Aux[Boolean]] = + instance[Boolean](key, default) + + /** @see [[FeatureKey.bool]] + * @see [[FeatureKey.instanceOrDie]] + */ + def boolOrDie(key: String, default: Boolean): FeatureKey.Aux[Boolean] = + instanceOrDie[Boolean](key, default) /** Define a feature key that is expected to return a string value. * @param key @@ -70,7 +92,14 @@ 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] = instance[String](key, default) + def string(key: String, default: String): LDCodecResult[FeatureKey.Aux[String]] = + instance[String](key, default) + + /** @see [[FeatureKey.string]] + * @see [[FeatureKey.instanceOrDie]] + */ + def stringOrDie(key: String, default: String): FeatureKey.Aux[String] = + instanceOrDie[String](key, default) /** Define a feature key that is expected to return a integer value. * @param key @@ -79,7 +108,13 @@ object FeatureKey { * a value to return if the retrieval fails or the type is not expected */ def int(key: String, default: Int): LDCodecResult[FeatureKey.Aux[Int]] = - instanceOrFailure[Int](key, default) + instance[Int](key, default) + + /** @see [[FeatureKey.int]] + * @see [[FeatureKey.instanceOrDie]] + */ + def intOrDie(key: String, default: Int): FeatureKey.Aux[Int] = + instanceOrDie[Int](key, default) /** Define a feature key that is expected to return a double value. * @param key @@ -87,7 +122,14 @@ 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] = instance[Double](key, default) + def double(key: String, default: Double): LDCodecResult[FeatureKey.Aux[Double]] = + instance[Double](key, default) + + /** @see [[FeatureKey.double]] + * @see [[FeatureKey.instanceOrDie]] + */ + def doubleOrDie(key: String, default: Double): FeatureKey.Aux[Double] = + instanceOrDie[Double](key, default) /** Define a feature key that is expected to return a JSON value. * diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala index bc93b82..3f4991c 100644 --- a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala @@ -92,18 +92,6 @@ object LDCodec { 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 = @@ -116,8 +104,6 @@ object LDCodec { 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], @@ -189,14 +175,34 @@ object LDCodec { } } + implicit val ldValueInstance: LDCodec[LDValue] = + instance((value, _) => value.valid, _.value.valid) + + implicit val booleanInstance: LDCodec[Boolean] = instance( + (value, _) => LDValue.of(value).valid, + _.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()), + ) + + implicit val stringInstance: LDCodec[String] = instance( + (value, _) => LDValue.of(value).valid, + _.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] = instance( + (value, _) => LDValue.of(value).valid, + _.checkType(LDValueType.NUMBER).map(_.value.doubleValue()), + ) + 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(), + implicit val noneInstance: LDCodec[None.type] = instance( + (_, _) => LDValue.ofNull().valid, _.checkType(LDValueType.NULL).as(None), ) @@ -229,21 +235,6 @@ object LDCodec { .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]] = @@ -264,31 +255,15 @@ object LDCodec { 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) @@ -359,24 +334,6 @@ object LDCodec { .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], @@ -410,50 +367,20 @@ object LDCodec { 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/syntax.scala b/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala index 35729fe..4168721 100644 --- a/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala +++ b/core/src/main/scala/org/typelevel/catapult/codec/syntax.scala @@ -22,10 +22,8 @@ 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) + def asLDValue(history: LDCursorHistory)(implicit CA: LDCodec[A]): LDCodecResult[LDValue] = + CA.encode(a, history) } implicit final class LDValueDecodeOps(private val ldValue: LDValue) extends AnyVal { From 94dafe9e4eed46a847c7912efb2a9826fc842464 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 5 Oct 2026 13:58:53 -0700 Subject: [PATCH 2/6] Fix incorrect "is this encodable as a double" check --- core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala index 3f4991c..f43d81e 100644 --- a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala @@ -158,7 +158,7 @@ object LDCodec { override def encode(n: N, history: LDCursorHistory): LDCodecResult[LDValue] = { val d = toDouble(n) Validated.condNec( - toDouble(n) == d, + fromDouble(d) == n, LDValue.of(d), LDCodecFailure(LDReason.undecodableValue(LDValueType.NUMBER, typeName), history), ) From 960c25399294b47c8b7f65e2b974f8ef319579a8 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 5 Oct 2026 13:59:11 -0700 Subject: [PATCH 3/6] Add tests for FeatureKey --- .../typelevel/catapult/FeatureKeyTests.scala | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala diff --git a/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala new file mode 100644 index 0000000..41a3edc --- /dev/null +++ b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala @@ -0,0 +1,193 @@ +/* + * 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.effect.IO +import cats.syntax.all.* +import com.launchdarkly.sdk.{LDContext, LDValue} +import org.typelevel.catapult.codec.LDCodec +import org.typelevel.catapult.testkit.* +import weaver.SimpleIOSuite + +object FeatureKeyTests extends SimpleIOSuite { + private val ctx = LDContext.create("testContext") + + test("serve boolean variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + fk <- IO(FeatureKey.boolOrDie("test", true)) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(false)))) + notDefault <- client.variation(fk, ctx) + } yield expect(default === true) && expect(notDefault === false) + } + } + + test("serve string variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + fk <- IO(FeatureKey.stringOrDie("test", "default")) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of("not-default")))) + notDefault <- client.variation(fk, ctx) + } yield expect(default === "default") && expect(notDefault === "not-default") + } + } + + test("serve int variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + fk <- IO(FeatureKey.intOrDie("test", 10)) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(-10)))) + notDefault <- client.variation(fk, ctx) + } yield expect(default === 10) && expect(notDefault === -10) + } + } + + test("serve double variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + fk <- IO(FeatureKey.doubleOrDie("test", 2d)) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(-2d)))) + notDefault <- client.variation(fk, ctx) + } yield expect(default === 2d) && expect(notDefault === -2d) + } + } + + test("serve ldValue variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + fk <- IO(FeatureKey.ldValue("test", LDValue.of(5))) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(true)))) + notDefault <- client.variation(fk, ctx) + } yield expect(default == LDValue.of(5)) && expect(notDefault == LDValue.of(true)) + } + } + + test("serve array variations through FeatureFlag") { + testClient.use { case (td, client) => + val emptyArray = Vector.empty[Int] + val arrayOfOne = Vector(1) + def arrayOfMany = Vector(1, 10) + for { + fk <- IO(FeatureKey.instanceOrDie("test", emptyArray)) + default <- client.variation(fk, ctx) + _ <- IO( + td.update( + td.flag(fk.key) + .valueForAll( + LDValue.arrayOf( + LDValue.of(1) + ) + ) + ) + ) + notDefaultOne <- client.variation(fk, ctx) + _ <- IO( + td.update( + td.flag(fk.key) + .valueForAll( + LDValue.arrayOf( + LDValue.of(1), + LDValue.of(10), + ) + ) + ) + ) + notDefaultMany <- client.variation(fk, ctx) + } yield expect(default == emptyArray) && + expect(notDefaultOne == arrayOfOne) && + expect(notDefaultMany == arrayOfMany) + } + } + + test("serve map variations through FeatureFlag") { + testClient.use { case (td, client) => + val emptyMap = Map.empty[String, Boolean] + val mapOfOne = Map("foo" -> true) + def mapOfMany = Map( + "foo" -> true, + "bar" -> false, + ) + for { + fk <- IO(FeatureKey.instanceOrDie("test", emptyMap)) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll { + LDValue + .buildObject() + .put("foo", true) + .build() + })) + notDefaultOne <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll { + LDValue + .buildObject() + .put("foo", true) + .put("bar", false) + .build() + })) + notDefaultMany <- client.variation(fk, ctx) + } yield expect(default == emptyMap) && + expect(notDefaultOne == mapOfOne) && + expect(notDefaultMany == mapOfMany) + } + } + + test("serve case class variation through FeatureFlag") { + final case class Foo(a: String, b: Boolean) + + testClient.use { case (td, client) => + implicit val codec: LDCodec[Foo] = LDCodec.instance[Foo]( + (foo, _) => + LDValue + .buildObject() + .put("a", foo.a) + .put("b", foo.b) + .build + .valid, + _.asObject.andThen { obj => + ( + obj.at("a").andThen(_.as[String]), + obj.at("b").andThen(_.as[Boolean]), + ).mapN(Foo) + }, + ) + val defaultFoo = Foo(a = "hi", b = false) + for { + fk <- IO(FeatureKey.instanceOrDie("test", defaultFoo)) + default <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll { + LDValue + .buildObject() + .put("a", "hola") + .put("b", true) + .build() + })) + notDefault <- client.variation(fk, ctx) + _ <- IO(td.update(td.flag(fk.key).valueForAll { + LDValue.buildObject().build() + })) + invalidValue <- client.variation(fk, ctx) + } yield expect(default == defaultFoo) && + expect(notDefault == Foo(a = "hola", b = true)) && + expect(invalidValue == defaultFoo) + } + } +} From 434f41e73993a39c87028797d3c838640d078dec Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 5 Oct 2026 15:14:09 -0700 Subject: [PATCH 4/6] Actually delete LDCodecWithInfallibleEncode.scala Really not sure why this wasn't deleted before, it wasn't showing in my editor, so I'm guessing some sort of odd IntelliJ bug? --- .../codec/LDCodecWithInfallibleEncode.scala | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala deleted file mode 100644 index 870a348..0000000 --- a/core/src/main/scala/org/typelevel/catapult/codec/LDCodecWithInfallibleEncode.scala +++ /dev/null @@ -1,98 +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.codec - -import cats.Invariant -import cats.syntax.all.* -import com.launchdarkly.sdk.{LDValue, LDValueType} -import org.typelevel.catapult.codec.LDCodec.{LDCodecResult, withInfallibleEncode} - -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] = instanceFull( - LDValue.of, - _.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()), - ) - - implicit val stringInstance: LDCodecWithInfallibleEncode[String] = instanceFull( - 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] = instanceFull( - 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)) - } -} From 622da30d1091d9cd9828b46bbb9d9e4b80e0af62 Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Mon, 5 Oct 2026 22:48:43 -0700 Subject: [PATCH 5/6] Final (probably) cleanup - Fix a Scala 3 thing that was making the tests mad - Made LDCursor a bit easier to work with --- .../catapult/circe/JsonLDCodec.scala | 21 +-- .../typelevel/catapult/codec/LDCodec.scala | 60 +++++--- .../typelevel/catapult/codec/LDCursor.scala | 144 +++++++++++------- .../typelevel/catapult/FeatureKeyTests.scala | 19 +-- 4 files changed, 150 insertions(+), 94 deletions(-) diff --git a/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala b/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala index a982e80..2ea38fd 100644 --- a/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala +++ b/circe/src/main/scala/org/typelevel/catapult/circe/JsonLDCodec.scala @@ -117,15 +117,16 @@ private object JsonLDCodecImplementation { 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")) - }) - }, + cursor => + cursor.checkType(LDValueType.NUMBER).value.andThen { value => + // 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(value) + .map(Json.fromDouble(_).flatMap(_.asNumber)) + .andThen(_.toValidNec { + cursor.fail(LDReason.undecodableValue(LDValueType.NUMBER, "JNumber")) + }) + }, ) } diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala index f43d81e..cdc3e3b 100644 --- a/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala @@ -19,7 +19,7 @@ package org.typelevel.catapult.codec import cats.{Defer, Invariant} import cats.data.* import cats.syntax.all.* -import com.launchdarkly.sdk.{LDValue, LDValueType} +import com.launchdarkly.sdk.{ArrayBuilder, LDValue, LDValueType, ObjectBuilder} import org.typelevel.catapult.codec.LDCodec.LDCodecResult import org.typelevel.catapult.codec.LDReason.{unableToDecodeKey, unableToEncodeKey} @@ -82,6 +82,8 @@ object LDCodec { type LDCodecResult[A] = ValidatedNec[LDCodecFailure, A] + /** Create an instance out of an encoder and decoder function + */ def instance[A]( _encode: (A, LDCursorHistory) => LDCodecResult[LDValue], _decode: LDCursor => LDCodecResult[A], @@ -92,6 +94,28 @@ object LDCodec { override def decode(c: LDCursor): LDCodecResult[A] = _decode(c) } + /** Sugar to make building an instance for the `LDValue` type `ARRAY` less cumbersome + */ + def arrInstance[A]( + _encode: (A, LDCursorHistory) => ArrayBuilder => LDCodecResult[ArrayBuilder], + _decode: LDCursor.LDArrayCursor => LDCodecResult[A], + ): LDCodec[A] = + instance( + (value, history) => _encode(value, history)(LDValue.buildArray()).map(_.build()), + cursor => _decode(cursor.asArray), + ) + + /** Sugar to make building an instance for the `LDValue` type `OBJECT` less cumbersome + */ + def objInstance[A]( + _encode: (A, LDCursorHistory) => ObjectBuilder => LDCodecResult[ObjectBuilder], + _decode: LDCursor.LDObjectCursor => LDCodecResult[A], + ): LDCodec[A] = + instance( + (value, history) => _encode(value, history)(LDValue.buildObject()).map(_.build()), + cursor => _decode(cursor.asObject), + ) + final class DecodingFailure(val failures: NonEmptyChain[LDCodecFailure]) extends IllegalArgumentException { override def getMessage: String = @@ -165,7 +189,7 @@ object LDCodec { } override def decode(c: LDCursor): LDCodecResult[N] = - c.checkType(LDValueType.NUMBER).map(_.value.doubleValue()).andThen { d => + c.checkType(LDValueType.NUMBER).as[Double].andThen { d => val n = fromDouble(d) Validated.condNec( toDouble(n) == d, @@ -176,23 +200,23 @@ object LDCodec { } implicit val ldValueInstance: LDCodec[LDValue] = - instance((value, _) => value.valid, _.value.valid) + instance((value, _) => value.valid, _.value) implicit val booleanInstance: LDCodec[Boolean] = instance( (value, _) => LDValue.of(value).valid, - _.checkType(LDValueType.BOOLEAN).map(_.value.booleanValue()), + _.checkType(LDValueType.BOOLEAN).value.map(_.booleanValue()), ) implicit val stringInstance: LDCodec[String] = instance( (value, _) => LDValue.of(value).valid, - _.checkType(LDValueType.STRING).map(_.value.stringValue()), + _.checkType(LDValueType.STRING).value.map(_.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] = instance( (value, _) => LDValue.of(value).valid, - _.checkType(LDValueType.NUMBER).map(_.value.doubleValue()), + _.checkType(LDValueType.NUMBER).value.map(_.doubleValue()), ) implicit val floatInstance: LDCodec[Float] = numericInstance("Float", _.toDouble, _.toFloat) @@ -203,7 +227,7 @@ object LDCodec { implicit val noneInstance: LDCodec[None.type] = instance( (_, _) => LDValue.ofNull().valid, - _.checkType(LDValueType.NULL).as(None), + _.checkType(LDValueType.NULL).value.as(None), ) implicit def decodeSome[A, C[_] <: LDCodec[?]](implicit CA: C[A], I: Invariant[C]): C[Some[A]] = @@ -218,13 +242,13 @@ object LDCodec { private def decodeIterableShaped[CC, A](factory: Factory[A, CC])(cursor: LDCursor)(implicit CA: LDCodec[A] - ): LDCodecResult[CC] = cursor.checkType(LDValueType.ARRAY).andThen { c => + ): LDCodecResult[CC] = cursor.checkType(LDValueType.ARRAY).value.andThen { value => val builder = factory.newBuilder val failures = Vector.newBuilder[LDCodecFailure] - builder.sizeHint(c.value.size()) + builder.sizeHint(value.size()) var idx = 0 - c.value.values().forEach { ldValue => - CA.decode(LDCursor.of(LDValue.normalize(ldValue), c.history.at(idx))) match { + value.values().forEach { ldValue => + LDCursor.of(LDValue.normalize(ldValue), cursor.history.at(idx)).as(CA) match { case Validated.Invalid(e) => failures.addAll(e.iterator) case Validated.Valid(value) => builder.addOne(value) } @@ -307,12 +331,12 @@ object LDCodec { 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 => + ): LDCodecResult[CC] = cursor.checkType(LDValueType.OBJECT).value.andThen { value => 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) + builder.sizeHint(value.size()) + value.keys().forEach { field => + val updatedHistory = cursor.history.at(field) LDKeyCodec[K].decode(field) match { case Validated.Invalid(reasons) => failures.addAll { @@ -321,9 +345,7 @@ object LDCodec { }.iterator } case Validated.Valid(key) => - LDCodec[V].decode( - LDCursor.of(LDValue.normalize(c.value.get(field)), updatedHistory) - ) match { + LDCursor.of(LDValue.normalize(value.get(field)), updatedHistory).as(CV) match { case Validated.Invalid(e) => failures.addAll(e.iterator) case Validated.Valid(value) => builder.addOne(key -> value) } @@ -346,7 +368,7 @@ object LDCodec { val builder = LDValue.buildObject() val failures = Vector.newBuilder[LDCodecFailure] toIterator(cc).foreach { case (k, v) => - LDKeyCodec[K].encode(k) match { + CK.encode(k) match { case Validated.Invalid(reasons) => failures.addAll { reasons.map { reason => diff --git a/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala index 8df7d4a..c01fb69 100644 --- a/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala +++ b/core/src/main/scala/org/typelevel/catapult/codec/LDCursor.scala @@ -17,7 +17,7 @@ package org.typelevel.catapult.codec import cats.Show -import cats.data.ValidatedNec +import cats.data.{Chain, NonEmptyChain, ValidatedNec} import cats.kernel.Hash import cats.syntax.all.* import com.launchdarkly.sdk.{LDValue, LDValueType} @@ -32,11 +32,11 @@ sealed trait LDCursor { /** The current value pointed to by the cursor. * - * @note This is guaranteed to be non-null + * @note This is guaranteed to be non-null, but may be `LDValue.ofNull` */ - def value: LDValue + def value: ValidatedNec[LDCodecFailure, LDValue] - def valueType: LDValueType = value.getType + def valueType: LDValueType = value.fold(_ => LDValueType.NULL, _.getType) /** The path to the current value */ @@ -51,18 +51,19 @@ sealed trait LDCursor { /** Ensure the type of `value` matches the expected `LDValueType` * * @see [[asArray]] if the expected type is `ARRAY` + * @see [[asObject]] if the expected type is `OBJECT` */ - def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] + def checkType(expected: LDValueType): LDCursor /** Ensure the type of `value` is `ARRAY` and return an `LDCursor` * specialized to working with `LDValue` arrays */ - def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] + def asArray: LDArrayCursor /** Ensure the type of `value` is `OBJECT` and return an `LDCursor` * specialized to working with `LDValue` objects */ - def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] + def asObject: LDObjectCursor override def toString: String = LDCursor.show.show(this) @@ -81,7 +82,13 @@ object 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)) + implicit val hash: Hash[LDCursor] = Hash.by { c => + ( + c.value.getOrElse(LDValue.ofNull()), + c.value.fold(_.toChain, _ => Chain.empty), + c.history, + ) + } /** An [[LDCursor]] that is specialized to work with `LDValue` arrays */ @@ -91,14 +98,14 @@ object LDCursor { * * @note Bounds checking will be done on `index` */ - def at(index: Int): ValidatedNec[LDCodecFailure, LDCursor] + def at(index: Int): 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]) + at(index).as[A] } /** An [[LDCursor]] that is specialized to work with `LDValue` objects @@ -107,88 +114,121 @@ object LDCursor { /** Descend to value at the given field */ - def at(field: String): ValidatedNec[LDCodecFailure, LDCursor] + def at(field: String): 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]) + at(field).as[A] } - private final class Impl(override val value: LDValue, override val history: LDCursorHistory) + private final class Impl(ldValue: 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 { + override def value: ValidatedNec[LDCodecFailure, LDValue] = ldValue.valid + + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(ldValue) + + override def checkType(expected: LDValueType): LDCursor = + ldValue.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 + FailedCursor.one(wrongType(expected, ldValue.getType), history) + case LDValueType.ARRAY => new ArrayCursorImpl(ldValue, history) + case LDValueType.OBJECT => new ObjectCursorImpl(ldValue, history) + case _ => this } - 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 asArray: LDArrayCursor = + if (ldValue.getType == LDValueType.ARRAY) new ArrayCursorImpl(ldValue, history) + else + FailedCursor.one(wrongType(LDValueType.ARRAY, ldValue.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) + override def asObject: LDObjectCursor = + if (ldValue.getType == LDValueType.OBJECT) new ObjectCursorImpl(ldValue, history) + else FailedCursor.one(wrongType(LDValueType.OBJECT, ldValue.getType), history) + } + + private final class FailedCursor(failures: NonEmptyChain[LDCodecFailure]) + extends LDCursor + with LDArrayCursor + with LDObjectCursor { + override def value: ValidatedNec[LDCodecFailure, LDValue] = failures.invalid + + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = failures.invalid + + override def checkType(expected: LDValueType): LDCursor = this + + override def asArray: LDArrayCursor = this + + override def asObject: LDObjectCursor = this + + override def at(index: Int): LDCursor = this + + override def at(field: String): LDCursor = this + + override def history: LDCursorHistory = failures.head.history + } + private object FailedCursor { + def one(reason: LDReason, history: LDCursorHistory): FailedCursor = + new FailedCursor(NonEmptyChain.one(LDCodecFailure(reason, history))) } private final class ArrayCursorImpl( - override val value: LDValue, + ldValue: LDValue, override val history: LDCursorHistory, ) extends LDArrayCursor { - override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(this) + override def value: ValidatedNec[LDCodecFailure, LDValue] = ldValue.valid + + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(ldValue) - override def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] = - if (expected == LDValueType.ARRAY) this.valid - else LDCodecFailure.failed(wrongType(expected, value.getType), history) + override def checkType(expected: LDValueType): LDCursor = + if (expected == LDValueType.ARRAY) this + else FailedCursor.one(wrongType(expected, ldValue.getType), history) - override def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] = - LDCodecFailure.failed(wrongType(LDValueType.OBJECT, value.getType), history) + override def asObject: LDObjectCursor = + FailedCursor.one(wrongType(LDValueType.OBJECT, ldValue.getType), history) - override def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] = this.valid + override def asArray: LDArrayCursor = this - override def at(index: Int): ValidatedNec[LDCodecFailure, LDCursor] = { + override def at(index: Int): 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) + if (index >= 0 && index < ldValue.size()) + new Impl(LDValue.normalize(ldValue.get(index)), updatedHistory) + else FailedCursor.one(IndexOutOfBounds, updatedHistory) } } private final class ObjectCursorImpl( - override val value: LDValue, + ldValue: LDValue, override val history: LDCursorHistory, ) extends LDObjectCursor { - override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(this) + override def value: ValidatedNec[LDCodecFailure, LDValue] = ldValue.valid + + override def as[A: LDCodec]: ValidatedNec[LDCodecFailure, A] = LDCodec[A].decode(ldValue) - override def checkType(expected: LDValueType): ValidatedNec[LDCodecFailure, LDCursor] = - if (expected == LDValueType.OBJECT) this.valid - else LDCodecFailure.failed(wrongType(expected, value.getType), history) + override def checkType(expected: LDValueType): LDCursor = + if (expected == LDValueType.OBJECT) this + else FailedCursor.one(wrongType(expected, ldValue.getType), history) - override def asObject: ValidatedNec[LDCodecFailure, LDObjectCursor] = this.valid + override def asObject: LDObjectCursor = this - override def asArray: ValidatedNec[LDCodecFailure, LDArrayCursor] = - LDCodecFailure.failed(wrongType(LDValueType.ARRAY, value.getType), history) + override def asArray: LDArrayCursor = + FailedCursor.one(wrongType(LDValueType.ARRAY, ldValue.getType), history) - override def at(field: String): ValidatedNec[LDCodecFailure, LDCursor] = { + override def at(field: String): LDCursor = { val updatedHistory = history.at(field) - val result = LDValue.normalize(value.get(field)) - if (!result.isNull) new Impl(result, updatedHistory).valid + val result = LDValue.normalize(ldValue.get(field)) + if (!result.isNull) new Impl(result, updatedHistory) else { // LDValue.get returns null when a field is missing, we can do better var found = false - value.keys().iterator().forEachRemaining { key => + ldValue.keys().iterator().forEachRemaining { key => if (key == field) { found = true } } - if (found) new Impl(result, updatedHistory).valid - else LDCodecFailure.failed(missingField, updatedHistory) + if (found) new Impl(result, updatedHistory) + else FailedCursor.one(missingField, updatedHistory) } } } diff --git a/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala index 41a3edc..6ee8ff2 100644 --- a/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala +++ b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala @@ -154,20 +154,13 @@ object FeatureKeyTests extends SimpleIOSuite { final case class Foo(a: String, b: Boolean) testClient.use { case (td, client) => - implicit val codec: LDCodec[Foo] = LDCodec.instance[Foo]( - (foo, _) => - LDValue - .buildObject() - .put("a", foo.a) - .put("b", foo.b) - .build - .valid, - _.asObject.andThen { obj => + implicit val codec: LDCodec[Foo] = LDCodec.objInstance[Foo]( + (foo, _) => _.put("a", foo.a).put("b", foo.b).valid, + obj => ( - obj.at("a").andThen(_.as[String]), - obj.at("b").andThen(_.as[Boolean]), - ).mapN(Foo) - }, + obj.at("a").as[String], + obj.at("b").as[Boolean], + ).mapN(Foo(_, _)), ) val defaultFoo = Foo(a = "hi", b = false) for { From 7f6bd1e242cfc69fb7528afbc0c4590bce33a69f Mon Sep 17 00:00:00 2001 From: Morgen Peschke Date: Thu, 8 Oct 2026 08:43:01 -0700 Subject: [PATCH 6/6] Prefer *Unsafe over *OrDie --- .../org/typelevel/catapult/FeatureKey.scala | 26 +++++++++---------- .../typelevel/catapult/FeatureKeyTests.scala | 14 +++++----- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala b/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala index 49df6c7..b8c1a93 100644 --- a/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala +++ b/core/src/main/scala/org/typelevel/catapult/FeatureKey.scala @@ -68,7 +68,7 @@ object FeatureKey { * @throws InvalidDefault * when `default` cannot be encoded in an `LDValue` */ - def instanceOrDie[A: LDCodec](key: String, default: A): FeatureKey.Aux[A] = + def instanceUnsafe[A: LDCodec](key: String, default: A): FeatureKey.Aux[A] = instance(key, default).valueOr(errors => throw InvalidDefault(key, default, errors)) /** Define a feature key that is expected to return a boolean value. @@ -81,10 +81,10 @@ object FeatureKey { instance[Boolean](key, default) /** @see [[FeatureKey.bool]] - * @see [[FeatureKey.instanceOrDie]] + * @see [[FeatureKey.instanceUnsafe]] */ - def boolOrDie(key: String, default: Boolean): FeatureKey.Aux[Boolean] = - instanceOrDie[Boolean](key, default) + def boolUnsafe(key: String, default: Boolean): FeatureKey.Aux[Boolean] = + instanceUnsafe[Boolean](key, default) /** Define a feature key that is expected to return a string value. * @param key @@ -96,10 +96,10 @@ object FeatureKey { instance[String](key, default) /** @see [[FeatureKey.string]] - * @see [[FeatureKey.instanceOrDie]] + * @see [[FeatureKey.instanceUnsafe]] */ - def stringOrDie(key: String, default: String): FeatureKey.Aux[String] = - instanceOrDie[String](key, default) + def stringUnsafe(key: String, default: String): FeatureKey.Aux[String] = + instanceUnsafe[String](key, default) /** Define a feature key that is expected to return a integer value. * @param key @@ -111,10 +111,10 @@ object FeatureKey { instance[Int](key, default) /** @see [[FeatureKey.int]] - * @see [[FeatureKey.instanceOrDie]] + * @see [[FeatureKey.instanceUnsafe]] */ - def intOrDie(key: String, default: Int): FeatureKey.Aux[Int] = - instanceOrDie[Int](key, default) + def intUnsafe(key: String, default: Int): FeatureKey.Aux[Int] = + instanceUnsafe[Int](key, default) /** Define a feature key that is expected to return a double value. * @param key @@ -126,10 +126,10 @@ object FeatureKey { instance[Double](key, default) /** @see [[FeatureKey.double]] - * @see [[FeatureKey.instanceOrDie]] + * @see [[FeatureKey.instanceUnsafe]] */ - def doubleOrDie(key: String, default: Double): FeatureKey.Aux[Double] = - instanceOrDie[Double](key, default) + def doubleUnsafe(key: String, default: Double): FeatureKey.Aux[Double] = + instanceUnsafe[Double](key, default) /** Define a feature key that is expected to return a JSON value. * diff --git a/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala index 6ee8ff2..6c45b65 100644 --- a/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala +++ b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala @@ -29,7 +29,7 @@ object FeatureKeyTests extends SimpleIOSuite { test("serve boolean variations through FeatureFlag") { testClient.use { case (td, client) => for { - fk <- IO(FeatureKey.boolOrDie("test", true)) + fk <- IO(FeatureKey.boolUnsafe("test", true)) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(false)))) notDefault <- client.variation(fk, ctx) @@ -40,7 +40,7 @@ object FeatureKeyTests extends SimpleIOSuite { test("serve string variations through FeatureFlag") { testClient.use { case (td, client) => for { - fk <- IO(FeatureKey.stringOrDie("test", "default")) + fk <- IO(FeatureKey.stringUnsafe("test", "default")) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of("not-default")))) notDefault <- client.variation(fk, ctx) @@ -51,7 +51,7 @@ object FeatureKeyTests extends SimpleIOSuite { test("serve int variations through FeatureFlag") { testClient.use { case (td, client) => for { - fk <- IO(FeatureKey.intOrDie("test", 10)) + fk <- IO(FeatureKey.intUnsafe("test", 10)) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(-10)))) notDefault <- client.variation(fk, ctx) @@ -62,7 +62,7 @@ object FeatureKeyTests extends SimpleIOSuite { test("serve double variations through FeatureFlag") { testClient.use { case (td, client) => for { - fk <- IO(FeatureKey.doubleOrDie("test", 2d)) + fk <- IO(FeatureKey.doubleUnsafe("test", 2d)) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll(LDValue.of(-2d)))) notDefault <- client.variation(fk, ctx) @@ -87,7 +87,7 @@ object FeatureKeyTests extends SimpleIOSuite { val arrayOfOne = Vector(1) def arrayOfMany = Vector(1, 10) for { - fk <- IO(FeatureKey.instanceOrDie("test", emptyArray)) + fk <- IO(FeatureKey.instanceUnsafe("test", emptyArray)) default <- client.variation(fk, ctx) _ <- IO( td.update( @@ -127,7 +127,7 @@ object FeatureKeyTests extends SimpleIOSuite { "bar" -> false, ) for { - fk <- IO(FeatureKey.instanceOrDie("test", emptyMap)) + fk <- IO(FeatureKey.instanceUnsafe("test", emptyMap)) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll { LDValue @@ -164,7 +164,7 @@ object FeatureKeyTests extends SimpleIOSuite { ) val defaultFoo = Foo(a = "hi", b = false) for { - fk <- IO(FeatureKey.instanceOrDie("test", defaultFoo)) + fk <- IO(FeatureKey.instanceUnsafe("test", defaultFoo)) default <- client.variation(fk, ctx) _ <- IO(td.update(td.flag(fk.key).valueForAll { LDValue