From e1759e3299a1f41f9790d9190d6440543c54292d Mon Sep 17 00:00:00 2001 From: Andriy Plokhotnyuk Date: Sat, 4 Jan 2025 07:55:15 +0100 Subject: [PATCH] Fix duplicated field rejection for JSON codecs of records (#765) --- .../scala/zio/schema/codec/JsonCodec.scala | 12 ++++++++-- .../zio/schema/codec/JsonCodecSpec.scala | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/zio-schema-json/shared/src/main/scala/zio/schema/codec/JsonCodec.scala b/zio-schema-json/shared/src/main/scala/zio/schema/codec/JsonCodec.scala index e3876b796..d211ab3ba 100644 --- a/zio-schema-json/shared/src/main/scala/zio/schema/codec/JsonCodec.scala +++ b/zio-schema-json/shared/src/main/scala/zio/schema/codec/JsonCodec.scala @@ -1002,7 +1002,7 @@ object JsonCodec { val rejectAdditionalFields = schema.annotations.exists(_.isInstanceOf[rejectExtraFields]) (trace: List[JsonError], in: RetractReader) => { Lexer.char(trace, in, '{') - val map = defaults.clone().asInstanceOf[util.HashMap[String, Any]] + val map = new util.HashMap[String, Any] var continue = Lexer.firstField(trace, in) while (continue) { val fieldNameOrAlias = Lexer.string(trace, in).toString @@ -1013,7 +1013,10 @@ object JsonCodec { val trace_ = span :: trace Lexer.char(trace_, in, ':') val fieldName = span.field - map.put(fieldName, dec.unsafeDecode(trace_, in)) + val prev = map.put(fieldName, dec.unsafeDecode(trace_, in)) + if (prev != null) { + throw UnsafeJson(JsonError.Message("duplicate") :: trace) + } } else if (rejectAdditionalFields) { throw UnsafeJson(JsonError.Message(s"unexpected field: $fieldNameOrAlias") :: trace) } else { @@ -1022,6 +1025,11 @@ object JsonCodec { } continue = Lexer.nextField(trace, in) } + val it = defaults.entrySet().iterator() + while (it.hasNext) { + val entry = it.next() + map.putIfAbsent(entry.getKey, entry.getValue) + } (ListMap.newBuilder[String, Any] ++= ({ // to avoid O(n) insert operations import scala.collection.JavaConverters.mapAsScalaMapConverter // use deprecated class for Scala 2.12 compatibility diff --git a/zio-schema-json/shared/src/test/scala/zio/schema/codec/JsonCodecSpec.scala b/zio-schema-json/shared/src/test/scala/zio/schema/codec/JsonCodecSpec.scala index 071695556..c20db4ba3 100644 --- a/zio-schema-json/shared/src/test/scala/zio/schema/codec/JsonCodecSpec.scala +++ b/zio-schema-json/shared/src/test/scala/zio/schema/codec/JsonCodecSpec.scala @@ -708,6 +708,18 @@ object JsonCodecSpec extends ZIOSpecDefault { charSequenceToByteChunk("""{"$f1":"test", "extraField":"extra"}""") ).flip.map(err => assertTrue(err.getMessage() == "(unexpected field: extraField)")) }, + test("reject duplicated fields") { + assertDecodesToError( + recordSchema, + """{"foo":"test","bar":10,"foo":10}""", + JsonError.Message("duplicate") :: Nil + ) &> + assertDecodesToError( + recordSchema, + """{"bar":10,"foo":"test","bar":"100"}""", + JsonError.Message("duplicate") :: Nil + ) + }, test("optional field with schema or annotated default value") { assertDecodes( RecordExampleWithOptField.schema, @@ -812,6 +824,18 @@ object JsonCodecSpec extends ZIOSpecDefault { JsonError.Message("extra field") :: Nil ) }, + test("reject duplicated fields") { + assertDecodesToError( + personSchema, + """{"name":"test","age":10,"name":10}""", + JsonError.Message("duplicate") :: Nil + ) &> + assertDecodesToError( + personSchema, + """{"age":10,"name":"test","age":"100"}""", + JsonError.Message("duplicate") :: Nil + ) + }, test("transient field annotation with default value in class definition") { assertDecodes( searchRequestWithTransientFieldSchema,