Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f3dcb86
Add FeatureKey
morgen-peschke Apr 1, 2025
87c63a3
Add a circe module
morgen-peschke Apr 1, 2025
ee85414
Regenerate ci.yml
morgen-peschke Apr 1, 2025
5433f6e
Merge origin/main
morgen-peschke Apr 7, 2025
6eabb5d
Make Scala 3 happy
morgen-peschke Apr 7, 2025
a45691d
Bump version because of new methods
morgen-peschke Apr 7, 2025
586124c
Make scaladoc happy
morgen-peschke Apr 7, 2025
bddc64f
Merge branch 'main' into feature-key
zarthross Apr 11, 2025
c211f89
Fix scaladoc
morgen-peschke Apr 11, 2025
adee340
Merge origin/main
morgen-peschke Apr 11, 2025
92791ea
I think I dislike scaladoc
morgen-peschke Apr 11, 2025
133c6d1
Switch to to using codec for implementation
morgen-peschke Apr 22, 2025
2f5eb79
Add headers
morgen-peschke Apr 22, 2025
4f70db2
Work around Java interop issue
morgen-peschke Apr 22, 2025
450aa66
Add documentation to LDCodec and friends
morgen-peschke Apr 23, 2025
5b325cb
Update circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec…
morgen-peschke May 29, 2025
8c577e7
Update circe/src/main/scala/org.typelevel/catapult/circe/LDValueCodec…
morgen-peschke May 29, 2025
34075db
Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/clien…
morgen-peschke May 29, 2025
fcee09f
Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/clien…
morgen-peschke May 29, 2025
85c5b0c
Update circe/src/main/scala/org.typelevel/catapult/circe/CirceFeature…
morgen-peschke May 29, 2025
0f150d6
Update circe/src/main/scala/org.typelevel/catapult/circe/CirceFeature…
morgen-peschke May 29, 2025
7ad5039
Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlCl…
morgen-peschke May 29, 2025
c7cfc05
Update circe/src/main/scala/org/typelevel/catapult/circe/syntax/mtlCl…
morgen-peschke May 29, 2025
70acaa7
Allow encoding to LDValue to fail
morgen-peschke Jun 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ jobs:

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

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

- name: Upload target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
Expand Down
21 changes: 19 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway
ThisBuild / tlBaseVersion := "0.6" // your current series x.y
ThisBuild / tlBaseVersion := "0.7" // your current series x.y

ThisBuild / organization := "org.typelevel"
ThisBuild / organizationName := "Typelevel"
Expand All @@ -21,7 +21,7 @@ val Scala213 = "2.13.16"
ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.3")
ThisBuild / scalaVersion := Scala213 // the default Scala

lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit)
lazy val root = tlCrossRootProject.aggregate(core, mtl, testkit, circe)

lazy val testkit = crossProject(JVMPlatform)
.crossType(CrossType.Pure)
Expand Down Expand Up @@ -64,4 +64,21 @@ lazy val mtl = crossProject(JVMPlatform)
)
.dependsOn(core)

lazy val circe = crossProject(JVMPlatform)
.crossType(CrossType.Pure)
.in(file("circe"))
.settings(
name := "catapult-circe",
libraryDependencies ++= Seq(
"io.circe" %% "circe-core" % "0.14.12",
"io.circe" %% "circe-parser" % "0.14.12",
"org.scalameta" %% "munit-scalacheck" % "1.1.0" % Test,
),
tlVersionIntroduced := Map(
"2.13" -> "0.7.0",
"3" -> "0.7.0",
),
)
.dependsOn(core, mtl)

lazy val docs = project.in(file("site")).enablePlugins(TypelevelSitePlugin)
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.catapult.circe

import io.circe.{Decoder, Encoder, Json}
import org.typelevel.catapult.FeatureKey
import org.typelevel.catapult.circe.JsonLDCodec.circeLDCodecForJSON
import org.typelevel.catapult.codec.LDCodec
import org.typelevel.catapult.codec.LDCodec.LDCodecResult

