Skip to content

Commit aa7be33

Browse files
committed
Additional JsonCodec configurations (#831)
1 parent bd8530b commit aa7be33

File tree

3 files changed

+365
-32
lines changed

3 files changed

+365
-32
lines changed

zio-schema-json/shared/src/main/scala/zio/schema/codec/JsonCodec.scala

Lines changed: 163 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import zio.json.{
2323
import zio.prelude.NonEmptyMap
2424
import zio.schema.Schema.GenericRecord
2525
import zio.schema._
26-
import zio.schema.annotation.{ discriminatorName, rejectExtraFields, _ }
26+
import zio.schema.annotation.{ rejectExtraFields, _ }
2727
import zio.schema.codec.DecodeError.ReadError
2828
import zio.schema.codec.JsonCodec.JsonDecoder.schemaDecoder
2929
import zio.stream.{ ZChannel, ZPipeline }
@@ -96,21 +96,134 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
9696
}
9797

9898
/**
99-
* When disabled for decoding, matching fields will be omitted from the JSON. When disabled for encoding,
99+
* When disabled for encoding, matching fields will be omitted from the JSON. When disabled for decoding,
100100
* missing fields will be decoded as the default value.
101101
*/
102102
case class ExplicitConfig(encoding: Boolean = true, decoding: Boolean = false)
103103

104104
final case class Configuration(
105105
explicitEmptyCollections: ExplicitConfig = ExplicitConfig(),
106+
explicitNulls: ExplicitConfig = ExplicitConfig(),
107+
discriminatorSettings: DiscriminatorSetting = DiscriminatorSetting.default,
108+
fieldNameFormat: NameFormat = NameFormat.Identity,
106109
treatStreamsAsArrays: Boolean = false,
107-
explicitNulls: ExplicitConfig = ExplicitConfig()
108-
) {}
110+
rejectExtraFields: Boolean = false
111+
) {
112+
113+
val noDiscriminator: Boolean = discriminatorSettings match {
114+
case DiscriminatorSetting.NoDiscriminator => true
115+
case _ => false
116+
}
117+
118+
val discriminatorName: Option[String] = discriminatorSettings match {
119+
case DiscriminatorSetting.Name(name, _) => Some(name)
120+
case _ => None
121+
}
122+
123+
val discriminatorFormat: NameFormat = discriminatorSettings match {
124+
case DiscriminatorSetting.ClassName(format) => format
125+
case DiscriminatorSetting.Name(_, format) => format
126+
case _ => NameFormat.Identity
127+
}
128+
}
109129

110130
object Configuration {
111131
val default: Configuration = Configuration()
112132
}
113133

134+
sealed trait NameFormat extends (String => String)
135+
136+
object NameFormat {
137+
import java.lang.Character._
138+
139+
private def enforceCamelOrPascalCase(s: String, toPascal: Boolean): String =
140+
if (s.indexOf('_') == -1 && s.indexOf('-') == -1) {
141+
if (s.isEmpty) s
142+
else {
143+
val ch = s.charAt(0)
144+
val fixedCh =
145+
if (toPascal) toUpperCase(ch)
146+
else toLowerCase(ch)
147+
s"$fixedCh${s.substring(1)}"
148+
}
149+
} else {
150+
val len = s.length
151+
val sb = new StringBuilder(len)
152+
var i = 0
153+
var isPrecedingDash = toPascal
154+
while (i < len) isPrecedingDash = {
155+
val ch = s.charAt(i)
156+
i += 1
157+
(ch == '_' || ch == '-') || {
158+
val fixedCh =
159+
if (isPrecedingDash) toUpperCase(ch)
160+
else toLowerCase(ch)
161+
sb.append(fixedCh)
162+
false
163+
}
164+
}
165+
sb.toString
166+
}
167+
168+
private def enforceSnakeOrKebabCase(s: String, separator: Char): String = {
169+
val len = s.length
170+
val sb = new StringBuilder(len << 1)
171+
var i = 0
172+
var isPrecedingNotUpperCased = false
173+
while (i < len) isPrecedingNotUpperCased = {
174+
val ch = s.charAt(i)
175+
i += 1
176+
if (ch == '_' || ch == '-') {
177+
sb.append(separator)
178+
false
179+
} else if (!isUpperCase(ch)) {
180+
sb.append(ch)
181+
true
182+
} else {
183+
if (isPrecedingNotUpperCased || i > 1 && i < len && !isUpperCase(s.charAt(i))) sb.append(separator)
184+
sb.append(toLowerCase(ch))
185+
false
186+
}
187+
}
188+
sb.toString
189+
}
190+
191+
case class Custom(f: String => String) extends NameFormat {
192+
override def apply(memberName: String): String = f(memberName)
193+
}
194+
195+
case object SnakeCase extends NameFormat {
196+
override def apply(memberName: String): String = enforceSnakeOrKebabCase(memberName, '_')
197+
}
198+
199+
case object CamelCase extends NameFormat {
200+
override def apply(memberName: String): String =
201+
enforceCamelOrPascalCase(memberName, toPascal = false)
202+
}
203+
204+
case object PascalCase extends NameFormat {
205+
override def apply(memberName: String): String =
206+
enforceCamelOrPascalCase(memberName, toPascal = true)
207+
}
208+
209+
case object KebabCase extends NameFormat {
210+
override def apply(memberName: String): String = enforceSnakeOrKebabCase(memberName, '-')
211+
}
212+
213+
case object Identity extends NameFormat {
214+
override def apply(memberName: String): String = memberName
215+
}
216+
}
217+
218+
sealed trait DiscriminatorSetting
219+
220+
object DiscriminatorSetting {
221+
val default: ClassName = ClassName(NameFormat.Identity)
222+
case class ClassName(format: NameFormat) extends DiscriminatorSetting
223+
case object NoDiscriminator extends DiscriminatorSetting
224+
case class Name(name: String, format: NameFormat = NameFormat.Identity) extends DiscriminatorSetting
225+
}
226+
114227
type DiscriminatorTuple = Option[(String, String)]
115228

