Skip to content

Commit 87c63a3

Browse files
Add a circe module
This provides interop between LDValue and circe.Json, as well as helper methods to retrieve variations that are either JSON or encoded as JSON.
1 parent f3dcb86 commit 87c63a3

File tree

6 files changed

+396
-2
lines changed

6 files changed

+396
-2
lines changed

build.sbt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ ThisBuild / tlSonatypeUseLegacyHost := false
1717
// publish website from this branch
1818
ThisBuild / tlSitePublishBranch := Some("main")
1919

20-
val Scala213 = "2.13.14"
20+
val Scala213 = "2.13.16"
2121
ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.3")
2222
ThisBuild / scalaVersion := Scala213 // the default Scala
2323

24-
lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit)
24+
lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit, circe)
2525

2626
lazy val testkit = crossProject(JVMPlatform)
2727
.crossType(CrossType.Pure)
@@ -64,4 +64,21 @@ lazy val mtl = crossProject(JVMPlatform)
6464
)
6565
.dependsOn(core)
6666

67+
lazy val circe = crossProject(JVMPlatform)
68+
.crossType(CrossType.Pure)
69+
.in(file("circe"))
70+
.settings(
71+
name := "catapult-circe",
72+
libraryDependencies ++= Seq(
73+
"io.circe" %% "circe-core" % "0.14.12",
74+
"io.circe" %% "circe-parser" % "0.14.12",
75+
"org.scalameta" %% "munit-scalacheck" % "1.1.0" % Test,
76+
),
77+
tlVersionIntroduced := Map(
78+
"2.13" -> "0.7.0",
79+
"3" -> "0.7.0",
80+
),
81+
)
82+
.dependsOn(core, mtl)
83+
6784
lazy val docs = project.in(file("site")).enablePlugins(TypelevelSitePlugin)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2022 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.catapult.circe
18+
19+
import com.launchdarkly.sdk.LDValue
20+
import io.circe.syntax.*
21+
import io.circe.{Decoder, Encoder, Json}
22+
import org.typelevel.catapult.FeatureKey
23+
import org.typelevel.catapult.circe.LDValueCodec.*
24+
25+
object CirceFeatureKey {
26+
27+
/** Define a feature key that is expected to return a JSON value.
28+
*
29+
* This uses `circe` encoding for JSON and will fail if the default value
30+
* cannot be represented by LaunchDarkly's `LDValue`
31+
*
32+
* @param key
33+
* the key of the flag
34+
* @param default
35+
* a value to return if the retrieval fails or the type is not expected
36+
*/
37+
def featureKey(
38+
key: String,
39+
default: Json,
40+
): Decoder.Result[FeatureKey.Aux[LDValue]] =
41+
default.as[LDValue].map(FeatureKey.ldValue(key, _))
42+
43+
/** Define a feature key that is expected to return a JSON value.
44+
*
45+
* This uses `circe` encoding for JSON and will fail if the default value
46+
* cannot be represented by LaunchDarkly's `LDValue`
47+
*
48+
* @param key
49+
* the key of the flag
50+
* @param default
51+
* a value to return if the retrieval fails or the type is not expected
52+
*/
53+
def featureKeyEncoded[A: Encoder](
54+
key: String,
55+
default: A,
56+
): Decoder.Result[FeatureKey.Aux[LDValue]] =
57+
featureKey(key, default.asJson)
58+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2022 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.catapult.circe
18+
19+
import cats.syntax.all.*
20+
import com.launchdarkly.sdk.json.{JsonSerialization, SerializationException}
21+
import com.launchdarkly.sdk.{LDValue, LDValueType}
22+
import io.circe.syntax.EncoderOps
23+
import io.circe.{Decoder, DecodingFailure, Encoder, Json}
24+
25+
object LDValueCodec {
26+
27+
/** Decode a `circe` [[Json]] value as a `launchdarkly` [[LDValue]]
28+
*
29+
* The primary failure path is due to the encoding of JSON numbers as doubles
30+
* in the [[LDValue]] type hierarchy
31+
*/
32+
implicit val catapultLDValueDecoder: Decoder[LDValue] = Decoder.instance { cursor =>
33+
cursor.focus.fold(LDValue.ofNull().asRight[DecodingFailure]) { circeValue =>
34+
circeValue.fold(
35+
jsonNull = LDValue.ofNull().asRight,
36+
jsonBoolean = LDValue.of(_).asRight,
37+
jsonNumber = jNumber =>
38+
// This nasty hack is because LDValue number support is lacking.
39+
//
40+
// LDValueNumber encodes everything as Double and that introduces encoding
41+
// issues like negative/positive/unsigned zero and rounding if we try to do
42+
// the conversion ourselves with the raw.
43+
//
44+
// Here, we're basically hoping they've got their house in order and can
45+
// parse a valid JSON number.
46+
Either
47+
.catchOnly[SerializationException] {
48+
LDValue.normalize(
49+
JsonSerialization.deserialize(
50+
Json.fromJsonNumber(jNumber).noSpaces,
51+
classOf[LDValue],
52+
)
53+
)
54+
}
55+
.leftMap { _: SerializationException =>
56+
DecodingFailure("JSON value is not supported by LaunchDarkly LDValue", cursor.history)
57+
},
58+
jsonString = LDValue.of(_).asRight,
59+
jsonArray = _.traverse(catapultLDValueDecoder.decodeJson).map { values =>
60+
val builder = LDValue.buildArray()
61+
values.foreach(builder.add)
62+
builder.build()
63+
},
64+
jsonObject = _.toVector
65+
.traverse { case (key, value) =>
66+
catapultLDValueDecoder.decodeJson(value).tupleLeft(key)
67+
}
68+
.map { entries =>
69+
val builder = LDValue.buildObject()
70+
entries.foreach { case (key, ldValue) =>
71+
builder.put(key, ldValue)
72+
}
73+
builder.build()
74+
},
75+
)
76+
}
77+
}
78+
79+
implicit val catapultLDValueEncoder: Encoder[LDValue] = Encoder.instance { ldValue =>
80+
// So we don't have to deal with JVM nulls
81+
val normalized = LDValue.normalize(ldValue)
82+
normalized.getType match {
83+
case LDValueType.NULL => Json.Null
84+
case LDValueType.BOOLEAN =>
85+
Json.fromBoolean(normalized.booleanValue())
86+
case LDValueType.NUMBER =>
87+
// This is a bit of a hack because LDValue number support is lacking.
88+
//
89+
// LDValueNumber encodes everything as Double and that introduces encoding
90+
// issues like negative/positive/unsigned zero and rounding when we try to do
91+
// the conversion ourselves with the raw.
92+
//
93+
// Here, we're basically deferring to circe for the right thing to do with a
94+
// Double by using the default Encoder[Double] (current behavior is to encode
95+
// invalid values as `null`).
96+
normalized.doubleValue().asJson
97+
case LDValueType.STRING =>
98+
Json.fromString(normalized.stringValue())
99+
case LDValueType.ARRAY =>
100+
normalized.values().iterator()
101+
val builder = Vector.newBuilder[LDValue]
102+
normalized.values().forEach { ldValue =>
103+
builder.addOne(ldValue)
104+
}
105+
Json.fromValues(builder.result().map(catapultLDValueEncoder(_)))
106+
case LDValueType.OBJECT =>
107+
val builder = Vector.newBuilder[(String, LDValue)]
108+
normalized.keys().forEach { key =>
109+
builder.addOne(key -> normalized.get(key))
110+
}
111+
Json.fromFields(builder.result().map { case (key, value) =>
112+
key -> catapultLDValueEncoder(value)
113+
})
114+
}
115+
}
116+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2022 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.catapult.circe.syntax
18+
19+
import cats.MonadThrow
20+
import cats.syntax.all.*
21+
import com.launchdarkly.sdk.LDValue
22+
import io.circe.syntax.*
23+
import io.circe.{Decoder, Json}
24+
import org.typelevel.catapult.{ContextEncoder, FeatureKey, LaunchDarklyClient}
25+
import org.typelevel.catapult.circe.LDValueCodec.*
26+
27+
object client {
28+
implicit final class CatapultLaunchDarklyClientCirceOps[F[_]](
29+
private val client: LaunchDarklyClient[F]
30+
) extends AnyVal {
31+
def circeVariation[Ctx: ContextEncoder](featureKey: String, ctx: Ctx, defaultValue: Json)(
32+
implicit F: MonadThrow[F]
33+
): F[Json] =
34+
defaultValue
35+
.as[LDValue]
36+
.liftTo[F]
37+
.flatMap(client.jsonValueVariation(featureKey, ctx, _))
38+
.map(_.asJson)
39+
40+
def circeVariation[Ctx: ContextEncoder](featureKey: FeatureKey.Aux[LDValue], ctx: Ctx)(implicit
41+
F: MonadThrow[F]
42+
): F[Json] =
43+
client.variation(featureKey, ctx).map(_.asJson)
44+
45+
def circeVariationAs[A: Decoder, Ctx: ContextEncoder](
46+
featureKey: String,
47+
ctx: Ctx,
48+
defaultValue: Json,
49+
)(implicit F: MonadThrow[F]): F[A] =
50+
defaultValue
51+
.as[LDValue]
52+
.liftTo[F]
53+
.flatMap(client.jsonValueVariation(featureKey, ctx, _))
54+
.flatMap(_.asJson.as[A].liftTo[F])
55+
56+
def circeVariationAs[A: Decoder, Ctx: ContextEncoder](
57+
featureKey: FeatureKey.Aux[LDValue],
58+
ctx: Ctx,
59+
)(implicit F: MonadThrow[F]): F[A] =
60+
client.variation(featureKey, ctx).flatMap(_.asJson.as[A].liftTo[F])
61+
}
62+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2022 Typelevel
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.typelevel.catapult.circe.syntax
18+
19+
import cats.MonadThrow
20+
import cats.syntax.all.*
21+
import com.launchdarkly.sdk.LDValue
22+
import io.circe.syntax.*
23+
import io.circe.{Decoder, Json}
24+
import org.typelevel.catapult.FeatureKey
25+
import org.typelevel.catapult.circe.LDValueCodec.*
26+
import org.typelevel.catapult.mtl.LaunchDarklyMTLClient
27+
28+
object mtlClient {
29+
implicit final class CatapultLaunchDarklyMTLClientCirceOps[F[_]](
30+
private val client: LaunchDarklyMTLClient[F]
31+
) extends AnyVal {
32+
def circeVariation(featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F]): F[Json] =
33+
defaultValue
34+
.as[LDValue]
35+
.liftTo[F]
36+
.flatMap(client.jsonValueVariation(featureKey, _))
37+
.map(_.asJson)
38+
39+
def circeVariation(featureKey: FeatureKey.Aux[LDValue])(implicit F: MonadThrow[F]): F[Json] =
40+
client.variation(featureKey).map(_.asJson)
41+
42+
def circeVariationAs[A: Decoder](featureKey: String, defaultValue: Json)(implicit
43+
F: MonadThrow[F]
44+
): F[A] =
45+
defaultValue
46+
.as[LDValue]
47+
.liftTo[F]
48+
.flatMap(client.jsonValueVariation(featureKey, _))
49+
.flatMap(_.asJson.as[A].liftTo[F])
50+
51+
def circeVariationAs[A: Decoder](featureKey: FeatureKey.Aux[LDValue])(implicit
52+
F: MonadThrow[F]
53+
): F[A] =
54+
client.variation(featureKey).flatMap(_.asJson.as[A].liftTo[F])
55+
}
56+
}

0 commit comments

Comments
 (0)