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..2ea38fd 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 { @@ -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/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..b8c1a93 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 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. * @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.instanceUnsafe]] + */ + 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 @@ -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.instanceUnsafe]] + */ + 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 @@ -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.instanceUnsafe]] + */ + 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 @@ -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.instanceUnsafe]] + */ + 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/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala b/core/src/main/scala/org/typelevel/catapult/codec/LDCodec.scala index bc93b82..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,17 +94,27 @@ 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) + /** 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), + ) - def withInfallibleEncodeFull[A]( - encode: A => LDValue, - decode: LDCursor => LDCodecResult[A], - ): LDCodecWithInfallibleEncode[A] = - LDCodecWithInfallibleEncode.instanceFull(encode, decode) + /** 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 { @@ -116,8 +128,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], @@ -172,14 +182,14 @@ 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), ) } 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, @@ -189,15 +199,35 @@ object LDCodec { } } + implicit val ldValueInstance: LDCodec[LDValue] = + instance((value, _) => value.valid, _.value) + + implicit val booleanInstance: LDCodec[Boolean] = instance( + (value, _) => LDValue.of(value).valid, + _.checkType(LDValueType.BOOLEAN).value.map(_.booleanValue()), + ) + + implicit val stringInstance: LDCodec[String] = instance( + (value, _) => LDValue.of(value).valid, + _.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).value.map(_.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(), - _.checkType(LDValueType.NULL).as(None), + implicit val noneInstance: LDCodec[None.type] = instance( + (_, _) => LDValue.ofNull().valid, + _.checkType(LDValueType.NULL).value.as(None), ) implicit def decodeSome[A, C[_] <: LDCodec[?]](implicit CA: C[A], I: Invariant[C]): C[Some[A]] = @@ -212,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) } @@ -229,21 +259,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 +279,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) @@ -332,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 { @@ -346,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) } @@ -359,24 +356,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], @@ -389,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 => @@ -410,50 +389,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/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)) - } -} 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/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 { 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..6c45b65 --- /dev/null +++ b/testkit/src/test/scala/org/typelevel/catapult/FeatureKeyTests.scala @@ -0,0 +1,186 @@ +/* + * 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.boolUnsafe("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.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) + } yield expect(default === "default") && expect(notDefault === "not-default") + } + } + + test("serve int variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + 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) + } yield expect(default === 10) && expect(notDefault === -10) + } + } + + test("serve double variations through FeatureFlag") { + testClient.use { case (td, client) => + for { + 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) + } 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.instanceUnsafe("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.instanceUnsafe("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.objInstance[Foo]( + (foo, _) => _.put("a", foo.a).put("b", foo.b).valid, + obj => + ( + obj.at("a").as[String], + obj.at("b").as[Boolean], + ).mapN(Foo(_, _)), + ) + val defaultFoo = Foo(a = "hi", b = false) + for { + fk <- IO(FeatureKey.instanceUnsafe("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) + } + } +}