116229
implicit def zioJsonBinaryCodec[A](implicit jsonCodec: ZJsonCodec[A]): BinaryCodec[A] =
@@ -579,32 +692,38 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
579692
}
580693
}
581694

582-
private def enumEncoder[Z](schema: Schema.Enum[Z], cfg: Configuration): ZJsonEncoder[Z] =
695+
private def enumEncoder[Z](schema: Schema.Enum[Z], cfg: Configuration): ZJsonEncoder[Z] = {
696+
def format(caseName: String): String =
697+
if (cfg.discriminatorFormat == NameFormat.Identity) caseName
698+
else cfg.discriminatorFormat(caseName)
583699
// if all cases are CaseClass0, encode as a String
584700
if (schema.annotations.exists(_.isInstanceOf[simpleEnum])) {
585701
val caseMap: Map[Z, String] =
586702
schema.nonTransientCases
587703
.map(
588704
case_ =>
589705
case_.schema.asInstanceOf[Schema.CaseClass0[Z]].defaultConstruct() ->
590-
case_.caseName
706+
format(case_.caseName)
591707
)
592708
.toMap
593709
ZJsonEncoder.string.contramap(caseMap(_))
594710
} else {
595711
val discriminatorName =
596-
if (schema.noDiscriminator) None
597-
else schema.annotations.collectFirst { case d: discriminatorName => d.tag }
598-
val doJsonObjectWrapping = discriminatorName.isEmpty && !schema.noDiscriminator
712+
if (schema.noDiscriminator || (cfg.noDiscriminator && schema.discriminatorName.isEmpty)) None
713+
else schema.discriminatorName.orElse(cfg.discriminatorName)
714+
val doJsonObjectWrapping =
715+
discriminatorName.isEmpty &&
716+
!schema.noDiscriminator &&
717+
!cfg.noDiscriminator
599718
if (doJsonObjectWrapping) {
600719
new ZJsonEncoder[Z] {
601720
private[this] val cases = schema.nonTransientCases.toArray
602721
private[this] val decoders =
603722
cases.map(case_ => schemaEncoder(case_.schema.asInstanceOf[Schema[Any]], cfg, None))
604723
private[this] val encodedKeys =
605-
cases.map(case_ => ZJsonEncoder.string.encodeJson(case_.caseName).toString + ':')
724+
cases.map(case_ => ZJsonEncoder.string.encodeJson(format(case_.caseName)).toString + ':')
606725
private[this] val prettyEncodedKeys =
607-
cases.map(case_ => ZJsonEncoder.string.encodeJson(case_.caseName).toString + " : ")
726+
cases.map(case_ => ZJsonEncoder.string.encodeJson(format(case_.caseName)).toString + " : ")
608727

609728
override def unsafeEncode(a: Z, indent: Option[Int], out: Write): Unit = {
610729
var idx = 0
@@ -634,7 +753,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
634753
if (discriminatorName eq None) None
635754
else {
636755
val key = ZJsonEncoder.string.encodeJson(discriminatorName.get, None).toString
637-
val value = ZJsonEncoder.string.encodeJson(case_.caseName, None).toString
756+
val value = ZJsonEncoder.string.encodeJson(format(case_.caseName), None).toString
638757
Some((key + ':' + value, key + " : " + value))
639758
}
640759
schemaEncoder(case_.schema.asInstanceOf[Schema[Any]], cfg, discriminatorTuple)
@@ -655,6 +774,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
655774
}
656775
}
657776
}
777+
}
658778

659779
private def fallbackEncoder[A, B](left: ZJsonEncoder[A], right: ZJsonEncoder[B]): ZJsonEncoder[Fallback[A, B]] =
660780
new ZJsonEncoder[Fallback[A, B]] {
@@ -686,10 +806,14 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
686806
out.write("{}")
687807
} else {
688808
val encoders = nonTransientFields.map(field => schemaEncoder(field.schema.asInstanceOf[Schema[Any]], cfg))
809+
def name(field: Schema.Field[_, _]): String =
810+
if (cfg.fieldNameFormat == JsonCodec.NameFormat.Identity) field.fieldName
811+
else if (field.fieldName == field.name) cfg.fieldNameFormat(field.fieldName)
812+
else field.fieldName
689813
val encodedNames =
690-
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(field.fieldName, None).toString + ':')
814+
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(name(field), None).toString + ':')
691815
val prettyEncodedNames =
692-
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(field.fieldName, None).toString + " : ")
816+
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(name(field), None).toString + " : ")
693817
(a: ListMap[String, _], indent: Option[Int], out: Write) => {
694818
out.write('{')
695819
val indent_ = bump(indent)
@@ -801,7 +925,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
801925
case s: Schema.GenericRecord => recordDecoder(s, discriminator, config)
802926
case s: Schema.Enum[A] => enumDecoder(s, config)
803927
//case Schema.Meta(_, _) => astDecoder
804-
case s @ Schema.CaseClass0(_, _, _) => caseClass0Decoder(discriminator, s)
928+
case s @ Schema.CaseClass0(_, _, _) => caseClass0Decoder(discriminator, s, config)
805929
case s @ Schema.CaseClass1(_, _, _, _) => caseClass1Decoder(discriminator, s, config)
806930
case s @ Schema.CaseClass2(_, _, _, _, _) => caseClass2Decoder(discriminator, s, config)
807931
case s @ Schema.CaseClass3(_, _, _, _, _, _) => caseClass3Decoder(discriminator, s, config)
@@ -889,10 +1013,13 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
8891013
}
8901014

