From 37b4c1d6bf7446eddab5d3005649ac0a4cbc0c17 Mon Sep 17 00:00:00 2001 From: Ali Ustek Date: Sun, 14 Jan 2024 17:07:53 +0000 Subject: [PATCH] fix when field is defined with a default value (#35) --- .../austek/plugin/avro/AvroRecord.scala | 76 +++++++++-------- .../avro/AvroRecordComplexTypesTest.scala | 84 ++++++++++++++++++- .../avro/AvroRecordPrimitiveTypesTest.scala | 73 +++++++++++++--- 3 files changed, 185 insertions(+), 48 deletions(-) diff --git a/modules/plugin/src/main/scala/com/github/austek/plugin/avro/AvroRecord.scala b/modules/plugin/src/main/scala/com/github/austek/plugin/avro/AvroRecord.scala index d14a294..5187bde 100644 --- a/modules/plugin/src/main/scala/com/github/austek/plugin/avro/AvroRecord.scala +++ b/modules/plugin/src/main/scala/com/github/austek/plugin/avro/AvroRecord.scala @@ -4,12 +4,12 @@ import au.com.dius.pact.core.model.matchingrules.{MatchingRule, MatchingRuleCate import com.github.austek.pact.RuleParser.parseRules import com.github.austek.plugin.avro.AvroPluginConstants.MatchingRuleCategoryName import com.github.austek.plugin.avro.error.* -import com.github.austek.plugin.avro.utils.StringUtils._ +import com.github.austek.plugin.avro.utils.StringUtils.* import com.google.protobuf.ByteString import com.google.protobuf.struct.Value import com.google.protobuf.struct.Value.Kind.* import com.typesafe.scalalogging.StrictLogging -import org.apache.avro.Schema +import org.apache.avro.{JsonProperties, Schema} import org.apache.avro.Schema.Type.* import org.apache.avro.generic.* import org.apache.avro.io.EncoderFactory @@ -96,7 +96,8 @@ object Avro { rules: Seq[MatchingRule] ): Either[PluginError[_], AvroValue] = { fieldValue match { - case value: String => fromString(path, fieldName, schemaType, value, rules) + case value: String => fromString(path, fieldName, schemaType, value, rules) + case _: JsonProperties.Null => Right(AvroNull(path, fieldName)) case _ => (schemaType match { case BOOLEAN => Try(AvroBoolean(path, fieldName, fieldValue.asInstanceOf[Boolean], rules)).toEither @@ -376,9 +377,9 @@ object Avro { schema.getFields.asScala.toSeq .map { schemaField => val fieldName = AvroFieldName(schemaField.name()) - configFields.get(schemaField.name()) match { - case Some(configValue) => configuredField(rootPath, schemaField, fieldName, configValue) - case None => unConfiguredField(rootPath, schemaField, fieldName) + schemaField.schema().getType match { + case UNION => handleUnionField(rootPath, fieldName, schemaField, configFields.get(schemaField.name())) + case _ => handleField(rootPath, fieldName, schemaField, configFields.get(schemaField.name())) } } .partitionMap(identity) match { @@ -387,40 +388,30 @@ object Avro { } } - private def configuredField( - rootPath: PactFieldPath, - schemaField: Schema.Field, - fieldName: AvroFieldName, - configValue: Value - ): Either[Seq[PluginError[_]], AvroValue] = { - schemaField.schema().getType match { - case STRING | INT | LONG | FLOAT | DOUBLE | BOOLEAN | ENUM | FIXED | BYTES | NULL => - AvroValue(rootPath, fieldName, schemaField.schema(), configValue) - case RECORD => AvroRecord(rootPath :+ fieldName, fieldName, schemaField.schema(), configValue.getStructValue.fields) - case ARRAY => AvroArray(rootPath, fieldName, schemaField.schema(), configValue) - case MAP => AvroMap(rootPath, fieldName, schemaField.schema(), configValue) - case UNION => handleAvroUnion(rootPath, schemaField, fieldName, configValue) - } - } - - private def handleAvroUnion(rootPath: PactFieldPath, schemaField: Schema.Field, fieldName: AvroFieldName, configValue: Value) = { + private def handleUnionField(rootPath: PactFieldPath, fieldName: AvroFieldName, schemaField: Schema.Field, mayBeConfigValue: Option[Value]) = { val subTypes = schemaField.schema().getTypes.asScala if (subTypes.size == 2 && subTypes.exists(_.getType == NULL)) { - subTypes.filterNot(_.getType == NULL).headOption match { - case Some(schema) => handleNullableField(rootPath, fieldName, configValue, schema) - case None => Left(Seq(PluginErrorException(FieldInvalidSchemaException(fieldName, configValue)))) + (mayBeConfigValue, subTypes.filterNot(_.getType == NULL).headOption) match { + case (Some(configValue), Some(schema)) => handleConfiguredField(rootPath, fieldName, schema, configValue) + case (None, Some(schema)) => handleDefaultValue(rootPath, fieldName, schemaField, schema) + case (_, _) => Left(Seq(PluginErrorException(FieldInvalidSchemaException(fieldName, mayBeConfigValue)))) } } else { - Left(Seq(PluginErrorException(FieldNotNullableException(fieldName, configValue)))) + Left(Seq(PluginErrorException(FieldNotNullableException(fieldName, mayBeConfigValue)))) } } - private def handleNullableField( + private def handleField( rootPath: PactFieldPath, fieldName: AvroFieldName, - configValue: Value, - schema: Schema - ): Either[Seq[PluginError[_]], AvroValue] = { + schemaField: Schema.Field, + maybeConfigValue: Option[Value] + ): Either[Seq[PluginError[_]], AvroValue] = + maybeConfigValue match + case Some(configValue) => handleConfiguredField(rootPath, fieldName, schemaField.schema(), configValue) + case None => handleNoneConfiguredField(rootPath, fieldName, schemaField) + + private def handleConfiguredField(rootPath: PactFieldPath, fieldName: AvroFieldName, schema: Schema, configValue: Value) = { schema.getType match { case STRING | INT | LONG | FLOAT | DOUBLE | BOOLEAN | ENUM | FIXED | BYTES | NULL => AvroValue(rootPath, fieldName, schema, configValue) case RECORD => AvroRecord(rootPath :+ fieldName, fieldName, schema, configValue.getStructValue.fields) @@ -430,14 +421,29 @@ object Avro { } } - private def unConfiguredField(rootPath: PactFieldPath, schemaField: Schema.Field, fieldName: AvroFieldName): Either[Seq[PluginError[_]], AvroValue] = { + private def handleNoneConfiguredField( + rootPath: PactFieldPath, + fieldName: AvroFieldName, + schemaField: Schema.Field + ): Either[Seq[PluginError[_]], AvroValue] = { if (schemaField.hasDefaultValue) { - AvroValue(rootPath :+ fieldName, fieldName, schemaField.schema().getType, schemaField.defaultVal(), Seq.empty).left.map(e => Seq(e)) - } else if (schemaField.schema().getType == UNION && schemaField.schema().getTypes.asScala.exists(_.getType == NULL)) { - Right(AvroNull(rootPath :+ fieldName, fieldName)) + handleDefaultValue(rootPath, fieldName, schemaField, schemaField.schema()) } else { Left(Seq(PluginErrorException(new Exception(s"Couldn't find configuration for field: ${schemaField.name()}")))) } } + + private def handleDefaultValue( + rootPath: PactFieldPath, + fieldName: AvroFieldName, + schemaField: Schema.Field, + schema: Schema + ): Either[Seq[PluginError[_]], AvroValue] = + Option(schemaField.defaultVal()) match + case Some(value) => AvroValue(rootPath :+ fieldName, fieldName, schema.getType, value, Seq.empty).left.map(e => Seq(e)) + case None => handleNullField(rootPath, fieldName) + + private def handleNullField(rootPath: PactFieldPath, fieldName: AvroFieldName) = + Right(AvroNull(rootPath :+ fieldName, fieldName)) } } diff --git a/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordComplexTypesTest.scala b/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordComplexTypesTest.scala index edbeac6..cc061d8 100644 --- a/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordComplexTypesTest.scala +++ b/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordComplexTypesTest.scala @@ -7,6 +7,7 @@ import com.github.austek.plugin.avro.TestSchemas.* import com.github.austek.plugin.avro.utils.MatchingRuleCategoryImplicits.* import com.google.protobuf.struct.Value.Kind.* import com.google.protobuf.struct.{ListValue as StructListValue, Struct, Value} +import org.apache.avro.Schema.Type.NULL import org.apache.avro.generic.{GenericData, GenericRecord} import org.scalatest.EitherValues import org.scalatest.matchers.should.Matchers @@ -50,7 +51,7 @@ class AvroRecordComplexTypesTest extends AnyWordSpecLike with Matchers with Eith genericRecord.get("color") shouldBe new GenericData.EnumSymbol(schema, "UNKNOWN") } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.color") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -86,7 +87,7 @@ class AvroRecordComplexTypesTest extends AnyWordSpecLike with Matchers with Eith genericRecord.get("md5") shouldBe new GenericData.Fixed(schema.getField("md5").schema(), "\\u0000".getBytes) } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.md5") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -250,4 +251,83 @@ class AvroRecordComplexTypesTest extends AnyWordSpecLike with Matchers with Eith } } } + + "Optional Complex field" when { + val schema = schemaWithField("""{ + | "name": "address", + | "type": [ "null", + | { + | "name": "Address", + | "type": "record", + | "fields": [ + | { "name": "street", "type": "string" } + | ] + | } + | ] + |}""".stripMargin) + "value provided" should provide { + val pactConfiguration = Map("address" -> Value(StructValue(Struct(Map("street" -> Value(StringValue("matching(equalTo, 'first street')"))))))) + val avroRecord = AvroRecord(schema, pactConfiguration).value + val addressSchema = schema.getField("address").schema().getTypes.asScala.filterNot(_.getType == NULL).head + val addressRecord = new GenericData.Record(addressSchema) + addressRecord.put("street", "first street") + + "a method," which { + "returns GenericRecord with field" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("address") shouldBe addressRecord + } + "returns matching rules using JsonPath" in { + avroRecord.matchingRules should have size 1 + avroRecord.matchingRules.getRules("$.address.street") shouldBe List(EqualsMatcher.INSTANCE) + } + } + } + + "value not provided but has default" should provide { + val schema = schemaWithField("""{ + | "name": "address", + | "type": [ "null", + | { + | "name": "Address", + | "type": "record", + | "fields": [ + | { "name": "street", "type": "string", "default": "first street" } + | ] + | } + | ], + | "default": null + |}""".stripMargin) + val pactConfiguration: Map[String, Value] = Map() + val avroRecord = AvroRecord(schema, pactConfiguration).value + val addressSchema = schema.getField("address").schema().getTypes.asScala.filterNot(_.getType == NULL).head + val addressRecord = new GenericData.Record(addressSchema) + addressRecord.put("street", "first street") + + "a method," which { + "returns GenericRecord with field containing default value" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("address") shouldBe null + } + "returns empty matching rules using JsonPath" in { + avroRecord.matchingRules shouldBe empty + } + } + } + + "value not provided" should provide { + val pactConfiguration: Map[String, Value] = Map() + val avroRecord = AvroRecord(schema, pactConfiguration).value + + "a method," which { + "returns GenericRecord with field containing default value" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("address") shouldBe null + } + "returns empty matching rules using JsonPath" in { + avroRecord.matchingRules shouldBe empty + } + } + } + } } diff --git a/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordPrimitiveTypesTest.scala b/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordPrimitiveTypesTest.scala index fe0873c..e1ccaf7 100644 --- a/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordPrimitiveTypesTest.scala +++ b/modules/plugin/src/test/scala/com/github/austek/plugin/avro/AvroRecordPrimitiveTypesTest.scala @@ -13,6 +13,8 @@ import org.scalatest.wordspec.AnyWordSpecLike class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with EitherValues { import com.github.austek.plugin.avro.utils.MatchingRuleCategoryImplicits.given + + private val byteValue = "\\\u0000\\\u0001\\\u0002\\\u0003\\\u0004\\\u0005\\\u0006\\\u0007" def provide: AfterWord = afterWord("provide") "String field" when { @@ -45,7 +47,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("street") shouldBe "NONE" } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.street") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -81,7 +83,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("no").toString.toInt shouldBe 5 } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.no") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -117,7 +119,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("id").toString.toInt shouldBe 100 } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.id") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -153,7 +155,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("width").toString.toDouble shouldBe 1.8d } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.width") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -189,7 +191,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("height").toString.toFloat shouldBe 15.8f } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.height") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -225,7 +227,7 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("enabled").toString.toBoolean shouldBe true } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.enabled") shouldBe empty + avroRecord.matchingRules shouldBe empty } } } @@ -234,15 +236,13 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei "Bytes field" when { "value provided" should provide { val schema = schemaWithField("""{"name": "MAC", "type": "bytes"}""") - val pactConfiguration: Map[String, Value] = Map( - "MAC" -> Value(StringValue("matching(equalTo, '\\\u0000\\\u0001\\\u0002\\\u0003\\\u0004\\\u0005\\\u0006\\\u0007')")) - ) + val pactConfiguration: Map[String, Value] = Map("MAC" -> Value(StringValue(s"matching(equalTo, '$byteValue')"))) val avroRecord = AvroRecord(schema, pactConfiguration).value "a method," which { "returns GenericRecord with field" in { val genericRecord = avroRecord.toGenericRecord(schema) - genericRecord.get("MAC") shouldBe "\\\u0000\\\u0001\\\u0002\\\u0003\\\u0004\\\u0005\\\u0006\\\u0007" + genericRecord.get("MAC") shouldBe byteValue } "returns matching rules using JsonPath" in { avroRecord.matchingRules should have size 1 @@ -263,7 +263,58 @@ class AvroRecordPrimitiveTypesTest extends AnyWordSpecLike with Matchers with Ei genericRecord.get("MAC") shouldBe "\\u0000" } "returns empty matching rules using JsonPath" in { - avroRecord.matchingRules.getRules("$.MAC") shouldBe empty + avroRecord.matchingRules shouldBe empty + } + } + } + } + + "Optional Primitive field" when { + val schema = schemaWithField("""{"name": "MAC", "type": ["null", "bytes"]}""") + "value provided" should provide { + val pactConfiguration: Map[String, Value] = Map("MAC" -> Value(StringValue(s"matching(equalTo, '$byteValue')"))) + val avroRecord = AvroRecord(schema, pactConfiguration).value + + "a method," which { + "returns GenericRecord with field" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("MAC") shouldBe byteValue + } + "returns matching rules using JsonPath" in { + avroRecord.matchingRules should have size 1 + val rules = avroRecord.matchingRules.getRules("$.MAC") + rules shouldBe Seq(EqualsMatcher.INSTANCE) + } + } + } + + "value not provided but has default" should provide { + val schema = schemaWithField("""{"name": "MAC", "type": ["null", "bytes"], "default": null}""") + val pactConfiguration: Map[String, Value] = Map() + val avroRecord = AvroRecord(schema, pactConfiguration).value + + "a method," which { + "returns GenericRecord with field containing default value" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("MAC") shouldBe null + } + "returns empty matching rules using JsonPath" in { + avroRecord.matchingRules shouldBe empty + } + } + } + + "value not provided" should provide { + val pactConfiguration: Map[String, Value] = Map() + val avroRecord = AvroRecord(schema, pactConfiguration).value + + "a method," which { + "returns GenericRecord with field containing default value" in { + val genericRecord = avroRecord.toGenericRecord(schema) + genericRecord.get("MAC") shouldBe null + } + "returns empty matching rules using JsonPath" in { + avroRecord.matchingRules shouldBe empty } } }