diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs index c02632b..e51742d 100644 --- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs +++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs @@ -165,10 +165,15 @@ public T ReadFragment(JsonNode input, AsyncApiVersion version, out AsyncApiDi if (this.settings.RuleSet != null && this.settings.RuleSet.Rules.Count > 0) { var errors = element.Validate(this.settings.RuleSet); - foreach (var item in errors) + foreach (var item in errors.OfType()) { diagnostic.Errors.Add(item); } + + foreach (var item in errors.OfType()) + { + diagnostic.Warnings.Add(item); + } } return (T)element; diff --git a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs index d67777e..fb749e8 100644 --- a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs @@ -1,5 +1,6 @@ -namespace ByteBard.AsyncAPI.Readers +namespace ByteBard.AsyncAPI.Readers { + using System.Collections.Generic; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Models.Avro.LogicalTypes; @@ -8,61 +9,61 @@ public class AsyncApiAvroSchemaDeserializer { - private static readonly FixedFieldMap FieldFixedFields = new() - { - { "name", (a, n) => a.Name = n.GetScalarValue() }, - { "type", (a, n) => a.Type = LoadSchema(n) }, - { "doc", (a, n) => a.Doc = n.GetScalarValue() }, - { "default", (a, n) => a.Default = n.CreateAny() }, - { "aliases", (a, n) => a.Aliases = n.CreateSimpleList(n2 => n2.GetScalarValue()) }, - { "order", (a, n) => a.Order = n.GetScalarValue().GetEnumFromDisplayName() }, + private static readonly ISet FieldPropertyNames = new HashSet + { + "name", + "type", + "doc", + "default", + "aliases", + "order", }; - private static readonly FixedFieldMap RecordFixedFields = new() + private static readonly ISet RecordPropertyNames = new HashSet { - { "type", (a, n) => { } }, - { "name", (a, n) => a.Name = n.GetScalarValue() }, - { "doc", (a, n) => a.Doc = n.GetScalarValue() }, - { "namespace", (a, n) => a.Namespace = n.GetScalarValue() }, - { "aliases", (a, n) => a.Aliases = n.CreateSimpleList(n2 => n2.GetScalarValue()) }, - { "fields", (a, n) => a.Fields = n.CreateList(LoadField) }, + "type", + "name", + "doc", + "namespace", + "aliases", + "fields", }; - private static readonly FixedFieldMap EnumFixedFields = new() + private static readonly ISet EnumPropertyNames = new HashSet { - { "type", (a, n) => { } }, - { "name", (a, n) => a.Name = n.GetScalarValue() }, - { "doc", (a, n) => a.Doc = n.GetScalarValue() }, - { "namespace", (a, n) => a.Namespace = n.GetScalarValue() }, - { "aliases", (a, n) => a.Aliases = n.CreateSimpleList(n2 => n2.GetScalarValue()) }, - { "symbols", (a, n) => a.Symbols = n.CreateSimpleList(n2 => n2.GetScalarValue()) }, - { "default", (a, n) => a.Default = n.GetScalarValue() }, + "type", + "name", + "doc", + "namespace", + "aliases", + "symbols", + "default", }; - private static readonly FixedFieldMap FixedFixedFields = new() + private static readonly ISet FixedPropertyNames = new HashSet { - { "type", (a, n) => { } }, - { "name", (a, n) => a.Name = n.GetScalarValue() }, - { "namespace", (a, n) => a.Namespace = n.GetScalarValue() }, - { "aliases", (a, n) => a.Aliases = n.CreateSimpleList(n2 => n2.GetScalarValue()) }, - { "size", (a, n) => a.Size = int.Parse(n.GetScalarValue(), n.Context.Settings.CultureInfo) }, + "type", + "name", + "namespace", + "aliases", + "size", }; - private static readonly FixedFieldMap ArrayFixedFields = new() + private static readonly ISet ArrayPropertyNames = new HashSet { - { "type", (a, n) => { } }, - { "items", (a, n) => a.Items = LoadSchema(n) }, + "type", + "items", }; - private static readonly FixedFieldMap MapFixedFields = new() + private static readonly ISet MapPropertyNames = new HashSet { - { "type", (a, n) => { } }, - { "values", (a, n) => a.Values = n.GetScalarValue().GetEnumFromDisplayName() }, + "type", + "values", }; - private static readonly FixedFieldMap UnionFixedFields = new() + private static readonly ISet PrimitivePropertyNames = new HashSet { - { "types", (a, n) => a.Types = n.CreateList(LoadSchema) }, + "type", }; private static readonly FixedFieldMap DecimalFixedFields = new() @@ -119,119 +120,79 @@ public class AsyncApiAvroSchemaDeserializer { "size", (a, n) => { } }, }; - private static readonly PatternFieldMap RecordMetadataPatternFields = - new() + private static readonly PatternFieldMap DecimalMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap FieldMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap EnumMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap FixedMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap ArrayMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap MapMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap UnionMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; - - private static readonly PatternFieldMap DecimalMetadataPatternFields = - new() + private static readonly PatternFieldMap UUIDMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap UUIDMetadataPatternFields = - new() + private static readonly PatternFieldMap DateMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap DateMetadataPatternFields = - new() + private static readonly PatternFieldMap TimeMillisMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap TimeMillisMetadataPatternFields = - new() + private static readonly PatternFieldMap TimeMicrosMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap TimeMicrosMetadataPatternFields = - new() + private static readonly PatternFieldMap TimestampMillisMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap TimestampMillisMetadataPatternFields = - new() + private static readonly PatternFieldMap TimestampMicrosMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap TimestampMicrosMetadataPatternFields = - new() + private static readonly PatternFieldMap DurationMetadataPatternFields = new() { { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; - private static readonly PatternFieldMap DurationMetadataPatternFields = - new() - { - { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, - }; + private readonly Dictionary namedTypes = new Dictionary(); + private readonly Stack namespaces = new Stack(); + + private string CurrentNamespace => this.namespaces.Count > 0 ? this.namespaces.Peek() : null; public static AsyncApiAvroSchema LoadSchema(ParseNode node) { + return new AsyncApiAvroSchemaDeserializer().LoadSchemaCore(node); + } + + private AsyncApiAvroSchema LoadSchemaCore(ParseNode node) + { + if (node is PropertyNode propertyNode) + { + node = propertyNode.Value; + } + if (node is ValueNode valueNode) { - return new AvroPrimitive(valueNode.GetScalarValue().GetEnumFromDisplayName()); + return this.LoadStringSchema(valueNode.GetScalarValue()); } - if (node is ListNode) + if (node is ListNode listNode) { var union = new AvroUnion(); - foreach (var item in node as ListNode) + foreach (var item in listNode) { - union.Types.Add(LoadSchema(item)); + union.Types.Add(this.LoadSchemaCore(item)); } return union; } - if (node is PropertyNode propertyNode) - { - node = propertyNode.Value; - } - if (node is MapNode mapNode) { var pointer = mapNode.GetReferencePointer(); @@ -244,37 +205,32 @@ public static AsyncApiAvroSchema LoadSchema(ParseNode node) var isLogicalType = mapNode["logicalType"] != null; if (isLogicalType) { - return LoadLogicalType(mapNode); + return this.LoadLogicalType(mapNode); } var type = mapNode["type"]?.Value.GetScalarValue(); switch (type) { case "record": - var record = new AvroRecord(); - mapNode.ParseFields(record, RecordFixedFields, RecordMetadataPatternFields); - return record; + return this.LoadRecord(mapNode); case "enum": - var @enum = new AvroEnum(); - mapNode.ParseFields(@enum, EnumFixedFields, EnumMetadataPatternFields); - return @enum; + return this.LoadEnum(mapNode); case "fixed": - var @fixed = new AvroFixed(); - mapNode.ParseFields(@fixed, FixedFixedFields, FixedMetadataPatternFields); - return @fixed; + return this.LoadFixed(mapNode); case "array": - var array = new AvroArray(); - mapNode.ParseFields(array, ArrayFixedFields, ArrayMetadataPatternFields); - return array; + return this.LoadArray(mapNode); case "map": - var map = new AvroMap(); - mapNode.ParseFields(map, MapFixedFields, MapMetadataPatternFields); - return map; + return this.LoadMap(mapNode); case "union": - var union = new AvroUnion(); - mapNode.ParseFields(union, UnionFixedFields, UnionMetadataPatternFields); - return union; + return this.LoadUnion(mapNode); default: + if (type != null) + { + var schema = this.LoadStringSchema(type); + this.ParseMetadata(mapNode, schema.Metadata, PrimitivePropertyNames); + return schema; + } + throw new AsyncApiException($"Unsupported type: {type}"); } } @@ -282,7 +238,135 @@ public static AsyncApiAvroSchema LoadSchema(ParseNode node) throw new AsyncApiReaderException("Invalid node type"); } - private static AsyncApiAvroSchema LoadLogicalType(MapNode mapNode) + private AvroRecord LoadRecord(MapNode mapNode) + { + var record = new AvroRecord + { + Name = this.GetStringValue(mapNode, "name"), + Namespace = this.GetStringValue(mapNode, "namespace"), + Doc = this.GetStringValue(mapNode, "doc"), + }; + + var aliases = mapNode["aliases"]?.Value; + if (aliases != null) + { + record.Aliases = aliases.CreateSimpleList(n => n.GetScalarValue()); + } + + this.RegisterNamedType(record, record.Name, record.Namespace); + + this.namespaces.Push(this.GetNamespaceForNamedType(record.Name, record.Namespace)); + try + { + var fields = mapNode["fields"]?.Value; + if (fields != null) + { + record.Fields = fields.CreateList(this.LoadField); + } + } + finally + { + this.namespaces.Pop(); + } + + this.ParseMetadata(mapNode, record.Metadata, RecordPropertyNames); + return record; + } + + private AvroEnum LoadEnum(MapNode mapNode) + { + var @enum = new AvroEnum + { + Name = this.GetStringValue(mapNode, "name"), + Namespace = this.GetStringValue(mapNode, "namespace"), + Doc = this.GetStringValue(mapNode, "doc"), + Default = this.GetStringValue(mapNode, "default"), + }; + + var aliases = mapNode["aliases"]?.Value; + if (aliases != null) + { + @enum.Aliases = aliases.CreateSimpleList(n => n.GetScalarValue()); + } + + var symbols = mapNode["symbols"]?.Value; + if (symbols != null) + { + @enum.Symbols = symbols.CreateSimpleList(n => n.GetScalarValue()); + } + + this.RegisterNamedType(@enum, @enum.Name, @enum.Namespace); + this.ParseMetadata(mapNode, @enum.Metadata, EnumPropertyNames); + return @enum; + } + + private AvroFixed LoadFixed(MapNode mapNode) + { + var @fixed = new AvroFixed + { + Name = this.GetStringValue(mapNode, "name"), + Namespace = this.GetStringValue(mapNode, "namespace"), + }; + + var aliases = mapNode["aliases"]?.Value; + if (aliases != null) + { + @fixed.Aliases = aliases.CreateSimpleList(n => n.GetScalarValue()); + } + + var size = mapNode["size"]?.Value; + if (size != null) + { + @fixed.Size = int.Parse(size.GetScalarValue(), size.Context.Settings.CultureInfo); + } + + this.RegisterNamedType(@fixed, @fixed.Name, @fixed.Namespace); + this.ParseMetadata(mapNode, @fixed.Metadata, FixedPropertyNames); + return @fixed; + } + + private AvroArray LoadArray(MapNode mapNode) + { + var array = new AvroArray(); + var items = mapNode["items"]?.Value; + if (items != null) + { + array.Items = this.LoadSchemaCore(items); + } + + this.ParseMetadata(mapNode, array.Metadata, ArrayPropertyNames); + return array; + } + + private AvroMap LoadMap(MapNode mapNode) + { + var map = new AvroMap(); + var values = mapNode["values"]?.Value; + if (values != null) + { + map.Values = this.LoadSchemaCore(values); + } + + this.ParseMetadata(mapNode, map.Metadata, MapPropertyNames); + return map; + } + + private AvroUnion LoadUnion(MapNode mapNode) + { + var union = new AvroUnion(); + var types = mapNode["types"]?.Value; + if (types is ListNode listNode) + { + foreach (var item in listNode) + { + union.Types.Add(this.LoadSchemaCore(item)); + } + } + + return union; + } + + private AsyncApiAvroSchema LoadLogicalType(MapNode mapNode) { var type = mapNode["logicalType"]?.Value.GetScalarValue(); switch (type) @@ -318,21 +402,136 @@ private static AsyncApiAvroSchema LoadLogicalType(MapNode mapNode) case "duration": var duration = new AvroDuration(); mapNode.ParseFields(duration, DurationFixedFields, DurationMetadataPatternFields); + this.RegisterNamedType(duration, duration.Name, duration.Namespace); return duration; default: throw new AsyncApiException($"Unsupported type: {type}"); } } - private static AvroField LoadField(ParseNode node) + private AvroField LoadField(ParseNode node) { var mapNode = node.CheckMapNode("field"); - var field = new AvroField(); + var field = new AvroField + { + Name = this.GetStringValue(mapNode, "name"), + Doc = this.GetStringValue(mapNode, "doc"), + }; - mapNode.ParseFields(field, FieldFixedFields, FieldMetadataPatternFields); + var type = mapNode["type"]?.Value; + if (type != null) + { + field.Type = this.LoadSchemaCore(type); + } + + var @default = mapNode["default"]?.Value; + if (@default != null) + { + field.Default = @default.CreateAny(); + } + var aliases = mapNode["aliases"]?.Value; + if (aliases != null) + { + field.Aliases = aliases.CreateSimpleList(n => n.GetScalarValue()); + } + + var order = mapNode["order"]?.Value; + if (order != null) + { + field.Order = order.GetScalarValue().GetEnumFromDisplayName(); + } + + this.ParseMetadata(mapNode, field.Metadata, FieldPropertyNames); return field; + } + private AsyncApiAvroSchema LoadStringSchema(string type) + { + if (this.IsPrimitiveType(type)) + { + return new AvroPrimitive(type.GetEnumFromDisplayName()); + } + + var fullName = this.GetReferenceFullName(type); + this.namedTypes.TryGetValue(fullName, out var target); + return new AvroNamedType(type, target); + } + + private void RegisterNamedType(AsyncApiAvroSchema schema, string name, string @namespace) + { + var fullName = this.GetFullName(name, @namespace); + if (fullName != null) + { + this.namedTypes[fullName] = schema; + } + } + + private string GetReferenceFullName(string name) + { + if (name == null || name.IndexOf('.') >= 0) + { + return name; + } + + var @namespace = this.CurrentNamespace; + return string.IsNullOrEmpty(@namespace) ? name : $"{@namespace}.{name}"; + } + + private string GetFullName(string name, string @namespace) + { + if (name == null || name.IndexOf('.') >= 0) + { + return name; + } + + @namespace ??= this.CurrentNamespace; + return string.IsNullOrEmpty(@namespace) ? name : $"{@namespace}.{name}"; + } + + private string GetNamespaceForNamedType(string name, string @namespace) + { + if (name != null && name.IndexOf('.') >= 0) + { + var lastDot = name.LastIndexOf('.'); + return lastDot > 0 ? name.Substring(0, lastDot) : string.Empty; + } + + return @namespace ?? this.CurrentNamespace; + } + + private string GetStringValue(MapNode mapNode, string propertyName) + { + return mapNode[propertyName]?.Value.GetScalarValue(); + } + + private bool IsPrimitiveType(string type) + { + switch (type) + { + case "null": + case "boolean": + case "int": + case "long": + case "float": + case "double": + case "bytes": + case "string": + return true; + default: + return false; + } + } + + private void ParseMetadata(MapNode mapNode, IDictionary metadata, ISet fixedFields) + { + foreach (var propertyNode in mapNode) + { + if (!fixedFields.Contains(propertyNode.Name)) + { + metadata[propertyNode.Name] = propertyNode.Value.CreateAny(); + } + } } } -} \ No newline at end of file +} diff --git a/src/ByteBard.AsyncAPI/Models/Avro/AsyncApiAvroSchema.cs b/src/ByteBard.AsyncAPI/Models/Avro/AsyncApiAvroSchema.cs index bf847de..1ba1de5 100644 --- a/src/ByteBard.AsyncAPI/Models/Avro/AsyncApiAvroSchema.cs +++ b/src/ByteBard.AsyncAPI/Models/Avro/AsyncApiAvroSchema.cs @@ -1,5 +1,6 @@ namespace ByteBard.AsyncAPI.Models { + using System; using System.Collections.Generic; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Writers; @@ -18,6 +19,27 @@ public static implicit operator AsyncApiAvroSchema(AvroPrimitiveType type) return new AvroPrimitive(type); } + public static explicit operator AvroPrimitiveType(AsyncApiAvroSchema schema) + { + if (schema is AvroPrimitive primitive) + { + return primitive.Type switch + { + "null" => AvroPrimitiveType.Null, + "boolean" => AvroPrimitiveType.Boolean, + "int" => AvroPrimitiveType.Int, + "long" => AvroPrimitiveType.Long, + "float" => AvroPrimitiveType.Float, + "double" => AvroPrimitiveType.Double, + "bytes" => AvroPrimitiveType.Bytes, + "string" => AvroPrimitiveType.String, + _ => throw new InvalidCastException($"Avro schema type '{primitive.Type}' is not a primitive type."), + }; + } + + throw new InvalidCastException($"Avro schema type '{schema?.Type}' is not a primitive type."); + } + public abstract void SerializeV2(IAsyncApiWriter writer); public abstract void SerializeV3(IAsyncApiWriter writer); @@ -41,4 +63,4 @@ public virtual T As() return this as T; } } -} \ No newline at end of file +} diff --git a/src/ByteBard.AsyncAPI/Models/Avro/AvroMap.cs b/src/ByteBard.AsyncAPI/Models/Avro/AvroMap.cs index 3799c7a..55db22b 100644 --- a/src/ByteBard.AsyncAPI/Models/Avro/AvroMap.cs +++ b/src/ByteBard.AsyncAPI/Models/Avro/AvroMap.cs @@ -1,5 +1,6 @@ namespace ByteBard.AsyncAPI.Models { + using System; using System.Collections.Generic; using System.Linq; using ByteBard.AsyncAPI.Writers; @@ -8,7 +9,7 @@ public class AvroMap : AsyncApiAvroSchema { public override string Type { get; } = "map"; - public AvroPrimitiveType Values { get; set; } + public AsyncApiAvroSchema Values { get; set; } /// /// A map of properties not in the schema, but added as additional metadata. @@ -17,19 +18,24 @@ public class AvroMap : AsyncApiAvroSchema public override void SerializeV2(IAsyncApiWriter writer) { - this.SerializeCore(writer); + this.SerializeCore(writer, (w, s) => s.SerializeV2(w)); } public override void SerializeV3(IAsyncApiWriter writer) { - this.SerializeCore(writer); + this.SerializeCore(writer, (w, s) => s.SerializeV3(w)); } public void SerializeCore(IAsyncApiWriter writer) + { + this.SerializeCore(writer, (w, s) => s.SerializeV2(w)); + } + + private void SerializeCore(IAsyncApiWriter writer, Action action) { writer.WriteStartObject(); writer.WriteOptionalProperty("type", this.Type); - writer.WriteRequiredProperty("values", this.Values.GetDisplayName()); + writer.WriteRequiredObject("values", this.Values, action); if (this.Metadata.Any()) { foreach (var item in this.Metadata) @@ -49,4 +55,4 @@ public void SerializeCore(IAsyncApiWriter writer) writer.WriteEndObject(); } } -} \ No newline at end of file +} diff --git a/src/ByteBard.AsyncAPI/Models/Avro/AvroNamedType.cs b/src/ByteBard.AsyncAPI/Models/Avro/AvroNamedType.cs new file mode 100644 index 0000000..1f53a2e --- /dev/null +++ b/src/ByteBard.AsyncAPI/Models/Avro/AvroNamedType.cs @@ -0,0 +1,74 @@ +namespace ByteBard.AsyncAPI.Models +{ + using System.Collections.Generic; + using ByteBard.AsyncAPI.Writers; + + public class AvroNamedType : AsyncApiAvroSchema + { + private IDictionary metadata = new Dictionary(); + + public AvroNamedType(string name, AsyncApiAvroSchema target = null) + { + this.Name = name; + this.Target = target; + } + + public string Name { get; set; } + + public AsyncApiAvroSchema Target { get; set; } + + public override string Type => this.Name; + + public override IDictionary Metadata + { + get => this.Target?.Metadata ?? this.metadata; + set + { + if (this.Target != null) + { + this.Target.Metadata = value; + return; + } + + this.metadata = value ?? new Dictionary(); + } + } + + public override T As() + { + var result = base.As(); + return result ?? this.Target?.As(); + } + + public override bool Is() + { + return base.Is() || this.Target?.Is() == true; + } + + public override bool TryGetAs(out T result) + { + if (base.TryGetAs(out result)) + { + return true; + } + + if (this.Target != null) + { + return this.Target.TryGetAs(out result); + } + + result = default; + return false; + } + + public override void SerializeV2(IAsyncApiWriter writer) + { + writer.WriteValue(this.Name); + } + + public override void SerializeV3(IAsyncApiWriter writer) + { + writer.WriteValue(this.Name); + } + } +} diff --git a/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs b/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs index 21c610d..5fc5e7a 100644 --- a/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs +++ b/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs @@ -9,6 +9,7 @@ public class AsyncApiWalker { private readonly AsyncApiVisitorBase visitor; private readonly Stack schemaLoop = new(); + private readonly Stack avroSchemaLoop = new(); public AsyncApiWalker(AsyncApiVisitorBase visitor) { @@ -23,6 +24,7 @@ public void Walk(AsyncApiDocument doc) } this.schemaLoop.Clear(); + this.avroSchemaLoop.Clear(); this.visitor.Visit(doc); @@ -391,13 +393,53 @@ internal void Walk(IAsyncApiSchema payload) internal void Walk(AsyncApiAvroSchema schema) { + if (schema == null) + { + return; + } + if (schema is AsyncApiAvroSchemaReference reference) { this.Walk(reference as IAsyncApiReferenceable); return; } + if (this.avroSchemaLoop.Contains(schema)) + { + return; + } + + this.avroSchemaLoop.Push(schema); + this.visitor.Visit(schema); + + switch (schema) + { + case AvroRecord record: + this.Walk("fields", () => + { + foreach (var field in record.Fields) + { + this.Walk(field.Name, () => this.Walk("type", () => this.Walk(field.Type))); + } + }); + break; + case AvroArray array: + this.Walk("items", () => this.Walk(array.Items)); + break; + case AvroMap map: + this.Walk("values", () => this.Walk(map.Values)); + break; + case AvroUnion union: + foreach (var type in union.Types) + { + this.Walk("types", () => this.Walk(type)); + } + + break; + } + + this.avroSchemaLoop.Pop(); } internal void Walk(AsyncApiJsonSchema schema) @@ -1218,4 +1260,4 @@ public void Walk(IAsyncApiElement element) } } } -} \ No newline at end of file +} diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiAvroRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiAvroRules.cs index 78cf1c3..0a289be 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiAvroRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiAvroRules.cs @@ -58,5 +58,17 @@ public static class AsyncApiMessagePayloadRules context.Exit(); }); + + public static ValidationRule NamedTypeMustResolve => + new ValidationRule( + (context, schema) => + { + if (schema is AvroNamedType namedType && namedType.Target == null) + { + context.CreateWarning( + nameof(NamedTypeMustResolve), + $"Avro named type '{namedType.Name}' is referenced but was not defined before use."); + } + }); } } diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AvroSchema_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AvroSchema_Should.cs index 9d6d57b..30a56d3 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AvroSchema_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AvroSchema_Should.cs @@ -1,9 +1,12 @@ namespace ByteBard.AsyncAPI.Tests.Models { using System.Collections.Generic; + using System.Linq; using FluentAssertions; + using ByteBard.AsyncAPI.Extensions; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Readers; + using ByteBard.AsyncAPI.Validations; using NUnit.Framework; public class AvroSchema_Should @@ -462,5 +465,276 @@ public void V2_ReadFragment_DeserializesCorrectly() actual.Should() .BeEquivalentTo(expected); } + + [Test] + public void V2_ReadFragment_WithRecursiveNamedType_DeserializesCorrectly() + { + var input = """ + { + "type": "record", + "name": "LongList", + "aliases": ["LinkedLongs"], + "fields" : [ + {"name": "value", "type": "long"}, + {"name": "next", "type": ["null", "LongList"]} + ] + } + """; + + var actual = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + + var record = actual.As(); + var union = record.Fields[1].Type.As(); + var namedType = union.Types[1].As(); + + namedType.Name.Should().Be("LongList"); + namedType.Target.Should().BeSameAs(record); + + var serialized = actual.SerializeAsJson(AsyncApiVersion.AsyncApi2_0); + serialized.Should().Contain("\"LongList\""); + serialized.Should().NotContain("$ref"); + + actual.Validate(ValidationRuleSet.GetDefaultRuleSet()) + .OfType() + .Should() + .BeEmpty(); + } + + [Test] + public void V2_Serialize_WithRecursiveNamedType_WritesNamedTypeAsString() + { + var expected = """ + type: record + name: LongList + fields: + - name: value + type: long + - name: next + type: + - 'null' + - LongList + """; + + var record = new AvroRecord + { + Name = "LongList", + }; + + record.Fields = new List + { + new AvroField + { + Name = "value", + Type = AvroPrimitiveType.Long, + }, + new AvroField + { + Name = "next", + Type = new AvroUnion + { + Types = new List + { + AvroPrimitiveType.Null, + new AvroNamedType("LongList", record), + }, + }, + }, + }; + + var actual = record.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + actual.Should().BePlatformAgnosticEquivalentTo(expected); + } + + [Test] + public void V2_ReadFragment_WithMapValuesNamedType_DeserializesCorrectly() + { + var input = """ + { + "type": "record", + "name": "Container", + "namespace": "example", + "fields" : [ + { + "name": "item", + "type": { + "type": "record", + "name": "Item", + "fields": [ + {"name": "id", "type": "string"} + ] + } + }, + { + "name": "itemsByKey", + "type": { + "type": "map", + "values": "Item" + } + } + ] + } + """; + + var actual = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + + var record = actual.As(); + var item = record.Fields[0].Type.As(); + var map = record.Fields[1].Type.As(); + var namedType = map.Values.As(); + + namedType.Name.Should().Be("Item"); + namedType.Target.Should().BeSameAs(item); + + var serialized = actual.SerializeAsJson(AsyncApiVersion.AsyncApi2_0); + serialized.Should().Contain("\"values\": \"Item\""); + serialized.Should().NotContain("$ref"); + + actual.Validate(ValidationRuleSet.GetDefaultRuleSet()) + .OfType() + .Should() + .BeEmpty(); + } + + [Test] + public void V2_Validate_WithUnresolvedNamedType_CreatesWarning() + { + var input = """ + { + "type": "record", + "name": "Container", + "fields" : [ + {"name": "missing", "type": "MissingType"} + ] + } + """; + + var actual = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + diagnostic.Warnings.Should() + .ContainSingle(w => w.Message == "Avro named type 'MissingType' is referenced but was not defined before use."); + + actual.Validate(ValidationRuleSet.GetDefaultRuleSet()) + .OfType() + .Should() + .ContainSingle(w => w.Message == "Avro named type 'MissingType' is referenced but was not defined before use."); + } + + [Test] + public void V2_Validate_WithUnresolvedMapValuesNamedType_CreatesWarning() + { + var input = """ + { + "type": "record", + "name": "Container", + "fields" : [ + { + "name": "itemsByKey", + "type": { + "type": "map", + "values": "MissingType" + } + } + ] + } + """; + + var actual = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + diagnostic.Warnings.Should() + .ContainSingle(w => w.Message == "Avro named type 'MissingType' is referenced but was not defined before use."); + + actual.Validate(ValidationRuleSet.GetDefaultRuleSet()) + .OfType() + .Should() + .ContainSingle(w => w.Message == "Avro named type 'MissingType' is referenced but was not defined before use."); + } + + [Test] + public void V2_ReadDocument_WithRecursiveNamedType_DeserializesAndValidates() + { + var input = """ + asyncapi: '2.6.0' + info: + title: Avro named type test + version: '1.0.0' + channels: + list: + publish: + message: + name: ListMessage + payload: + type: record + name: LongList + fields: + - name: value + type: long + - name: next + type: + - 'null' + - LongList + schemaFormat: application/vnd.apache.avro + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + diagnostic.Warnings.Should().BeEmpty(); + + var message = document.Operations.Values.First(operation => operation.Action == AsyncApiAction.Receive).Messages.First(); + var record = message.Payload.Schema.As(); + var union = record.Fields[1].Type.As(); + var namedType = union.Types[1].As(); + + namedType.Name.Should().Be("LongList"); + namedType.Target.Should().BeSameAs(record); + } + + [Test] + public void V2_ReadDocument_WithUnresolvedNamedType_CreatesWarning() + { + var input = """ + asyncapi: '2.6.0' + info: + title: Avro named type test + version: '1.0.0' + channels: + list: + publish: + message: + name: ListMessage + payload: + type: record + name: LongList + fields: + - name: value + type: long + - name: next + type: + - 'null' + - MissingType + schemaFormat: application/vnd.apache.avro + """; + + new AsyncApiStringReader().Read(input, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + diagnostic.Warnings.Should() + .ContainSingle(w => w.Message == "Avro named type 'MissingType' is referenced but was not defined before use."); + } + + [Test] + public void V2_AvroSchema_WithPrimitiveSchema_ConvertsToPrimitiveType() + { + AsyncApiAvroSchema schema = AvroPrimitiveType.String; + + ((AvroPrimitiveType)schema).Should().Be(AvroPrimitiveType.String); + } } } diff --git a/test/ByteBard.AsyncAPI.Tests/Validation/ValidationRulesetTests.cs b/test/ByteBard.AsyncAPI.Tests/Validation/ValidationRulesetTests.cs index b5ad517..6a92c42 100644 --- a/test/ByteBard.AsyncAPI.Tests/Validation/ValidationRulesetTests.cs +++ b/test/ByteBard.AsyncAPI.Tests/Validation/ValidationRulesetTests.cs @@ -33,7 +33,7 @@ public void V2_DefaultRuleSet_PropertyReturnsTheCorrectRules() Assert.IsNotEmpty(rules); // Update the number if you add new default rule(s). - Assert.AreEqual(28, rules.Count); + Assert.AreEqual(29, rules.Count); } } }