object CirceFeatureKey {

/** Define a feature key that is expected to return a JSON value.
*
* This uses `circe` encoding for JSON and will fail if the default value
* cannot be represented by LaunchDarkly's `LDValue`
*
* @param key
* the key of the flag
* @param default
* a value to return if the retrieval fails or the type is not expected
*/
def featureKey(
key: String,
default: Json,
): LDCodecResult[FeatureKey.Aux[Json]] =
FeatureKey.instanceOrFailure(key, default)

/** Define a feature key that is expected to return a JSON value.
*
* This uses `circe` encoding for JSON and will fail if the default value
* cannot be represented by LaunchDarkly's `LDValue`
*
* @param key
* the key of the flag
* @param default
* a value to return if the retrieval fails or the type is not expected
*/
def featureKeyEncoded[A: Encoder: Decoder](
key: String,
default: A,
): LDCodecResult[FeatureKey.Aux[A]] = {
implicit val ldCodec: LDCodec[A] = JsonLDCodec.ldCodecFromCirceCodec[A]
FeatureKey.instanceOrFailure(key, default)
}
}
131 changes: 131 additions & 0 deletions circe/src/main/scala/org.typelevel/catapult/circe/JsonLDCodec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.catapult.circe

import cats.Defer
import cats.data.{Chain, NonEmptyChain, Validated}
import cats.syntax.all.*
import com.launchdarkly.sdk.json.{JsonSerialization, SerializationException}
import com.launchdarkly.sdk.{LDValue, LDValueType}
import io.circe.*
import org.typelevel.catapult.codec.*
import org.typelevel.catapult.codec.LDCodec.LDCodecResult
import org.typelevel.catapult.codec.LDCursorHistory.Move
import org.typelevel.catapult.codec.syntax.*

object JsonLDCodec {
implicit val circeLDCodecForJSON: LDCodec[Json] =
JsonLDCodecImplementation.jsonLDCodecRetainingNumberErrors

def ldCodecFromCirceCodec[A: Encoder: Decoder]: LDCodec[A] =
circeLDCodecForJSON.imapVFull[A](
(a, _) => Encoder[A].apply(a).valid,
(json, history) =>
Decoder[A].decodeAccumulating(json.hcursor).leftMap { errors =>
NonEmptyChain
.fromNonEmptyList(errors)
.map(JsonLDCodecImplementation.convertCirceFailureToLDFailure)
.map(lcf => lcf.updateHistory(history.combine(_)))
},
)
}

