diff --git a/src/LEGO.AsyncAPI.Readers/V2/AsyncApiAvroSchemaDeserializer.cs b/src/LEGO.AsyncAPI.Readers/V2/AsyncApiAvroSchemaDeserializer.cs index 5015cef2..baae8873 100644 --- a/src/LEGO.AsyncAPI.Readers/V2/AsyncApiAvroSchemaDeserializer.cs +++ b/src/LEGO.AsyncAPI.Readers/V2/AsyncApiAvroSchemaDeserializer.cs @@ -3,8 +3,10 @@ namespace LEGO.AsyncAPI.Readers { using System; + using System.Threading; using LEGO.AsyncAPI.Exceptions; using LEGO.AsyncAPI.Models; + using LEGO.AsyncAPI.Models.Avro.LogicalTypes; using LEGO.AsyncAPI.Readers.Exceptions; using LEGO.AsyncAPI.Readers.ParseNodes; using LEGO.AsyncAPI.Writers; @@ -68,6 +70,60 @@ public class AsyncApiAvroSchemaDeserializer { "types", (a, n) => a.Types = n.CreateList(LoadSchema) }, }; + private static readonly FixedFieldMap DecimalFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + { "precision", (a, n) => a.Precision = int.Parse(n.GetScalarValue()) }, + { "scale", (a, n) => a.Scale = int.Parse(n.GetScalarValue()) }, + }; + + private static readonly FixedFieldMap UUIDFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap DateFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap TimeMillisFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap TimeMicrosFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap TimestampMillisFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap TimestampMicrosFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (a, n) => { } }, + }; + + private static readonly FixedFieldMap DurationFixedFields = new() + { + { "type", (a, n) => { } }, + { "logicalType", (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) => { } }, + }; + private static readonly PatternFieldMap RecordMetadataPatternFields = new() { @@ -110,6 +166,54 @@ public class AsyncApiAvroSchemaDeserializer { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, }; + private static readonly PatternFieldMap DecimalMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap UUIDMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap DateMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap TimeMillisMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap TimeMicrosMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap TimestampMillisMetadataPatternFields = + new() + { + { s => s.StartsWith(string.Empty), (a, p, n) => a.Metadata[p] = n.CreateAny() }, + }; + + private static readonly PatternFieldMap TimestampMicrosMetadataPatternFields = + 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() }, + }; + public static AvroSchema LoadSchema(ParseNode node) { if (node is ValueNode valueNode) @@ -141,8 +245,13 @@ public static AvroSchema LoadSchema(ParseNode node) }; } - var type = mapNode["type"]?.Value.GetScalarValue(); + var isLogicalType = mapNode["logicalType"] != null; + if (isLogicalType) + { + return LoadLogicalType(mapNode); + } + var type = mapNode["type"]?.Value.GetScalarValue(); switch (type) { case "record": @@ -177,6 +286,48 @@ public static AvroSchema LoadSchema(ParseNode node) throw new AsyncApiReaderException("Invalid node type"); } + private static AvroSchema LoadLogicalType(MapNode mapNode) + { + var type = mapNode["logicalType"]?.Value.GetScalarValue(); + switch (type) + { + case "decimal": + var @decimal = new AvroDecimal(); + mapNode.ParseFields(ref @decimal, DecimalFixedFields, DecimalMetadataPatternFields); + return @decimal; + case "uuid": + var uuid = new AvroUUID(); + mapNode.ParseFields(ref uuid, UUIDFixedFields, UUIDMetadataPatternFields); + return uuid; + case "date": + var date = new AvroDate(); + mapNode.ParseFields(ref date, DateFixedFields, DateMetadataPatternFields); + return date; + case "time-millis": + var timeMillis = new AvroTimeMillis(); + mapNode.ParseFields(ref timeMillis, TimeMillisFixedFields, TimeMillisMetadataPatternFields); + return timeMillis; + case "time-micros": + var timeMicros = new AvroTimeMicros(); + mapNode.ParseFields(ref timeMicros, TimeMicrosFixedFields, TimeMicrosMetadataPatternFields); + return timeMicros; + case "timestamp-millis": + var timestampMillis = new AvroTimestampMillis(); + mapNode.ParseFields(ref timestampMillis, TimestampMillisFixedFields, TimestampMillisMetadataPatternFields); + return timestampMillis; + case "timestamp-micros": + var timestampMicros = new AvroTimestampMicros(); + mapNode.ParseFields(ref timestampMicros, TimestampMicrosFixedFields, TimestampMicrosMetadataPatternFields); + return timestampMicros; + case "duration": + var duration = new AvroDuration(); + mapNode.ParseFields(ref duration, DurationFixedFields, DurationMetadataPatternFields); + return duration; + default: + throw new AsyncApiException($"Unsupported type: {type}"); + } + } + private static AvroField LoadField(ParseNode node) { var mapNode = node.CheckMapNode("field"); diff --git a/src/LEGO.AsyncAPI/Models/Avro/AvroMap.cs b/src/LEGO.AsyncAPI/Models/Avro/AvroMap.cs index c66d476b..8b3028c6 100644 --- a/src/LEGO.AsyncAPI/Models/Avro/AvroMap.cs +++ b/src/LEGO.AsyncAPI/Models/Avro/AvroMap.cs @@ -2,7 +2,6 @@ namespace LEGO.AsyncAPI.Models { - using System; using System.Collections.Generic; using System.Linq; using LEGO.AsyncAPI.Writers; diff --git a/src/LEGO.AsyncAPI/Models/Avro/AvroPrimitive.cs b/src/LEGO.AsyncAPI/Models/Avro/AvroPrimitive.cs index d7526344..4d12c792 100644 --- a/src/LEGO.AsyncAPI/Models/Avro/AvroPrimitive.cs +++ b/src/LEGO.AsyncAPI/Models/Avro/AvroPrimitive.cs @@ -20,10 +20,6 @@ public AvroPrimitive(AvroPrimitiveType type) this.Type = type.GetDisplayName(); } - public AvroPrimitive() - { - } - public override void SerializeV2WithoutReference(IAsyncApiWriter writer) { writer.WriteValue(this.Type); diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDate.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDate.cs new file mode 100644 index 00000000..0b01ab0c --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDate.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroDate : AvroLogicalType + { + public AvroDate() + : base(AvroPrimitiveType.Int) + { + } + + public override LogicalType LogicalType => LogicalType.Date; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDecimal.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDecimal.cs new file mode 100644 index 00000000..7c042dbb --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDecimal.cs @@ -0,0 +1,26 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + using LEGO.AsyncAPI.Writers; + + public class AvroDecimal : AvroLogicalType + { + public AvroDecimal() + : base(AvroPrimitiveType.Bytes) + { + } + + public override LogicalType LogicalType => LogicalType.Decimal; + + public int? Scale { get; set; } + + public int? Precision { get; set; } + + public override void SerializeV2Core(IAsyncApiWriter writer) + { + writer.WriteOptionalProperty("scale", this.Scale); + writer.WriteOptionalProperty("precision", this.Precision); + } + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDuration.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDuration.cs new file mode 100644 index 00000000..01469d0b --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroDuration.cs @@ -0,0 +1,42 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + using System.Linq; + using LEGO.AsyncAPI.Writers; + + public class AvroDuration : AvroFixed + { + public LogicalType LogicalType { get; } = LogicalType.Duration; + + public new int Size { get; } = 12; + + public override void SerializeV2WithoutReference(IAsyncApiWriter writer) + { + writer.WriteStartObject(); + writer.WriteOptionalProperty("type", this.Type); + writer.WriteOptionalProperty("logicalType", this.LogicalType.GetDisplayName()); + writer.WriteRequiredProperty("name", this.Name); + writer.WriteOptionalProperty("namespace", this.Namespace); + writer.WriteOptionalCollection("aliases", this.Aliases, (w, s) => w.WriteValue(s)); + writer.WriteRequiredProperty("size", this.Size); + if (this.Metadata.Any()) + { + foreach (var item in this.Metadata) + { + writer.WritePropertyName(item.Key); + if (item.Value == null) + { + writer.WriteNull(); + } + else + { + writer.WriteAny(item.Value); + } + } + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroLogicalType.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroLogicalType.cs new file mode 100644 index 00000000..235899a1 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroLogicalType.cs @@ -0,0 +1,48 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + using System.Linq; + using LEGO.AsyncAPI.Writers; + + public abstract class AvroLogicalType : AvroPrimitive + { + protected AvroLogicalType(AvroPrimitiveType type) + : base(type) + { + } + + public abstract LogicalType LogicalType { get; } + + public virtual void SerializeV2Core(IAsyncApiWriter writer) + { + } + + public override void SerializeV2WithoutReference(IAsyncApiWriter writer) + { + writer.WriteStartObject(); + writer.WriteOptionalProperty("type", this.Type); + writer.WriteOptionalProperty("logicalType", this.LogicalType.GetDisplayName()); + + this.SerializeV2Core(writer); + + if (this.Metadata.Any()) + { + foreach (var item in this.Metadata) + { + writer.WritePropertyName(item.Key); + if (item.Value == null) + { + writer.WriteNull(); + } + else + { + writer.WriteAny(item.Value); + } + } + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMicros.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMicros.cs new file mode 100644 index 00000000..df7dfa59 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMicros.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroTimeMicros : AvroLogicalType + { + public AvroTimeMicros() + : base(AvroPrimitiveType.Long) + { + } + + public override LogicalType LogicalType => LogicalType.Time_Micros; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMillis.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMillis.cs new file mode 100644 index 00000000..71cbb6e2 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimeMillis.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroTimeMillis : AvroLogicalType + { + public AvroTimeMillis() + : base(AvroPrimitiveType.Int) + { + } + + public override LogicalType LogicalType => LogicalType.Time_Millis; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMicros.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMicros.cs new file mode 100644 index 00000000..295f2883 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMicros.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroTimestampMicros : AvroLogicalType + { + public AvroTimestampMicros() + : base(AvroPrimitiveType.Long) + { + } + + public override LogicalType LogicalType => LogicalType.Timestamp_Micros; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMillis.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMillis.cs new file mode 100644 index 00000000..0f03ca0c --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroTimestampMillis.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroTimestampMillis : AvroLogicalType + { + public AvroTimestampMillis() + : base(AvroPrimitiveType.Long) + { + } + + public override LogicalType LogicalType => LogicalType.Timestamp_Millis; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroUUID.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroUUID.cs new file mode 100644 index 00000000..6a7a27a0 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/AvroUUID.cs @@ -0,0 +1,14 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + public class AvroUUID : AvroLogicalType + { + public AvroUUID() + : base(AvroPrimitiveType.String) + { + } + + public override LogicalType LogicalType => LogicalType.UUID; + } +} diff --git a/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/LogicalType.cs b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/LogicalType.cs new file mode 100644 index 00000000..7db53258 --- /dev/null +++ b/src/LEGO.AsyncAPI/Models/Avro/LogicalTypes/LogicalType.cs @@ -0,0 +1,33 @@ +// Copyright (c) The LEGO Group. All rights reserved. + +namespace LEGO.AsyncAPI.Models.Avro.LogicalTypes +{ + using LEGO.AsyncAPI.Attributes; + + public enum LogicalType + { + [Display("decimal")] + Decimal, + + [Display("uuid")] + UUID, + + [Display("date")] + Date, + + [Display("time-millis")] + Time_Millis, + + [Display("time-micros")] + Time_Micros, + + [Display("timestamp-millis")] + Timestamp_Millis, + + [Display("timestamp-micros")] + Timestamp_Micros, + + [Display("duration")] + Duration, + } +} diff --git a/src/LEGO.AsyncAPI/Models/AsyncApiSchema.cs b/src/LEGO.AsyncAPI/Models/JsonSchema/AsyncApiSchema.cs similarity index 100% rename from src/LEGO.AsyncAPI/Models/AsyncApiSchema.cs rename to src/LEGO.AsyncAPI/Models/JsonSchema/AsyncApiSchema.cs diff --git a/src/LEGO.AsyncAPI/Writers/AsyncApiYamlWriter.cs b/src/LEGO.AsyncAPI/Writers/AsyncApiYamlWriter.cs index f7b32dd9..a278ebcd 100644 --- a/src/LEGO.AsyncAPI/Writers/AsyncApiYamlWriter.cs +++ b/src/LEGO.AsyncAPI/Writers/AsyncApiYamlWriter.cs @@ -1,13 +1,13 @@ // Copyright (c) The LEGO Group. All rights reserved. -using System; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; - namespace LEGO.AsyncAPI.Writers { + using System; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Text.RegularExpressions; + /// /// Used to conver an AsyncApi schema into a yaml document. /// diff --git a/test/LEGO.AsyncAPI.Tests/Models/AvroSchema_Should.cs b/test/LEGO.AsyncAPI.Tests/Models/AvroSchema_Should.cs index 2b5b5b0e..0ba1e80e 100644 --- a/test/LEGO.AsyncAPI.Tests/Models/AvroSchema_Should.cs +++ b/test/LEGO.AsyncAPI.Tests/Models/AvroSchema_Should.cs @@ -222,6 +222,63 @@ public void SerializeV2_SerializesCorrectly() .BePlatformAgnosticEquivalentTo(expected); } + [Test] + public void SerializeV2_WithLogicalTypes_SerializesCorrectly() + { + // Arrange + var input = """ + { + "type": "array", + "items": [ + { + "type": "bytes", + "logicalType": "decimal", + "scale": 2, + "precision": 4 + }, + { + "type": "string", + "logicalType": "uuid" + }, + { + "type": "int", + "logicalType": "date" + }, + { + "type": "int", + "logicalType": "time-millis" + }, + { + "type": "long", + "logicalType": "time-micros" + }, + { + "type": "long", + "logicalType": "timestamp-millis" + }, + { + "type": "long", + "logicalType": "timestamp-micros" + }, + { + "type": "fixed", + "logicalType": "duration", + "name": "Duration", + "size": 12 + } + ] + } + """; + + // Act + var model = new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out var diag); + var serialized = model.SerializeAsJson(AsyncApiVersion.AsyncApi2_0); + // Assert + model.As().Items.As().Types.Should().HaveCount(8); + + serialized.Should().BePlatformAgnosticEquivalentTo(input); + } + [Test] public void ReadFragment_DeserializesCorrectly() {