Skip to content

Commit

Permalink
macro configuration (#811)
Browse files Browse the repository at this point in the history
  • Loading branch information
senia-psm authored Dec 4, 2022
1 parent d4afef5 commit 88e204f
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 7 deletions.
75 changes: 68 additions & 7 deletions zio-json/shared/src/main/scala-2.x/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package zio.json

import magnolia1._
import zio.Chunk
import zio.json.JsonCodecConfiguration.SumTypeHandling
import zio.json.JsonCodecConfiguration.SumTypeHandling.WrapperWithClassNameField
import zio.json.JsonDecoder.{ JsonError, UnsafeJson }
import zio.json.ast.Json
import zio.json.internal.{ Lexer, RetractReader, StringMatrix, Write }
Expand Down Expand Up @@ -54,6 +56,9 @@ case object PascalCase extends JsonMemberFormat {
case object KebabCase extends JsonMemberFormat {
override def apply(memberName: String): String = jsonMemberNames.enforceSnakeOrKebabCase(memberName, '-')
}
case object IdentityFormat extends JsonMemberFormat {
override def apply(memberName: String): String = memberName
}

/**
* If used on a case class, determines the strategy of member names
Expand Down Expand Up @@ -148,18 +153,70 @@ final class jsonNoExtraFields extends Annotation
*/
final class jsonExclude extends Annotation

// TODO: implement same configuration for Scala 3 once this issue is resolved: https://github.com/softwaremill/magnolia/issues/296
/**
* Implicit codec derivation configuration.
*
* @param sumTypeHandling see [[jsonDiscriminator]]
* @param fieldNameMapping see [[jsonMemberNames]]
* @param allowExtraFields see [[jsonNoExtraFields]]
*/
final case class JsonCodecConfiguration(
sumTypeHandling: SumTypeHandling = WrapperWithClassNameField,
fieldNameMapping: JsonMemberFormat = IdentityFormat,
allowExtraFields: Boolean = true
)

object JsonCodecConfiguration {
implicit val default: JsonCodecConfiguration = JsonCodecConfiguration()

sealed trait SumTypeHandling {
def discriminatorField: Option[String]
}

object SumTypeHandling {

/**
* Use an object with a single key that is the class name.
*/
case object WrapperWithClassNameField extends SumTypeHandling {
override def discriminatorField: Option[String] = None
}

/**
* For sealed classes, will determine the name of the field for
* disambiguating classes.
*
* The default is to not use a typehint field and instead
* have an object with a single key that is the class name.
* See [[WrapperWithClassNameField]].
*
* Note that using a discriminator is less performant, uses more memory, and may
* be prone to DOS attacks that are impossible with the default encoding. In
* addition, there is slightly less type safety when using custom product
* encoders (which must write an unenforced object type). Only use this option
* if you must model an externally defined schema.
*/
final case class DiscriminatorField(name: String) extends SumTypeHandling {
override def discriminatorField: Option[String] = Some(name)
}
}
}

object DeriveJsonDecoder {
type Typeclass[A] = JsonDecoder[A]

def join[A](ctx: CaseClass[JsonDecoder, A]): JsonDecoder[A] = {
def join[A](ctx: CaseClass[JsonDecoder, A])(implicit config: JsonCodecConfiguration): JsonDecoder[A] = {
val (transformNames, nameTransform): (Boolean, String => String) =
ctx.annotations.collectFirst { case jsonMemberNames(format) => format }
.orElse(Some(config.fieldNameMapping))
.filter(_ != IdentityFormat)
.map(true -> _)
.getOrElse(false -> identity _)

val no_extra = ctx.annotations.collectFirst { case _: jsonNoExtraFields =>
()
}.isDefined
}.isDefined || !config.allowExtraFields

if (ctx.parameters.isEmpty)
new JsonDecoder[A] {
Expand Down Expand Up @@ -286,7 +343,7 @@ object DeriveJsonDecoder {
}
}

def split[A](ctx: SealedTrait[JsonDecoder, A]): JsonDecoder[A] = {
def split[A](ctx: SealedTrait[JsonDecoder, A])(implicit config: JsonCodecConfiguration): JsonDecoder[A] = {
val names: Array[String] = ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
Expand All @@ -297,7 +354,8 @@ object DeriveJsonDecoder {
ctx.subtypes.map(_.typeclass).toArray.asInstanceOf[Array[JsonDecoder[Any]]]
lazy val namesMap: Map[String, Int] = names.zipWithIndex.toMap

def discrim = ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }
def discrim =
ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }.orElse(config.sumTypeHandling.discriminatorField)
if (discrim.isEmpty)
new JsonDecoder[A] {
val spans: Array[JsonError] = names.map(JsonError.ObjectAccess(_))
Expand Down Expand Up @@ -388,7 +446,7 @@ object DeriveJsonDecoder {
object DeriveJsonEncoder {
type Typeclass[A] = JsonEncoder[A]

def join[A](ctx: CaseClass[JsonEncoder, A]): JsonEncoder[A] =
def join[A](ctx: CaseClass[JsonEncoder, A])(implicit config: JsonCodecConfiguration): JsonEncoder[A] =
if (ctx.parameters.isEmpty)
new JsonEncoder[A] {
def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = out.write("{}")
Expand All @@ -400,6 +458,8 @@ object DeriveJsonEncoder {
new JsonEncoder[A] {
val (transformNames, nameTransform): (Boolean, String => String) =
ctx.annotations.collectFirst { case jsonMemberNames(format) => format }
.orElse(Some(config.fieldNameMapping))
.filter(_ != IdentityFormat)
.map(true -> _)
.getOrElse(false -> identity)

Expand Down Expand Up @@ -461,13 +521,14 @@ object DeriveJsonEncoder {
.map(Json.Obj.apply)
}

def split[A](ctx: SealedTrait[JsonEncoder, A]): JsonEncoder[A] = {
def split[A](ctx: SealedTrait[JsonEncoder, A])(implicit config: JsonCodecConfiguration): JsonEncoder[A] = {
val names: Array[String] = ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
}.getOrElse(p.typeName.short)
}.toArray
def discrim = ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }
def discrim =
ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }.orElse(config.sumTypeHandling.discriminatorField)
if (discrim.isEmpty)
new JsonEncoder[A] {
def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = ctx.split(a) { sub =>
Expand Down
1 change: 1 addition & 0 deletions zio-json/shared/src/main/scala-3/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ final class jsonNoExtraFields extends Annotation
*/
final class jsonExclude extends Annotation

// TODO: implement same configuration as for Scala 2 once this issue is resolved: https://github.com/softwaremill/magnolia/issues/296
object DeriveJsonDecoder extends Derivation[JsonDecoder] { self =>
def join[A](ctx: CaseClass[Typeclass, A]): JsonDecoder[A] = {
val (transformNames, nameTransform): (Boolean, String => String) =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package zio.json

import zio.json.JsonCodecConfiguration.SumTypeHandling.DiscriminatorField
import zio.test._

object ConfigurableDeriveCodecSpec extends ZIOSpecDefault {
case class ClassWithFields(someField: Int, someOtherField: String)

sealed trait ST

object ST {
case object CaseObj extends ST
case class CaseClass(i: Int) extends ST
}

def spec = suite("ConfigurableDeriveCodecSpec")(
suite("defaults")(
test("should not map field names by default") {
val expectedStr = """{"someField":1,"someOtherField":"a"}"""
val expectedObj = ClassWithFields(1, "a")

implicit val codec: JsonCodec[ClassWithFields] = DeriveJsonCodec.gen

assertTrue(
expectedStr.fromJson[ClassWithFields].toOption.get == expectedObj,
expectedObj.toJson == expectedStr
)
},
test("should not use discriminator by default") {
val expectedStr = """{"CaseObj":{}}"""
val expectedObj: ST = ST.CaseObj

implicit val codec: JsonCodec[ST] = DeriveJsonCodec.gen

assertTrue(
expectedStr.fromJson[ST].toOption.get == expectedObj,
expectedObj.toJson == expectedStr
)
},
test("should allow extra fields by default") {
val jsonStr = """{"someField":1,"someOtherField":"a","extra":123}"""
val expectedObj = ClassWithFields(1, "a")

implicit val codec: JsonCodec[ClassWithFields] = DeriveJsonCodec.gen

assertTrue(
jsonStr.fromJson[ClassWithFields].toOption.get == expectedObj
)
}
),
suite("overrides")(
test("should override field name mapping") {
val expectedStr = """{"some_field":1,"some_other_field":"a"}"""
val expectedObj = ClassWithFields(1, "a")

implicit val config: JsonCodecConfiguration =
JsonCodecConfiguration(fieldNameMapping = SnakeCase)
implicit val codec: JsonCodec[ClassWithFields] = DeriveJsonCodec.gen

assertTrue(
expectedStr.fromJson[ClassWithFields].toOption.get == expectedObj,
expectedObj.toJson == expectedStr
)
},
test("should specify discriminator") {
val expectedStr = """{"$type":"CaseClass","i":1}"""
val expectedObj: ST = ST.CaseClass(i = 1)

implicit val config: JsonCodecConfiguration =
JsonCodecConfiguration(sumTypeHandling = DiscriminatorField("$type"))
implicit val codec: JsonCodec[ST] = DeriveJsonCodec.gen

assertTrue(
expectedStr.fromJson[ST].toOption.get == expectedObj,
expectedObj.toJson == expectedStr
)
},
test("should prevent extra fields") {
val jsonStr = """{"someField":1,"someOtherField":"a","extra":123}"""

implicit val config: JsonCodecConfiguration =
JsonCodecConfiguration(allowExtraFields = false)
implicit val codec: JsonCodec[ClassWithFields] = DeriveJsonCodec.gen

assertTrue(
jsonStr.fromJson[ClassWithFields].isLeft
)
}
)
)
}

0 comments on commit 88e204f

Please sign in to comment.