// This is separate to keep the implicits from colliding during construction
private object JsonLDCodecImplementation {
val jsonLDCodecRetainingNumberErrors: LDCodec[Json] = Defer[LDCodec].fix { implicit recurse =>
LDCodec.instance(
_encode = (json, history) =>
json.fold[LDCodecResult[LDValue]](
jsonNull = LDValue.ofNull().validNec,
jsonBoolean = _.asLDValue.valid,
jsonNumber = _.asLDValueOrFailure(history),
jsonString = _.asLDValue.valid,
jsonArray = _.asLDValueOrFailure(history),
jsonObject = _.toIterable.asLDValueOrFailure(history),
),
_decode = cursor =>
cursor.valueType match {
case LDValueType.NULL => Json.Null.valid
case LDValueType.BOOLEAN => cursor.as[Boolean].map(Json.fromBoolean)
case LDValueType.NUMBER => cursor.as[JsonNumber].map(Json.fromJsonNumber)
case LDValueType.STRING => cursor.as[String].map(Json.fromString)
case LDValueType.ARRAY => cursor.as[Iterable[Json]].map(Json.fromValues)
case LDValueType.OBJECT => cursor.as[Vector[(String, Json)]].map(Json.fromFields(_))
},
)
}

def convertCirceFailureToLDFailure(decodingFailure: DecodingFailure): LDCodecFailure =
LDCodecFailure(
decodingFailure.reason match {
case DecodingFailure.Reason.CustomReason(message) => LDReason.other(message)
case DecodingFailure.Reason.MissingField => LDReason.missingField
case DecodingFailure.Reason.WrongTypeExpectation(expectedJsonFieldType, jsonValue) =>
LDReason.wrongType(
expectedJsonFieldType,
jsonValue.fold(
jsonNull = LDValueType.NULL,
jsonBoolean = _ => LDValueType.BOOLEAN,
jsonNumber = _ => LDValueType.NUMBER,
jsonString = _ => LDValueType.STRING,
jsonArray = _ => LDValueType.ARRAY,
jsonObject = _ => LDValueType.OBJECT,
),
)
},
LDCursorHistory.of(Chain.fromSeq(decodingFailure.history).mapFilter {
case CursorOp.DownField(k) => Move(k).some
case CursorOp.DownN(n) => Move(n).some
case _ => none
}),
)

implicit private val jNumberCodec: LDCodec[JsonNumber] = LDCodec.instance(
(jNumber, history) =>
Validated
.catchOnly[SerializationException] {
// This nasty hack is because LDValue number support is lacking.
//
// LDValueNumber encodes everything as Double and that introduces encoding
// issues like negative/positive/unsigned zero and rounding if we try to do
// the conversion ourselves with the raw.
//
// Here, we're basically hoping they've got their house in order and can
// parse a valid JSON number.
LDValue.normalize(
JsonSerialization.deserialize(
Json.fromJsonNumber(jNumber).noSpaces,
classOf[LDValue],
)
)
}
.leftMap { (_: SerializationException) =>
LDCodecFailure(LDReason.unencodableValue(LDValueType.NUMBER, "JNumber"), history)
}
.toValidatedNec,
_.checkType(LDValueType.NUMBER).andThen { c =>
// Less of a hack on the encoding side, because circe can handle (most) doubles.
// The JVM double doesn't map cleanly to the JSON number, so this can fail as well.
LDCodec[Double]
.decode(c)
.map(Json.fromDouble(_).flatMap(_.asNumber))
.andThen(_.toValidNec {
c.fail(LDReason.undecodableValue(LDValueType.NUMBER, "JNumber"))
})
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.catapult.circe.syntax

import cats.MonadThrow
import cats.syntax.all.*
import io.circe.syntax.*
import io.circe.{Decoder, Encoder, Json}
import org.typelevel.catapult.circe.JsonLDCodec.*
import org.typelevel.catapult.codec.LDCursorHistory
import org.typelevel.catapult.codec.syntax.*
import org.typelevel.catapult.{ContextEncoder, LaunchDarklyClient}

object client {
implicit final class CatapultLaunchDarklyClientCirceOps[F[_]](
private val client: LaunchDarklyClient[F]
) extends AnyVal {
def circeVariation[Ctx: ContextEncoder](featureKey: String, ctx: Ctx, defaultValue: Json)(
implicit F: MonadThrow[F]
): F[Json] =
defaultValue
.asLDValueOrFailure(LDCursorHistory.root)
.asEncodingFailure
.liftTo[F]
.flatMap(client.jsonValueVariation(featureKey, ctx, _))
.flatMap(_.decode[Json].asDecodingFailure.liftTo[F])

def circeVariationAs[A: Decoder: Encoder, Ctx: ContextEncoder](
featureKey: String,
ctx: Ctx,
defaultValue: A,
)(implicit F: MonadThrow[F]): F[A] =
circeVariation[Ctx](featureKey, ctx, defaultValue.asJson)
.flatMap(_.as[A].liftTo[F])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2022 Typelevel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.typelevel.catapult.circe.syntax

import cats.MonadThrow
import cats.syntax.all.*
import io.circe.syntax.*
import io.circe.{Decoder, Encoder, Json}
import org.typelevel.catapult.circe.JsonLDCodec.*
import org.typelevel.catapult.codec.LDCursorHistory
import org.typelevel.catapult.codec.syntax.*
import org.typelevel.catapult.mtl.LaunchDarklyMTLClient

object mtlClient {
implicit final class CatapultLaunchDarklyMTLClientCirceOps[F[_]](
private val client: LaunchDarklyMTLClient[F]
) extends AnyVal {
def circeVariation(featureKey: String, defaultValue: Json)(implicit F: MonadThrow[F]): F[Json] =
defaultValue
.asLDValueOrFailure(LDCursorHistory.root)
.asEncodingFailure
.liftTo[F]
.flatMap(client.jsonValueVariation(featureKey, _))
.flatMap(_.decode[Json].asDecodingFailure.liftTo[F])

def circeVariationAs[A: Decoder: Encoder](featureKey: String, defaultValue: A)(implicit
F: MonadThrow[F]
): F[A] =
circeVariation(featureKey, defaultValue.asJson)
.flatMap(_.as[A].liftTo[F])
}
}
Loading