diff --git a/derivation/src/main/scala/io/circe/magnolia/MagnoliaDecoder.scala b/derivation/src/main/scala/io/circe/magnolia/MagnoliaDecoder.scala index 1d1f207..3fe69f7 100644 --- a/derivation/src/main/scala/io/circe/magnolia/MagnoliaDecoder.scala +++ b/derivation/src/main/scala/io/circe/magnolia/MagnoliaDecoder.scala @@ -25,7 +25,7 @@ private[magnolia] object MagnoliaDecoder { throw new DerivationError("Duplicate key detected after applying transformation function for case class parameters") } - if (configuration.useDefaults) { + val nonStrictDecoder = if (configuration.useDefaults) { new Decoder[T] { override def apply(c: HCursor): Result[T] = { caseClass.constructEither { p => @@ -52,6 +52,26 @@ private[magnolia] object MagnoliaDecoder { }.leftMap(_.head) } } + + if (configuration.strictDeserialization) { + val expectedFields = paramJsonKeyLookup.values + val strictDecoder = nonStrictDecoder.validate { cursor: HCursor => + val maybeUnexpectedErrors = for { + json <- cursor.focus + jsonKeys <- json.hcursor.keys + unexpected = jsonKeys.toSet -- expectedFields + } yield { + unexpected.toList map { unexpectedField => + s"Unexpected field: [$unexpectedField]. Valid fields: ${expectedFields.mkString(",")}" + } + } + + maybeUnexpectedErrors.getOrElse(List("Couldn't determine decoded fields.")) + } + (c: HCursor) => strictDecoder(c) + } else { + nonStrictDecoder + } } private[magnolia] def dispatch[T]( diff --git a/derivation/src/main/scala/io/circe/magnolia/configured/Configuration.scala b/derivation/src/main/scala/io/circe/magnolia/configured/Configuration.scala index 90dbd58..21b1697 100644 --- a/derivation/src/main/scala/io/circe/magnolia/configured/Configuration.scala +++ b/derivation/src/main/scala/io/circe/magnolia/configured/Configuration.scala @@ -19,12 +19,15 @@ import java.util.regex.Pattern * formatting or case changes. * If there are collisions in transformed constructor names, an exception will be thrown * during derivation (runtime) + * @param strictDeserialization When true, raises a decoding error when there are any extraneous fields in the given JSON + * that aren't present in the case class. */ final case class Configuration( transformMemberNames: String => String, transformConstructorNames: String => String, useDefaults: Boolean, - discriminator: Option[String] + discriminator: Option[String], + strictDeserialization: Boolean = false ) { def withSnakeCaseMemberNames: Configuration = copy( transformMemberNames = Configuration.snakeCaseTransformation @@ -44,6 +47,7 @@ final case class Configuration( def withDefaults: Configuration = copy(useDefaults = true) def withDiscriminator(discriminator: String): Configuration = copy(discriminator = Some(discriminator)) + def withStrictDeserialization: Configuration = copy(strictDeserialization = true) } final object Configuration { diff --git a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredAutoDerivedEquivalenceSuite.scala b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredAutoDerivedEquivalenceSuite.scala index 9aee665..fce0dfe 100644 --- a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredAutoDerivedEquivalenceSuite.scala +++ b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredAutoDerivedEquivalenceSuite.scala @@ -134,6 +134,7 @@ class ConfiguredAutoDerivedEquivalenceSuite extends CirceSuite { testWithConfiguration("with snake case configuration", Configuration.default.withSnakeCaseConstructorNames.withSnakeCaseMemberNames) testWithConfiguration("with useDefault = true", Configuration.default.copy(useDefaults = true)) testWithConfiguration("with discriminator", Configuration.default.copy(discriminator = Some("type"))) + testWithConfiguration("with strict", Configuration.default.copy(strictDeserialization = true)) "If a sealed trait subtype has explicit Encoder instance that doesn't encode to a JsonObject, the derived encoder" should s"wrap it with type constructor even when discriminator is specified by the configuration" in { diff --git a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedEquivalenceSuite.scala b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedEquivalenceSuite.scala index 7ea3911..9b08664 100644 --- a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedEquivalenceSuite.scala +++ b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedEquivalenceSuite.scala @@ -126,6 +126,7 @@ class ConfiguredSemiautoDerivedEquivalenceSuite extends CirceSuite { testWithConfiguration("with snake case configuration", Configuration.default.withSnakeCaseConstructorNames.withSnakeCaseMemberNames) testWithConfiguration("with useDefault = true", Configuration.default.copy(useDefaults = true)) testWithConfiguration("with discriminator", Configuration.default.copy(discriminator = Some("type"))) + testWithConfiguration("with strict", Configuration.default.copy(strictDeserialization = true)) "If a sealed trait subtype has explicit Encoder instance that doesn't encode to a JsonObject, the derived encoder" should s"wrap it with type constructor even when discriminator is specified by the configuration" in { diff --git a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedSuite.scala b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedSuite.scala index 02f4dd2..3bd3e7a 100644 --- a/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedSuite.scala +++ b/tests/src/test/scala/io/circe/magnolia/configured/ConfiguredSemiautoDerivedSuite.scala @@ -2,12 +2,7 @@ package io.circe.magnolia.configured import io.circe.magnolia.DerivationError import io.circe._ -import io.circe.magnolia.configured.ConfiguredSemiautoDerivedSuite.{ - DefaultConfig, - KebabCase, - SnakeCaseAndDiscriminator, - WithDefaultValue -} +import io.circe.magnolia.configured.ConfiguredSemiautoDerivedSuite.{DefaultConfig, KebabCase, Lenient, SnakeCaseAndDiscriminator, Strict, WithDefaultValue} import io.circe.tests.CirceSuite import io.circe.tests.examples.{Bar, ClassWithDefaults, ClassWithJsonKey, NonProfit, Organization, Public} import org.scalatest.Inside @@ -192,6 +187,38 @@ class ConfiguredSemiautoDerivedSuite extends CirceSuite with Inside { ) } + "Configuration#strictDeserialization" should "Raise error when strict deserialization enabled and extraneous key is found in JSON" in { + val input = parse(""" + { + "NonProfit": { + "orgName": "RSPCA", + "extraneous": true + } + } + """) + inside(input.flatMap(i => Strict.decoder(i.hcursor))) { + case Left(e: DecodingFailure) => { + assert(e.message.contains("Unexpected field")) + assert(e.message.contains("extraneous")) + assert(e.message.contains("orgName")) + } + case x => fail(x.toString) + } + } + + "Configuration#strictDeserialization" should "Should not raise error when strict deserialization is disabled and extraneous key is found in JSON" in { + val input = parse(""" + { + "NonProfit": { + "orgName": "RSPCA", + "extraneous": true + } + } + """) + val expected = NonProfit("RSPCA") + assert(input.flatMap(i => Lenient.decoder(i.hcursor)) == Right(expected)) + } + "Encoder derivation" should "fail if transforming parameter names has collisions" in { implicit val config: Configuration = Configuration.default.copy(transformMemberNames = _ => "sameKey") @@ -279,4 +306,18 @@ object ConfiguredSemiautoDerivedSuite { val decoder: Decoder[Organization] = deriveConfiguredMagnoliaDecoder[Organization] } + object Strict { + implicit val configuration: Configuration = Configuration.default.withStrictDeserialization + + val encoder: Encoder[Organization] = deriveConfiguredMagnoliaEncoder[Organization] + val decoder: Decoder[Organization] = deriveConfiguredMagnoliaDecoder[Organization] + } + + object Lenient { + implicit val configuration: Configuration = Configuration.default + + val encoder: Encoder[Organization] = deriveConfiguredMagnoliaEncoder[Organization] + val decoder: Decoder[Organization] = deriveConfiguredMagnoliaDecoder[Organization] + } + }