Skip to content

Commit 97dd41e

Browse files
authored
Merge pull request #171 from morgen-peschke/feature-key
Add FeatureKey
2 parents bc0b8f9 + 70acaa7 commit 97dd41e

File tree

19 files changed

+1759
-12
lines changed

19 files changed

+1759
-12
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ jobs:
7575

7676
- name: Make target directories
7777
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
78-
run: mkdir -p core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target
78+
run: mkdir -p circe/.jvm/target core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target
7979

8080
- name: Compress target directories
8181
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
82-
run: tar cf targets.tar core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target
82+
run: tar cf targets.tar circe/.jvm/target core/.jvm/target mtl/.jvm/target testkit/.jvm/target project/target
8383

8484
- name: Upload target directories
8585
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')

build.sbt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway
2-
ThisBuild / tlBaseVersion := "0.6" // your current series x.y
2+
ThisBuild / tlBaseVersion := "0.7" // your current series x.y
33

44
ThisBuild / organization := "org.typelevel"
55
ThisBuild / organizationName := "Typelevel"
@@ -18,7 +18,7 @@ val Scala213 = "2.13.16"
1818
ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.6")
1919
ThisBuild / scalaVersion := Scala213 // the default Scala
2020

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

2323
lazy val testkit = crossProject(JVMPlatform)
2424
.crossType(CrossType.Pure)
@@ -61,4 +61,21 @@ lazy val mtl = crossProject(JVMPlatform)
6161
)
6262
.dependsOn(core)
6363

