From b79d6bb2f05e43647072293a3e0a5a69109fc62f Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Mon, 8 Jun 2026 08:27:17 +0200 Subject: [PATCH 1/5] feat: add Obfuscan workflow for pull requests Adds a new job named Obfuscan to the CI workflow to scan the pull request diff using the ByteBardOrg/obfuscan-action. This job runs only when a pull request is opened, and it uses the head SHA of the pull request to check for potential issues in the code changes before the main build proceeds. --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b00e7d..a022c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,23 @@ on: - '!**/*.md' workflow_dispatch: jobs: + obfuscan: + if: github.event_name == 'pull_request' + name: Obfuscan + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Scan PR diff + uses: ByteBardOrg/obfuscan-action@v1 + with: + fail-on: block + build: runs-on: ${{ matrix.os }} From b9344ca37765679a5041cfebce6fde107402838e Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Mon, 8 Jun 2026 08:51:58 +0200 Subject: [PATCH 2/5] fix(avro)!: support named type references Adds Avro-specific named type handling for schemas that reference previously defined records, enums, or fixed types by name. Introduces `AvroNamedType`, keeps AsyncAPI `$ref` handling unchanged, supports recursive schemas like `LongList`, and widens map `values` to accept any Avro schema instead of only primitive types. Existing construction with `AvroPrimitiveType` is preserved through the existing implicit conversion to `AsyncApiAvroSchema`, and primitive schema values can be converted back with an explicit cast. BREAKING CHANGE: `AvroMap.Values` now uses `AsyncApiAvroSchema` instead of `AvroPrimitiveType`. --- .../Schemas/AsyncApiAvroSchemaDeserializer.cs | 455 +++++++++++++----- .../Models/Avro/AsyncApiAvroSchema.cs | 24 +- src/ByteBard.AsyncAPI/Models/Avro/AvroMap.cs | 16 +- .../Models/Avro/AvroNamedType.cs | 74 +++ .../Services/AsyncApiWalker.cs | 44 +- .../Validation/Rules/AsyncApiAvroRules.cs | 12 + .../Models/AvroSchema_Should.cs | 274 +++++++++++ .../Validation/ValidationRulesetTests.cs | 2 +- 8 files changed, 765 insertions(+), 136 deletions(-) create mode 100644 src/ByteBard.AsyncAPI/Models/Avro/AvroNamedType.cs 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); } } } From e81fb75d87f71e16e7a7689419ad8b05727b0259 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Mon, 8 Jun 2026 08:57:58 +0200 Subject: [PATCH 3/5] refactor: error and warning collection in document reader Changed how validation errors and warnings are added to the diagnostic collection. Previously, all items from the validation result were iterated over. This change explicitly separates the handling of AsyncApiValidatorError into diagnostic.Errors and AsyncApiValidatorWarning into diagnostic.Warnings, ensuring correct categorization of validation feedback. --- .../AsyncApiJsonDocumentReader.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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; From 02742497f2aba63190120698963dd1d9818eb198 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Mon, 8 Jun 2026 09:05:13 +0200 Subject: [PATCH 4/5] docs: add schema wiki page --- docs/Schemas.md | 418 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 docs/Schemas.md diff --git a/docs/Schemas.md b/docs/Schemas.md new file mode 100644 index 0000000..5535903 --- /dev/null +++ b/docs/Schemas.md @@ -0,0 +1,418 @@ +# Schemas + +AsyncAPI.Net supports 3 types of message payloads: + +1. [Schema Object](https://v2.asyncapi.com/docs/reference/specification/v2.6.0#schemaObject) +2. [Avro 1.9.0](https://avro.apache.org/docs/1.9.0/spec.html#schemas) +3. Custom formats + +The payload types are `AsyncApiJsonSchema` and `AsyncApiAvroSchema` respectively. + +Note that `AsyncApiJsonSchema` is implicitly convertable to `AsyncMultiFormatSchema`. + +## JsonSchema + +```csharp +new AsyncApiJsonSchema() +{ + Title = "title1", + AllOf = new List + { + new AsyncApiJsonSchema + { + Title = "title2", + Properties = new Dictionary + { + ["property1"] = new AsyncApiJsonSchema + { + Type = SchemaType.Integer, + }, + ["property2"] = new AsyncApiJsonSchema + { + Type = SchemaType.String, + MaxLength = 15, + }, + }, + }, + new AsyncApiJsonSchema + { + Title = "title3", + Properties = new Dictionary + { + ["property3"] = new AsyncApiJsonSchema + { + Properties = new Dictionary + { + ["property4"] = new AsyncApiJsonSchema + { + Type = SchemaType.Boolean, + }, + }, + }, + ["property5"] = new AsyncApiJsonSchema + { + Type = SchemaType.String, + MinLength = 2, + }, + }, + Nullable = true, + }, + }, + Nullable = true, + ExternalDocs = new AsyncApiExternalDocumentation + { + Url = new Uri("http://example.com/externalDocs"), + }, +}; +``` + +## Avro + +Due to the nature of Avro, the payload type has a helper method `bool TryGetAs(out T schema)` to make the casting logic slightly easier on you. + +The Avro types are implemented through a common base class `AsyncApiAvroSchema`. As Avro supports adding custom properties to the schemas as "metadata", these when deserialized, will be added to the `Metadata` dictionary which exists on all implemented Avro types. + +Supported types: + +* Record +* Fixed +* Enum +* Union +* Map +* Array +* Primitive +* Field +* Named type + +Note, all above types are prefixed "Avro" within the dotnet classes. + +### Named Types + +Avro named schemas can be referenced by name using `AvroNamedType`. This is separate from AsyncAPI `$ref`; Avro named references serialize as plain strings such as `"LongList"`, not as `$ref` objects. + +Named types are resolved using the Avro name and namespace rules. A named schema must be defined before it is referenced, following Avro's depth-first, left-to-right traversal rule. If a name cannot be resolved, validation reports an `AsyncApiValidatorWarning` instead of a validation error. + +Named references can also be used recursively and inside schemas such as arrays, unions, and map values. + +```csharp +new AvroRecord +{ + Name = "LongList", + Fields = new List + { + new AvroField + { + Name = "value", + Type = AvroPrimitiveType.Long, + }, + new AvroField + { + Name = "next", + Type = new AvroUnion + { + Types = new List + { + AvroPrimitiveType.Null, + new AvroNamedType + { + Name = "LongList", + }, + }, + }, + }, + }, +}; +``` + +`AvroMap.Values` accepts any `AsyncApiAvroSchema`, not only primitive types, so maps can use named schemas as values. + +```csharp +new AvroMap +{ + Values = new AvroNamedType + { + Name = "Address", + }, +}; +``` + +## Usage + +```csharp +new AvroRecord +{ + Name = "User", + Namespace = "com.example", + Fields = new List + { + new AvroField + { + Name = "username", + Type = AvroPrimitiveType.String, + Doc = "The username of the user.", + Default = new AsyncApiAny("guest"), + Order = AvroFieldOrder.Ascending, + }, + new AvroField + { + Name = "status", + Type = new AvroEnum + { + Name = "Status", + Symbols = new List { "ACTIVE", "INACTIVE", "BANNED" }, + }, + Doc = "The status of the user.", + }, + new AvroField + { + Name = "emails", + Type = new AvroArray + { + Items = AvroPrimitiveType.String, + }, + Doc = "A list of email addresses.", + }, + new AvroField + { + Name = "metadata", + Type = new AvroMap + { + Values = AvroPrimitiveType.String, + }, + Doc = "Metadata associated with the user.", + }, + new AvroField + { + Name = "address", + Type = new AvroRecord + { + Name = "Address", + Fields = new List + { + new AvroField { Name = "street", Type = AvroPrimitiveType.String }, + new AvroField { Name = "city", Type = AvroPrimitiveType.String }, + new AvroField { Name = "zipcode", Type = AvroPrimitiveType.String }, + }, + }, + Doc = "The address of the user.", + }, + new AvroField + { + Name = "profilePicture", + Type = new AvroFixed + { + Name = "ProfilePicture", + Size = 256, + }, + Doc = "A fixed-size profile picture.", + }, + new AvroField + { + Name = "contact", + Type = new AvroUnion + { + Types = new List + { + AvroPrimitiveType.Null, + new AvroRecord + { + Name = "PhoneNumber", + Fields = new List + { + new AvroField { Name = "countryCode", Type = AvroPrimitiveType.Int }, + new AvroField { Name = "number", Type = AvroPrimitiveType.String }, + }, + }, + }, + }, + Doc = "The contact information of the user, which can be either null or a phone number.", + }, + }, +}; +``` + +## Custom Formats + +You can define custom payloads/formats by implementing `ISchemaParser` and attaching it via to the readers settings. + +```csharp +var settings = new AsyncApiReaderSettings(); +settings.SchemaParserRegistry.RegisterParser(new CustomParser()); + +var reader = new AsyncApiStringReader(settings); +``` + +There are a few moving parts that needs to align. Mainly you will need a model to hold the values and the parser itself. + +### The Model + +**Note: If you don't need to serialize the model, meaning only read and not write it, you don't have to implement the `SerializeV` methods.** + +The typed nature of AsyncAPI.NET is what sets it apart, so of course you need to implement a model to hold the values. + +The following is an excerpt from the JSONSchema implementation. + +```csharp +public class AsyncApiJsonSchema : IAsyncApiSchema +{ + public string Title { get; set; } + public SchemaType? Type { get; set; } + public ISet Required { get; set; } = new HashSet(); + public double? Maximum { get; set; } + public IList AllOf { get; set; } = new List(); + public AsyncApiJsonSchema If { get; set; } + public IDictionary Properties { get; set; } = new Dictionary(); + public IList Enum { get; set; } = new List(); + public AsyncApiAny Const { get; set; } + public bool Nullable { get; set; } + + public void SerializeV2(IAsyncApiWriter writer) + { + this.SerializeCore(writer); + } + + public void SerializeV3(IAsyncApiWriter writer) + { + this.SerializeCore(writer); + } + + private void SerializeCore(IAsyncApiWriter writer) + { + writer.WriteStartObject(); + + // title + writer.WriteOptionalProperty(AsyncApiConstants.Title, this.Title); + + // type + if (this.Type != null) + { + var types = EnumExtensions.GetFlags(this.Type.Value); + if (types.Count() == 1) + { + writer.WriteOptionalProperty(AsyncApiConstants.Type, types.First().GetDisplayName()); + } + else + { + writer.WriteOptionalCollection(AsyncApiConstants.Type, types.Select(t => t.GetDisplayName()), (w, s) => w.WriteValue(s)); + } + } + + // maximum + writer.WriteOptionalProperty(AsyncApiConstants.Maximum, this.Maximum); + + // allOf + writer.WriteOptionalCollection(AsyncApiConstants.AllOf, this.AllOf, (w, s) => s.SerializeV2(w)); + + // uniqueItems + writer.WriteOptionalProperty(AsyncApiConstants.UniqueItems, this.UniqueItems); + + // properties + writer.WriteOptionalMap(AsyncApiConstants.Properties, this.Properties, (w, s) => s.SerializeV2(w)); + + // enum + writer.WriteOptionalCollection(AsyncApiConstants.Enum, this.Enum, (nodeWriter, s) => nodeWriter.WriteAny(s)); + + writer.WriteOptionalObject(AsyncApiConstants.Const, this.Const, (w, s) => w.WriteAny(s)); + + // nullable + writer.WriteOptionalProperty(AsyncApiConstants.Nullable, this.Nullable, false); + + writer.WriteEndObject(); + } +} +``` + +So basically. Define the properties. Define how they are serialized. + +### The Parser + +Deserializers/parsers in AsyncAPI.NET uses maps of fields that takes an `Action` that tells it how to get the proper value out. +You can see an example of this below (excerpt from the JsonSchemaDeserializer). + +```csharp +private static readonly FixedFieldMap schemaFixedFields = new() +{ + { + "title", (a, n) => { a.Title = n.GetScalarValue(); } + }, + { + "type", (a, n) => { a.Type = n.GetScalarValue().GetEnumFromDisplayName(); } + }, + { + "required", + (a, n) => { a.Required = new HashSet(n.CreateSimpleList(n2 => n2.GetScalarValue())); } + }, + { + "maximum", + (a, n) => + { + a.Maximum = double.Parse(n.GetScalarValue(), NumberStyles.Float, n.Context.Settings.CultureInfo); + } + }, + { + "uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); } + }, + { + "enum", (a, n) => { a.Enum = n.CreateListOfAny(); } + }, + { + "const", (a, n) => { a.Const = n.CreateAny(); } + }, + { + "if", (a, n) => { a.If = LoadSchema(n); } + }, + { + "properties", (a, n) => { a.Properties = n.CreateMap(LoadSchema); } + }, + { + "allOf", (a, n) => { a.AllOf = n.CreateList(LoadSchema); } + }, + { + "nullable", (a, n) => { a.Nullable = n.GetBooleanValue(); } + }, +}; +``` + +You'll notice its all just field names from the schema and an action that takes the `ParseNode` and creates the proper structure depending on the type of property. There are extensions for maps, collections, scalars etc. + +The `ISchemaParser` defines 2 methods. + +1. `IAsyncApiSchema LoadSchema(ParseNode node)` +2. `IEnumerable SupportedFormats` + +The first should generally look something like this: + +```csharp +public IAsyncApiSchema LoadSchema(ParseNode node) +{ + // Check that we are dealing with an object and cast accordingly. + var mapNode = node.CheckMapNode("arbitrary string"); + + var schema = new MyCustomSchema(); + + // map each propery against the fixedFieldMap + foreach (var property in mapNode) + { + property.ParseField(schema, schemaFixedFields, null); + } + + return schema; +} +``` + +The latter should simply be the list of formats that should resolve to this schema. For JsonSchema its looks like this: + +```csharp +public IEnumerable SupportedFormats => new List +{ + "application/vnd.aai.asyncapi+json", + "application/vnd.aai.asyncapi+yaml", + "application/vnd.aai.asyncapi", + "application/schema+json;version=draft-07", + "application/schema+yaml;version=draft-07", +} +``` + +And that is all you need to implement a custom schema parser. + +You can check out a full example in the following Unit Test: https://github.com/ByteBardOrg/AsyncAPI.NET/blob/vnext/test/ByteBard.AsyncAPI.Tests/Models/CustomSchema_Should.cs From 0f0d50f6b7a41efe12949a8d3228e7ffa4ff5112 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Mon, 8 Jun 2026 09:18:56 +0200 Subject: [PATCH 5/5] remove docs file --- docs/Schemas.md | 418 ------------------------------------------------ 1 file changed, 418 deletions(-) delete mode 100644 docs/Schemas.md diff --git a/docs/Schemas.md b/docs/Schemas.md deleted file mode 100644 index 5535903..0000000 --- a/docs/Schemas.md +++ /dev/null @@ -1,418 +0,0 @@ -# Schemas - -AsyncAPI.Net supports 3 types of message payloads: - -1. [Schema Object](https://v2.asyncapi.com/docs/reference/specification/v2.6.0#schemaObject) -2. [Avro 1.9.0](https://avro.apache.org/docs/1.9.0/spec.html#schemas) -3. Custom formats - -The payload types are `AsyncApiJsonSchema` and `AsyncApiAvroSchema` respectively. - -Note that `AsyncApiJsonSchema` is implicitly convertable to `AsyncMultiFormatSchema`. - -## JsonSchema - -```csharp -new AsyncApiJsonSchema() -{ - Title = "title1", - AllOf = new List - { - new AsyncApiJsonSchema - { - Title = "title2", - Properties = new Dictionary - { - ["property1"] = new AsyncApiJsonSchema - { - Type = SchemaType.Integer, - }, - ["property2"] = new AsyncApiJsonSchema - { - Type = SchemaType.String, - MaxLength = 15, - }, - }, - }, - new AsyncApiJsonSchema - { - Title = "title3", - Properties = new Dictionary - { - ["property3"] = new AsyncApiJsonSchema - { - Properties = new Dictionary - { - ["property4"] = new AsyncApiJsonSchema - { - Type = SchemaType.Boolean, - }, - }, - }, - ["property5"] = new AsyncApiJsonSchema - { - Type = SchemaType.String, - MinLength = 2, - }, - }, - Nullable = true, - }, - }, - Nullable = true, - ExternalDocs = new AsyncApiExternalDocumentation - { - Url = new Uri("http://example.com/externalDocs"), - }, -}; -``` - -## Avro - -Due to the nature of Avro, the payload type has a helper method `bool TryGetAs(out T schema)` to make the casting logic slightly easier on you. - -The Avro types are implemented through a common base class `AsyncApiAvroSchema`. As Avro supports adding custom properties to the schemas as "metadata", these when deserialized, will be added to the `Metadata` dictionary which exists on all implemented Avro types. - -Supported types: - -* Record -* Fixed -* Enum -* Union -* Map -* Array -* Primitive -* Field -* Named type - -Note, all above types are prefixed "Avro" within the dotnet classes. - -### Named Types - -Avro named schemas can be referenced by name using `AvroNamedType`. This is separate from AsyncAPI `$ref`; Avro named references serialize as plain strings such as `"LongList"`, not as `$ref` objects. - -Named types are resolved using the Avro name and namespace rules. A named schema must be defined before it is referenced, following Avro's depth-first, left-to-right traversal rule. If a name cannot be resolved, validation reports an `AsyncApiValidatorWarning` instead of a validation error. - -Named references can also be used recursively and inside schemas such as arrays, unions, and map values. - -```csharp -new AvroRecord -{ - Name = "LongList", - Fields = new List - { - new AvroField - { - Name = "value", - Type = AvroPrimitiveType.Long, - }, - new AvroField - { - Name = "next", - Type = new AvroUnion - { - Types = new List - { - AvroPrimitiveType.Null, - new AvroNamedType - { - Name = "LongList", - }, - }, - }, - }, - }, -}; -``` - -`AvroMap.Values` accepts any `AsyncApiAvroSchema`, not only primitive types, so maps can use named schemas as values. - -```csharp -new AvroMap -{ - Values = new AvroNamedType - { - Name = "Address", - }, -}; -``` - -## Usage - -```csharp -new AvroRecord -{ - Name = "User", - Namespace = "com.example", - Fields = new List - { - new AvroField - { - Name = "username", - Type = AvroPrimitiveType.String, - Doc = "The username of the user.", - Default = new AsyncApiAny("guest"), - Order = AvroFieldOrder.Ascending, - }, - new AvroField - { - Name = "status", - Type = new AvroEnum - { - Name = "Status", - Symbols = new List { "ACTIVE", "INACTIVE", "BANNED" }, - }, - Doc = "The status of the user.", - }, - new AvroField - { - Name = "emails", - Type = new AvroArray - { - Items = AvroPrimitiveType.String, - }, - Doc = "A list of email addresses.", - }, - new AvroField - { - Name = "metadata", - Type = new AvroMap - { - Values = AvroPrimitiveType.String, - }, - Doc = "Metadata associated with the user.", - }, - new AvroField - { - Name = "address", - Type = new AvroRecord - { - Name = "Address", - Fields = new List - { - new AvroField { Name = "street", Type = AvroPrimitiveType.String }, - new AvroField { Name = "city", Type = AvroPrimitiveType.String }, - new AvroField { Name = "zipcode", Type = AvroPrimitiveType.String }, - }, - }, - Doc = "The address of the user.", - }, - new AvroField - { - Name = "profilePicture", - Type = new AvroFixed - { - Name = "ProfilePicture", - Size = 256, - }, - Doc = "A fixed-size profile picture.", - }, - new AvroField - { - Name = "contact", - Type = new AvroUnion - { - Types = new List - { - AvroPrimitiveType.Null, - new AvroRecord - { - Name = "PhoneNumber", - Fields = new List - { - new AvroField { Name = "countryCode", Type = AvroPrimitiveType.Int }, - new AvroField { Name = "number", Type = AvroPrimitiveType.String }, - }, - }, - }, - }, - Doc = "The contact information of the user, which can be either null or a phone number.", - }, - }, -}; -``` - -## Custom Formats - -You can define custom payloads/formats by implementing `ISchemaParser` and attaching it via to the readers settings. - -```csharp -var settings = new AsyncApiReaderSettings(); -settings.SchemaParserRegistry.RegisterParser(new CustomParser()); - -var reader = new AsyncApiStringReader(settings); -``` - -There are a few moving parts that needs to align. Mainly you will need a model to hold the values and the parser itself. - -### The Model - -**Note: If you don't need to serialize the model, meaning only read and not write it, you don't have to implement the `SerializeV` methods.** - -The typed nature of AsyncAPI.NET is what sets it apart, so of course you need to implement a model to hold the values. - -The following is an excerpt from the JSONSchema implementation. - -```csharp -public class AsyncApiJsonSchema : IAsyncApiSchema -{ - public string Title { get; set; } - public SchemaType? Type { get; set; } - public ISet Required { get; set; } = new HashSet(); - public double? Maximum { get; set; } - public IList AllOf { get; set; } = new List(); - public AsyncApiJsonSchema If { get; set; } - public IDictionary Properties { get; set; } = new Dictionary(); - public IList Enum { get; set; } = new List(); - public AsyncApiAny Const { get; set; } - public bool Nullable { get; set; } - - public void SerializeV2(IAsyncApiWriter writer) - { - this.SerializeCore(writer); - } - - public void SerializeV3(IAsyncApiWriter writer) - { - this.SerializeCore(writer); - } - - private void SerializeCore(IAsyncApiWriter writer) - { - writer.WriteStartObject(); - - // title - writer.WriteOptionalProperty(AsyncApiConstants.Title, this.Title); - - // type - if (this.Type != null) - { - var types = EnumExtensions.GetFlags(this.Type.Value); - if (types.Count() == 1) - { - writer.WriteOptionalProperty(AsyncApiConstants.Type, types.First().GetDisplayName()); - } - else - { - writer.WriteOptionalCollection(AsyncApiConstants.Type, types.Select(t => t.GetDisplayName()), (w, s) => w.WriteValue(s)); - } - } - - // maximum - writer.WriteOptionalProperty(AsyncApiConstants.Maximum, this.Maximum); - - // allOf - writer.WriteOptionalCollection(AsyncApiConstants.AllOf, this.AllOf, (w, s) => s.SerializeV2(w)); - - // uniqueItems - writer.WriteOptionalProperty(AsyncApiConstants.UniqueItems, this.UniqueItems); - - // properties - writer.WriteOptionalMap(AsyncApiConstants.Properties, this.Properties, (w, s) => s.SerializeV2(w)); - - // enum - writer.WriteOptionalCollection(AsyncApiConstants.Enum, this.Enum, (nodeWriter, s) => nodeWriter.WriteAny(s)); - - writer.WriteOptionalObject(AsyncApiConstants.Const, this.Const, (w, s) => w.WriteAny(s)); - - // nullable - writer.WriteOptionalProperty(AsyncApiConstants.Nullable, this.Nullable, false); - - writer.WriteEndObject(); - } -} -``` - -So basically. Define the properties. Define how they are serialized. - -### The Parser - -Deserializers/parsers in AsyncAPI.NET uses maps of fields that takes an `Action` that tells it how to get the proper value out. -You can see an example of this below (excerpt from the JsonSchemaDeserializer). - -```csharp -private static readonly FixedFieldMap schemaFixedFields = new() -{ - { - "title", (a, n) => { a.Title = n.GetScalarValue(); } - }, - { - "type", (a, n) => { a.Type = n.GetScalarValue().GetEnumFromDisplayName(); } - }, - { - "required", - (a, n) => { a.Required = new HashSet(n.CreateSimpleList(n2 => n2.GetScalarValue())); } - }, - { - "maximum", - (a, n) => - { - a.Maximum = double.Parse(n.GetScalarValue(), NumberStyles.Float, n.Context.Settings.CultureInfo); - } - }, - { - "uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); } - }, - { - "enum", (a, n) => { a.Enum = n.CreateListOfAny(); } - }, - { - "const", (a, n) => { a.Const = n.CreateAny(); } - }, - { - "if", (a, n) => { a.If = LoadSchema(n); } - }, - { - "properties", (a, n) => { a.Properties = n.CreateMap(LoadSchema); } - }, - { - "allOf", (a, n) => { a.AllOf = n.CreateList(LoadSchema); } - }, - { - "nullable", (a, n) => { a.Nullable = n.GetBooleanValue(); } - }, -}; -``` - -You'll notice its all just field names from the schema and an action that takes the `ParseNode` and creates the proper structure depending on the type of property. There are extensions for maps, collections, scalars etc. - -The `ISchemaParser` defines 2 methods. - -1. `IAsyncApiSchema LoadSchema(ParseNode node)` -2. `IEnumerable SupportedFormats` - -The first should generally look something like this: - -```csharp -public IAsyncApiSchema LoadSchema(ParseNode node) -{ - // Check that we are dealing with an object and cast accordingly. - var mapNode = node.CheckMapNode("arbitrary string"); - - var schema = new MyCustomSchema(); - - // map each propery against the fixedFieldMap - foreach (var property in mapNode) - { - property.ParseField(schema, schemaFixedFields, null); - } - - return schema; -} -``` - -The latter should simply be the list of formats that should resolve to this schema. For JsonSchema its looks like this: - -```csharp -public IEnumerable SupportedFormats => new List -{ - "application/vnd.aai.asyncapi+json", - "application/vnd.aai.asyncapi+yaml", - "application/vnd.aai.asyncapi", - "application/schema+json;version=draft-07", - "application/schema+yaml;version=draft-07", -} -``` - -And that is all you need to implement a custom schema parser. - -You can check out a full example in the following Unit Test: https://github.com/ByteBardOrg/AsyncAPI.NET/blob/vnext/test/ByteBard.AsyncAPI.Tests/Models/CustomSchema_Should.cs