8911015
private def enumDecoder[Z](parentSchema: Schema.Enum[Z], config: Configuration): ZJsonDecoder[Z] = {
1016+
def format(caseName: String): String =
1017+
if (config.discriminatorFormat == NameFormat.Identity) caseName
1018+
else config.discriminatorFormat(caseName)
8921019
val caseNameAliases = new mutable.HashMap[String, Schema.Case[Z, Any]]
8931020
parentSchema.cases.foreach { case_ =>
8941021
val schema = case_.asInstanceOf[Schema.Case[Z, Any]]
895-
caseNameAliases.put(case_.caseName, schema)
1022+
caseNameAliases.put(format(case_.caseName), schema)
8961023
case_.caseNameAliases.foreach(a => caseNameAliases.put(a, schema))
8971024
}
8981025

@@ -926,7 +1053,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
9261053
}
9271054
}
9281055
}
929-
} else if (parentSchema.annotations.exists(_.isInstanceOf[noDiscriminator])) {
1056+
} else if (parentSchema.noDiscriminator || config.noDiscriminator) {
9301057
new ZJsonDecoder[Z] {
9311058
private[this] val decoders = parentSchema.cases.map(c => schemaDecoder(c.schema, config))
9321059

@@ -946,13 +1073,13 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
9461073
}
9471074
}
9481075
} else {
949-
val discriminator = parentSchema.annotations.collectFirst { case d: discriminatorName => d.tag }
1076+
val discriminator = parentSchema.discriminatorName.orElse(config.discriminatorName)
9501077
discriminator match {
9511078
case None =>
9521079
if (caseNameAliases.size <= 64) {
9531080
val caseMatrix = new StringMatrix(caseNameAliases.keys.toArray)
9541081
val cases = caseNameAliases.values.map { case_ =>
955-
(JsonError.ObjectAccess(case_.caseName), schemaDecoder(case_.schema, config))
1082+
(JsonError.ObjectAccess(format(case_.caseName)), schemaDecoder(case_.schema, config))
9561083
}.toArray
9571084
(trace: List[JsonError], in: RetractReader) => {
9581085
val lexer = Lexer
@@ -971,7 +1098,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
9711098
new util.HashMap[String, (JsonError.ObjectAccess, ZJsonDecoder[Any])](caseNameAliases.size << 1)
9721099
caseNameAliases.foreach {
9731100
case (name, case_) =>
974-
cases.put(name, (JsonError.ObjectAccess(case_.caseName), schemaDecoder(case_.schema, config)))
1101+
cases.put(name, (JsonError.ObjectAccess(format(case_.caseName)), schemaDecoder(case_.schema, config)))
9751102
}
9761103
(trace: List[JsonError], in: RetractReader) => {
9771104
val lexer = Lexer
@@ -993,7 +1120,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
9931120
if (caseNameAliases.size <= 64) {
9941121
val caseMatrix = new StringMatrix(caseNameAliases.keys.toArray)
9951122
val cases = caseNameAliases.values.map { case_ =>
996-
(JsonError.ObjectAccess(case_.caseName), schemaDecoder(case_.schema, config, discriminator))
1123+
(JsonError.ObjectAccess(format(case_.caseName)), schemaDecoder(case_.schema, config, discriminator))
9971124
}.toArray
9981125
(trace: List[JsonError], in: RetractReader) => {
9991126
val lexer = Lexer
@@ -1020,7 +1147,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
10201147
case (name, case_) =>
10211148
cases.put(
10221149
name,
1023-
(JsonError.ObjectAccess(case_.caseName), schemaDecoder(case_.schema, config, discriminator))
1150+
(JsonError.ObjectAccess(format(case_.caseName)), schemaDecoder(case_.schema, config, discriminator))
10241151
)
10251152
}
10261153
(trace: List[JsonError], in: RetractReader) => {
@@ -1223,10 +1350,14 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
12231350
private[codec] def caseClassEncoder[Z](schema: Schema.Record[Z], cfg: Configuration, discriminatorTuple: DiscriminatorTuple): ZJsonEncoder[Z] = {
12241351
val nonTransientFields = schema.nonTransientFields.toArray.asInstanceOf[Array[Schema.Field[Z, Any]]]
12251352
val encoders = nonTransientFields.map(s => JsonEncoder.schemaEncoder(s.schema, cfg))
1353+
def name(field: Schema.Field[_, _]): String =
1354+
if (cfg.fieldNameFormat == JsonCodec.NameFormat.Identity) field.fieldName
1355+
else if (field.fieldName == field.name) cfg.fieldNameFormat(field.fieldName)
1356+
else field.fieldName
12261357
val encodedNames =
1227-
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(field.fieldName, None).toString + ':')
1358+
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(name(field), None).toString + ':')
12281359
val prettyEncodedNames =
1229-
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(field.fieldName, None).toString + " : ")
1360+
nonTransientFields.map(field => ZJsonEncoder.string.encodeJson(name(field), None).toString + " : ")
12301361
(a: Z, indent: Option[Int], out: Write) => {
12311362
out.write('{')
12321363
val indent_ = bump(indent)
@@ -1260,8 +1391,8 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
12601391
//scalafmt: { maxColumn = 400, optIn.configStyleArguments = false }
12611392
private[codec] object ProductDecoder {
12621393

1263-
private[codec] def caseClass0Decoder[Z](discriminator: Option[String], schema: Schema.CaseClass0[Z]): ZJsonDecoder[Z] = {
1264-
val rejectExtraFields = schema.annotations.exists(_.isInstanceOf[rejectExtraFields])
1394+
private[codec] def caseClass0Decoder[Z](discriminator: Option[String], schema: Schema.CaseClass0[Z], config: Configuration): ZJsonDecoder[Z] = {
1395+
val rejectExtraFields = schema.rejectExtraFields || config.rejectExtraFields
12651396
val noDiscriminator = discriminator.isEmpty
12661397
(trace: List[JsonError], in: RetractReader) => {
12671398
val lexer = Lexer
@@ -1740,10 +1871,13 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
17401871
schema.fields.foreach { field =>
17411872
fields(idx) = field
17421873
decoders(idx) = schemaDecoder(field.schema, config)
1743-
val name = field.fieldName
1874+
val name =
1875+
if (config.fieldNameFormat == JsonCodec.NameFormat.Identity) field.fieldName
1876+
else if (field.fieldName == field.name) config.fieldNameFormat(field.fieldName)
1877+
else field.fieldName
17441878
names(idx) = name
17451879
spans(idx) = JsonError.ObjectAccess(name)
1746-
(field.nameAndAliases - name).foreach { a =>
1880+
(field.nameAndAliases - field.fieldName).foreach { a =>
17471881
aliases(aliasIdx) = (a, idx)
17481882
aliasIdx += 1
17491883
}
@@ -1760,7 +1894,7 @@ JsonCodec.Configuration makes it now possible to configure en-/decoding of empty
17601894
spans,
17611895
new StringMatrix(names, aliases),
17621896
!hasDiscriminator,
1763-
!schema.annotations.exists(_.isInstanceOf[rejectExtraFields]),
1897+
!schema.rejectExtraFields && !config.rejectExtraFields,
17641898
config
17651899
)
17661900
}

0 commit comments

Comments
 (0)