64+
lazy val circe = crossProject(JVMPlatform)
65+
.crossType(CrossType.Pure)
66+
.in(file("circe"))
67+
.settings(
68+
name := "catapult-circe",
69+
libraryDependencies ++= Seq(
70+
"io.circe" %% "circe-core" % "0.14.12",
71+
"io.circe" %% "circe-parser" % "0.14.12",
72+
"org.scalameta" %% "munit-scalacheck" % "1.1.0" % Test,
73+
),
74+
tlVersionIntroduced := Map(
75+
"2.13" -> "0.7.0",
76+
"3" -> "0.7.0",
77+
),
78+
)
79+
.dependsOn(core, mtl)
80+
6481
lazy val docs = project.in(file("site")).enablePlugins(TypelevelSitePlugin)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 io.circe.{Decoder, Encoder, Json}
20+
import org.typelevel.catapult.FeatureKey
21+
import org.typelevel.catapult.circe.JsonLDCodec.circeLDCodecForJSON
22+
import org.typelevel.catapult.codec.LDCodec
23+
import org.typelevel.catapult.codec.LDCodec.LDCodecResult
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+
): LDCodecResult[FeatureKey.Aux[Json]] =
41+
FeatureKey.instanceOrFailure(key, default)
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: Decoder](
54+
key: String,
55+
default: A,
56+
): LDCodecResult[FeatureKey.Aux[A]] = {
57+
implicit val ldCodec: LDCodec[A] = JsonLDCodec.ldCodecFromCirceCodec[A]
58+
FeatureKey.instanceOrFailure(key, default)
59+
}
60+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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.Defer
20+
import cats.data.{Chain, NonEmptyChain, Validated}
21+
import cats.syntax.all.*
22+
import com.launchdarkly.sdk.json.{JsonSerialization, SerializationException}
23+
import com.launchdarkly.sdk.{LDValue, LDValueType}
24+
import io.circe.*
25+
import org.typelevel.catapult.codec.*
26+
import org.typelevel.catapult.codec.LDCodec.LDCodecResult
27+
import org.typelevel.catapult.codec.LDCursorHistory.Move
28+
import org.typelevel.catapult.codec.syntax.*
29+
30+
object JsonLDCodec {
31+
implicit val circeLDCodecForJSON: LDCodec[Json] =
32+
JsonLDCodecImplementation.jsonLDCodecRetainingNumberErrors
33+
34+
def ldCodecFromCirceCodec[A: Encoder: Decoder]: LDCodec[A] =
35+
circeLDCodecForJSON.imapVFull[A](
36+
(a, _) => Encoder[A].apply(a).valid,
37+
(json, history) =>
38+
Decoder[A].decodeAccumulating(json.hcursor).leftMap { errors =>
39+
NonEmptyChain
40+
.fromNonEmptyList(errors)
41+
.map(JsonLDCodecImplementation.convertCirceFailureToLDFailure)
42+
.map(lcf => lcf.updateHistory(history.combine(_)))
43+
},
44+
)
45+
}
46+
47+
// This is separate to keep the implicits from colliding during construction
48+
private object JsonLDCodecImplementation {
49+
val jsonLDCodecRetainingNumberErrors: LDCodec[Json] = Defer[LDCodec].fix { implicit recurse =>
50+
LDCodec.instance(
51+
_encode = (json, history) =>
52+
json.fold[LDCodecResult[LDValue]](
53+
jsonNull = LDValue.ofNull().validNec,
54+
jsonBoolean = _.asLDValue.valid,
55+
jsonNumber = _.asLDValueOrFailure(history),
56+
jsonString = _.asLDValue.valid,
57+
jsonArray = _.asLDValueOrFailure(history),
58+
jsonObject = _.toIterable.asLDValueOrFailure(history),
59+
),
60+
_decode = cursor =>
61+
cursor.valueType match {
62+
case LDValueType.NULL => Json.Null.valid
63+
case LDValueType.BOOLEAN => cursor.as[Boolean].map(Json.fromBoolean)
64+
case LDValueType.NUMBER => cursor.as[JsonNumber].map(Json.fromJsonNumber)
65+
case LDValueType.STRING => cursor.as[String].map(Json.fromString)
66+
case LDValueType.ARRAY => cursor.as[Iterable[Json]].map(Json.fromValues)
67+
case LDValueType.OBJECT => cursor.as[Vector[(String, Json)]].map(Json.fromFields(_))
68+
},
69+
)
70+
}
71+
72+
def convertCirceFailureToLDFailure(decodingFailure: DecodingFailure): LDCodecFailure =
73+
LDCodecFailure(
74+
decodingFailure.reason match {
75+
case DecodingFailure.Reason.CustomReason(message) => LDReason.other(message)
76+
case DecodingFailure.Reason.MissingField => LDReason.missingField
77+
case DecodingFailure.Reason.WrongTypeExpectation(expectedJsonFieldType, jsonValue) =>
78+
LDReason.wrongType(
79+
expectedJsonFieldType,
80+
jsonValue.fold(
81+
jsonNull = LDValueType.NULL,
82+
jsonBoolean = _ => LDValueType.BOOLEAN,
83+
jsonNumber = _ => LDValueType.NUMBER,
84+
jsonString = _ => LDValueType.STRING,
85+
jsonArray = _ => LDValueType.ARRAY,
86+
jsonObject = _ => LDValueType.OBJECT,
87+
),
88+
)
89+
},
90+
LDCursorHistory.of(Chain.fromSeq(decodingFailure.history).mapFilter {
91+
case CursorOp.DownField(k) => Move(k).some
92+
case CursorOp.DownN(n) => Move(n).some
93+
case _ => none
94+
}),
95+
)
96+
97+
implicit private val jNumberCodec: LDCodec[JsonNumber] = LDCodec.instance(
98+
(jNumber, history) =>
99+
Validated
100+
.catchOnly[SerializationException] {
101+
// This nasty hack is because LDValue number support is lacking.
102+
//
103+
// LDValueNumber encodes everything as Double and that introduces encoding
104+
// issues like negative/positive/unsigned zero and rounding if we try to do
105+
// the conversion ourselves with the raw.
106+
//
107+
// Here, we're basically hoping they've got their house in order and can
108+
// parse a valid JSON number.
109+
LDValue.normalize(
110+
JsonSerialization.deserialize(
111+
Json.fromJsonNumber(jNumber).noSpaces,
112+
classOf[LDValue],
113+
)
114+
)
115+
}
116+
.leftMap { (_: SerializationException) =>
117+
LDCodecFailure(LDReason.unencodableValue(LDValueType.NUMBER, "JNumber"), history)
118+
}
119+
.toValidatedNec,
120+
_.checkType(LDValueType.NUMBER).andThen { c =>
121+
// Less of a hack on the encoding side, because circe can handle (most) doubles.
122+
// The JVM double doesn't map cleanly to the JSON number, so this can fail as well.
123+
LDCodec[Double]
124+
.decode(c)
125+
.map(Json.fromDouble(_).flatMap(_.asNumber))
126+
.andThen(_.toValidNec {
127+
c.fail(LDReason.undecodableValue(LDValueType.NUMBER, "JNumber"))
128+
})
129+
},
130+
)
131+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 io.circe.syntax.*
22+
import io.circe.{Decoder, Encoder, Json}
23+
import org.typelevel.catapult.circe.JsonLDCodec.*
24+
import org.typelevel.catapult.codec.LDCursorHistory
25+
import org.typelevel.catapult.codec.syntax.*
26+
import org.typelevel.catapult.{ContextEncoder, LaunchDarklyClient}
27+
28+
object client {
29+
implicit final class CatapultLaunchDarklyClientCirceOps[F[_]](
30+
private val client: LaunchDarklyClient[F]
31+
) extends AnyVal {
32+
def circeVariation[Ctx: ContextEncoder](featureKey: String, ctx: Ctx, defaultValue: Json)(
33+
implicit F: MonadThrow[F]
34+
): F[Json] =
35+
defaultValue
36+
.asLDValueOrFailure(LDCursorHistory.root)
37+
.asEncodingFailure
38+
.liftTo[F]
39+
.flatMap(client.jsonValueVariation(featureKey, ctx, _))
40+
.flatMap(_.decode[Json].asDecodingFailure.liftTo[F])
41+
42+
def circeVariationAs[A: Decoder: Encoder, Ctx: ContextEncoder](
43+
featureKey: String,
44+
ctx: Ctx,
45+
defaultValue: A,
46+
)(implicit F: MonadThrow[F]): F[A] =
47+
circeVariation[Ctx](featureKey, ctx, defaultValue.asJson)
48+
.flatMap(_.as[A].liftTo[F])
49+
}
50+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 io.circe.syntax.*
22+
import io.circe.{Decoder, Encoder, Json}
23+
import org.typelevel.catapult.circe.JsonLDCodec.*
24+
import org.typelevel.catapult.codec.LDCursorHistory
25+
import org.typelevel.catapult.codec.syntax.*
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+
.asLDValueOrFailure(LDCursorHistory.root)
35+
.asEncodingFailure
36+
.liftTo[F]
37+
.flatMap(client.jsonValueVariation(featureKey, _))
38+
.flatMap(_.decode[Json].asDecodingFailure.liftTo[F])
39+
40+
def circeVariationAs[A: Decoder: Encoder](featureKey: String, defaultValue: A)(implicit
41+
F: MonadThrow[F]
42+
): F[A] =
43+
circeVariation(featureKey, defaultValue.asJson)
44+
.flatMap(_.as[A].liftTo[F])
45+
}
46+
}

0 commit comments

Comments
 (0)