diff --git a/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs index a8275f2..7106b9b 100644 --- a/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs @@ -1,7 +1,9 @@ namespace ByteBard.AsyncAPI.Readers { + using System; using System.Collections.Generic; using System.Linq; + using ByteBard.AsyncAPI.Bindings; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Extensions; using ByteBard.AsyncAPI.Models; @@ -25,29 +27,51 @@ public static T LoadBinding(string nodeName, ParseNode node, FixedFieldMap internal static AsyncApiBindings LoadBindings( MapNode mapNode, IDictionary> parsers, - PatternFieldMap> patternFields) + PatternFieldMap> patternFields, + AsyncApiVersion version = AsyncApiVersion.AsyncApi2_0) where TBinding : IBinding { var context = mapNode.Context; var bindings = new AsyncApiBindings(); + + // TBinding is the binding interface (e.g. IChannelBinding), so drop the leading "I" for the diagnostics. + var bindingType = typeof(TBinding).Name.Substring(1); foreach (var property in mapNode) { - if (patternFields.Keys.Any(isMatch => isMatch(property.Name))) + // A registered key is read as that binding, even when it carries the x- prefix itself. + var key = property.Name; + if (!parsers.ContainsKey(key)) { - continue; + if (version == AsyncApiVersion.AsyncApi3_0 && TryGetCustomBindingKey(key, parsers, out var customKey)) + { + // AsyncAPI 3 writes a custom binding as an extension (x-); read it back as the binding. + key = customKey; + } + else if (patternFields.Keys.Any(isMatch => isMatch(key))) + { + property.ParseField(bindings, patternFields); + continue; + } } - if (!parsers.TryGetValue(property.Name, out var parser)) + if (!parsers.TryGetValue(key, out var parser)) { - // TBinding is the binding interface (e.g. IChannelBinding), so drop the leading "I" for the diagnostic. - var bindingType = typeof(TBinding).Name.Substring(1); - // Reported as an error because the binding is dropped from the model and cannot be written back. context.Diagnostic.Errors.Add( - new AsyncApiError(context.GetLocation(), $"{bindingType} {property.Name} is not found")); + new AsyncApiError(context.GetLocation(), $"{bindingType} {key} is not found")); continue; } + if (version == AsyncApiVersion.AsyncApi3_0 && AsyncApiBindingKeys.ToV3Key(key) != property.Name) + { + // AsyncAPI 3 bindings objects only allow the specification's binding names and extensions. The binding + // is still read, and written back as x-. + context.Diagnostic.Errors.Add( + new AsyncApiError( + context.GetLocation(), + $"{bindingType} {key} is not an AsyncAPI 3 binding name; write a custom binding as {AsyncApiBindingKeys.ToV3Key(key)}")); + } + try { var binding = parser.LoadBinding(property); @@ -63,11 +87,31 @@ internal static AsyncApiBindings LoadBindings( } } - mapNode.ParseFields(bindings, patternFields); - return bindings; } + private static bool TryGetCustomBindingKey( + string propertyName, + IDictionary> parsers, + out string key) + where TBinding : IBinding + { + key = null; + if (!propertyName.StartsWith(AsyncApiConstants.ExtensionFieldNamePrefix, StringComparison.Ordinal)) + { + return false; + } + + var candidate = propertyName.Substring(AsyncApiConstants.ExtensionFieldNamePrefix.Length); + if (AsyncApiBindingKeys.IsSpecificationKey(candidate) || !parsers.ContainsKey(candidate)) + { + return false; + } + + key = candidate; + return true; + } + private static PatternFieldMap BindingPatternExtensionFields() where T : IBinding, new() { diff --git a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiChannelBindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiChannelBindingDeserializer.cs index 0133fee..cbf9f5c 100644 --- a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiChannelBindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiChannelBindingDeserializer.cs @@ -22,7 +22,7 @@ internal static AsyncApiBindings LoadChannelBindings(ParseNode return new AsyncApiBindingsReference(pointer); } - return BindingDeserializer.LoadBindings(mapNode, node.Context.ChannelBindingParsers, channelBindingPatternFields); + return BindingDeserializer.LoadBindings(mapNode, node.Context.ChannelBindingParsers, channelBindingPatternFields, AsyncApiVersion.AsyncApi3_0); } } } diff --git a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiMessageBindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiMessageBindingDeserializer.cs index 5b6e8a0..4a9f787 100644 --- a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiMessageBindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiMessageBindingDeserializer.cs @@ -22,7 +22,7 @@ internal static AsyncApiBindings LoadMessageBindings(ParseNode return new AsyncApiBindingsReference(pointer); } - return BindingDeserializer.LoadBindings(mapNode, node.Context.MessageBindingParsers, messageBindingPatternFields); + return BindingDeserializer.LoadBindings(mapNode, node.Context.MessageBindingParsers, messageBindingPatternFields, AsyncApiVersion.AsyncApi3_0); } } } diff --git a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiOperationBindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiOperationBindingDeserializer.cs index 38086fb..fc925cd 100644 --- a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiOperationBindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiOperationBindingDeserializer.cs @@ -22,7 +22,7 @@ internal static AsyncApiBindings LoadOperationBindings(ParseN return new AsyncApiBindingsReference(pointer); } - return BindingDeserializer.LoadBindings(mapNode, node.Context.OperationBindingParsers, operationBindingPatternFields); + return BindingDeserializer.LoadBindings(mapNode, node.Context.OperationBindingParsers, operationBindingPatternFields, AsyncApiVersion.AsyncApi3_0); } } } diff --git a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiServerBindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiServerBindingDeserializer.cs index f38d4f9..0060055 100644 --- a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiServerBindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiServerBindingDeserializer.cs @@ -22,7 +22,7 @@ internal static AsyncApiBindings LoadServerBindings(ParseNode no return new AsyncApiBindingsReference(pointer); } - return BindingDeserializer.LoadBindings(mapNode, node.Context.ServerBindingParsers, serverBindingPatternFields); + return BindingDeserializer.LoadBindings(mapNode, node.Context.ServerBindingParsers, serverBindingPatternFields, AsyncApiVersion.AsyncApi3_0); } } } diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs new file mode 100644 index 0000000..b5f87d2 --- /dev/null +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs @@ -0,0 +1,53 @@ +namespace ByteBard.AsyncAPI.Bindings +{ + using System; + using System.Collections.Generic; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Models.Interfaces; + + /// + /// The binding names AsyncAPI 3 defines, and the key a binding is written under in an AsyncAPI 3 document. + /// + /// + /// In AsyncAPI 3 the server, channel, operation and message bindings objects only allow the protocol names + /// the specification lists, plus x- extensions. A custom binding (one whose + /// is not listed) is therefore written as x-<key>. AsyncAPI 2 allows any key, so it keeps the key as is. + /// + public static class AsyncApiBindingKeys + { + // Every binding the AsyncAPI 3.0 and 3.1 specifications name in a bindings object. The prose is normative; the + // JSON schemas omit mqtt5 and mercure, but the specification text lists them as fixed fields. + private static readonly HashSet SpecificationKeys = new HashSet(StringComparer.Ordinal) + { + "amqp", "amqp1", "anypointmq", "googlepubsub", "http", "ibmmq", "jms", "kafka", "mercure", "mqtt", "mqtt5", + "nats", "pulsar", "redis", "ros2", "sns", "solace", "sqs", "stomp", "ws", + }; + + /// + /// Whether AsyncAPI 3 defines a binding with this name. + /// + /// The binding key. + /// true for a binding the specification lists; false otherwise, including for x- keys. + public static bool IsSpecificationKey(string key) + { + return key != null && SpecificationKeys.Contains(key); + } + + /// + /// The property name a binding is written under in an AsyncAPI 3 bindings object. + /// + /// The binding key. + /// The key itself for a specification binding or an x- key; otherwise the key with an x- prefix. + public static string ToV3Key(string key) + { + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + return IsSpecificationKey(key) || key.StartsWith(AsyncApiConstants.ExtensionFieldNamePrefix, StringComparison.Ordinal) + ? key + : AsyncApiConstants.ExtensionFieldNamePrefix + key; + } + } +} diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs index 4a2149b..ebe0a02 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; + using ByteBard.AsyncAPI.Bindings; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Writers; @@ -41,16 +42,34 @@ public virtual void SerializeV3(IAsyncApiWriter writer) throw new ArgumentNullException(nameof(writer)); } - writer.WriteStartObject(); + // AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension. + // Its x- name can then clash with an extension (or another binding) of the same name; refuse to write a + // document with a duplicate property rather than silently dropping one of them. + var names = new Dictionary(StringComparer.Ordinal); + foreach (var extension in this.Extensions.Keys) + { + names[extension] = $"extension '{extension}'"; + } foreach (var binding in this) { - var bindingType = binding.Key; - var bindingValue = binding.Value; + var name = AsyncApiBindingKeys.ToV3Key(binding.Key); + if (names.TryGetValue(name, out var existing)) + { + throw new AsyncApiWriterException( + $"Binding '{binding.Key}' is written as '{name}' in AsyncAPI 3, which clashes with {existing}."); + } + + names[name] = $"binding '{binding.Key}'"; + } - writer.WritePropertyName(bindingType); + writer.WriteStartObject(); + + foreach (var binding in this) + { + writer.WritePropertyName(AsyncApiBindingKeys.ToV3Key(binding.Key)); - bindingValue.SerializeV3(writer); + binding.Value.SerializeV3(writer); } writer.WriteExtensions(this.Extensions); diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs new file mode 100644 index 0000000..4576583 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs @@ -0,0 +1,281 @@ +namespace ByteBard.AsyncAPI.Tests.Bindings +{ + using System.Linq; + using ByteBard.AsyncAPI.Bindings; + using ByteBard.AsyncAPI.Bindings.Kafka; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Models.Interfaces; + using ByteBard.AsyncAPI.Readers; + using ByteBard.AsyncAPI.Readers.Interface; + using ByteBard.AsyncAPI.Readers.ParseNodes; + using ByteBard.AsyncAPI.Writers; + using FluentAssertions; + using NUnit.Framework; + + public class CustomBindingKey_Should : TestBase + { + [Test] + public void V3_CustomBinding_SerializesUnderExtensionKey() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new MyBinding { Custom = "someValue", Any = new AsyncApiAny("anyValue") }); + + // Act + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + // Assert + actual.Should().BePlatformAgnosticEquivalentTo( + """ + bindings: + x-my: + custom: someValue + any: anyValue + """); + } + + [Test] + public void V3_SpecificationBinding_SerializesUnderItsOwnKey() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new KafkaChannelBinding { Topic = "orders" }); + + // Act + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + // Assert + actual.Should().Contain("kafka:"); + actual.Should().NotContain("x-kafka"); + } + + [Test] + public void V2_CustomBinding_SerializesUnderItsOwnKey() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new MyBinding { Custom = "someValue", Any = new AsyncApiAny("anyValue") }); + + // Act + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + // Assert + actual.Should().Contain(" my:"); + actual.Should().NotContain("x-my"); + } + + [Test] + public void V3_CustomBindingClashingWithExtension_Throws() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new MyBinding { Custom = "someValue" }); + channel.Bindings.Extensions.Add("x-my", new AsyncApiAny("extensionValue")); + + // Act + var act = () => channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + // Assert + act.Should().Throw().WithMessage("*'my'*'x-my'*extension 'x-my'*"); + } + + [Test] + public void V3_CustomBindingClashingWithPrefixedBinding_Throws() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new MyBinding { Custom = "someValue" }); + channel.Bindings.Add("x-my", new MyBinding { Custom = "otherValue" }); + + // Act + var act = () => channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + // Assert + act.Should().Throw().WithMessage("*clashes with binding*"); + } + + [Test] + public void V2_CustomBindingAndSameNamedExtension_SerializesBoth() + { + // Arrange + var channel = new AsyncApiChannel(); + channel.Bindings.Add(new MyBinding { Custom = "someValue" }); + channel.Bindings.Extensions.Add("x-my", new AsyncApiAny("extensionValue")); + + // Act + var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + // Assert + actual.Should().Contain(" my:"); + actual.Should().Contain("x-my: extensionValue"); + } + + [Test] + public void V3_CustomBindingUnderExtensionKey_ReadsAsBindingWithoutDiagnostics() + { + // Arrange + var input = + """ + bindings: + x-my: + custom: someValue + any: anyValue + """; + + // Act + var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + // Assert + diagnostic.Errors.Should().BeEmpty(); + diagnostic.Warnings.Should().BeEmpty(); + channel.Bindings.Keys.Should().BeEquivalentTo("my"); + channel.Bindings.Extensions.Should().BeEmpty(); + ((MyBinding)channel.Bindings["my"]).Custom.Should().Be("someValue"); + channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().BePlatformAgnosticEquivalentTo(input); + } + + [Test] + public void V3_CustomBindingWithoutExtensionPrefix_AddsErrorAndReadsBinding() + { + // Arrange + var input = + """ + bindings: + my: + custom: someValue + any: anyValue + """; + + // Act + var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + // Assert + diagnostic.Errors.Select(e => e.Message).Should().BeEquivalentTo( + "ChannelBinding my is not an AsyncAPI 3 binding name; write a custom binding as x-my"); + channel.Bindings.Keys.Should().BeEquivalentTo("my"); + } + + [Test] + public void V2_CustomBindingWithoutExtensionPrefix_ReadsWithoutDiagnostics() + { + // Arrange + var input = + """ + bindings: + my: + custom: someValue + any: anyValue + """; + + // Act + var channel = ReadChannel(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic); + + // Assert + diagnostic.Errors.Should().BeEmpty(); + channel.Bindings.Keys.Should().BeEquivalentTo("my"); + } + + [Test] + public void V3_ExtensionWithoutRegisteredBinding_ReadsAsExtension() + { + // Arrange + var input = + """ + bindings: + x-other: + custom: someValue + """; + + // Act + var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + // Assert + diagnostic.Errors.Should().BeEmpty(); + channel.Bindings.Keys.Should().BeEmpty(); + channel.Bindings.Extensions.Keys.Should().BeEquivalentTo("x-other"); + } + + [TestCase(AsyncApiVersion.AsyncApi2_0)] + [TestCase(AsyncApiVersion.AsyncApi3_0)] + public void BindingKeyWithExtensionPrefix_ReadsAndWritesAsBinding(AsyncApiVersion version) + { + // Arrange + var input = + """ + bindings: + x-prefixed: + custom: someValue + """; + + // Act + var channel = ReadChannel(input, version, out var diagnostic); + + // Assert + diagnostic.Errors.Should().BeEmpty(); + channel.Bindings.Extensions.Should().BeEmpty(); + ((PrefixedBinding)channel.Bindings["x-prefixed"]).Custom.Should().Be("someValue"); + channel.SerializeAsYaml(version).Should().BePlatformAgnosticEquivalentTo(input); + } + + [TestCase("kafka", true)] + [TestCase("ws", true)] + [TestCase("ros2", true)] + [TestCase("mqtt5", true)] + [TestCase("mercure", true)] + [TestCase("signalr", false)] + [TestCase("x-signalr", false)] + public void IsSpecificationKey_ReturnsWhetherAsyncApi3DefinesTheBinding(string key, bool expected) + { + AsyncApiBindingKeys.IsSpecificationKey(key).Should().Be(expected); + } + + [TestCase("kafka", "kafka")] + [TestCase("signalr", "x-signalr")] + [TestCase("x-signalr", "x-signalr")] + [TestCase("mqtt5", "mqtt5")] + public void ToV3Key_PrefixesKeysAsyncApi3DoesNotDefine(string key, string expected) + { + AsyncApiBindingKeys.ToV3Key(key).Should().Be(expected); + } + + private static AsyncApiChannel ReadChannel(string input, AsyncApiVersion version, out AsyncApiDiagnostic diagnostic) + { + var settings = new AsyncApiReaderSettings(); + settings.Bindings = BindingsCollection.Kafka; + settings.Bindings.Add(new MyBinding()); + settings.Bindings.Add(new PrefixedBinding()); + return new AsyncApiStringReader(settings).ReadFragment(input, version, out diagnostic); + } + } + + /// A custom binding whose key already carries the extension prefix. + public class PrefixedBinding : ChannelBinding + { + public string Custom { get; set; } + + public override string BindingKey => "x-prefixed"; + + protected override FixedFieldMap FixedFieldMap => new FixedFieldMap() + { + { "custom", (a, n) => { a.Custom = n.GetScalarValue(); } }, + }; + + public override void SerializeV2(IAsyncApiWriter writer) + { + this.SerializeProperties(writer); + } + + public override void SerializeV3(IAsyncApiWriter writer) + { + this.SerializeProperties(writer); + } + + public override void SerializeProperties(IAsyncApiWriter writer) + { + writer.WriteStartObject(); + writer.WriteRequiredProperty("custom", this.Custom); + writer.WriteExtensions(this.Extensions); + writer.WriteEndObject(); + } + } +} diff --git a/website/docs/guides/custom-bindings.mdx b/website/docs/guides/custom-bindings.mdx index 3c67ba2..fda8568 100644 --- a/website/docs/guides/custom-bindings.mdx +++ b/website/docs/guides/custom-bindings.mdx @@ -49,4 +49,10 @@ settings.Bindings.Add(new FooBinding()); var document = new AsyncApiStringReader(settings).Read(input, out var diagnostics); ``` -The binding's key and serialized fields must follow the relevant AsyncAPI binding specification. Custom bindings are separate from protocol-specific binding support already covered under [bindings](/docs/bindings); this page only documents the extension point. Confirm the exact parser type and signatures against the referenced package version when implementing a binding. +## Binding keys in AsyncAPI 3 + +AsyncAPI 3 bindings objects only allow the binding names the specification lists (`amqp`, `http`, `kafka`, `ws` and so on) and `x-` extensions. The list follows the specification text, which also names `mqtt5` and `mercure` even though the AsyncAPI 3 JSON schemas leave them out. When a document is serialized as AsyncAPI 3, a binding whose `BindingKey` is not one of those names is written as an extension: `FooBinding` above appears as `x-foo`. The key in the object model stays `foo`, so `channel.Bindings["foo"]` and `TryGetValue` work as before. `AsyncApiBindingKeys.ToV3Key` returns the name a key is written under. AsyncAPI 2 allows any key, so AsyncAPI 2 documents keep `foo`. A binding whose `BindingKey` already starts with `x-` is written and read under that key in both versions. + +When reading an AsyncAPI 3 document, `x-foo` is read as the binding if a parser for `foo` is registered; otherwise it stays an extension. A registered custom binding under its bare key (`foo`) is still read, but the reader reports an error, since the document does not conform to the specification. + +The binding's serialized fields must follow the relevant AsyncAPI binding specification. Custom bindings are separate from protocol-specific binding support already covered under [bindings](/docs/bindings); this page only documents the extension point. Confirm the exact parser type and signatures against the referenced package version when implementing a binding.