Skip to content

Commit

Permalink
Implement strict decoding
Browse files Browse the repository at this point in the history
  • Loading branch information
s-nel committed Jul 31, 2022
1 parent 925556e commit c57dd16
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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]
}

}

0 comments on commit c57dd16

Please sign in to comment.