From 60e5ce664cf0afdb2af1fdbadc46f1250ba9bdb7 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel <2244074+bielu@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:54:32 +0000 Subject: [PATCH 1/4] fix: write custom bindings as x- extensions in AsyncAPI 3 documents AsyncAPI 3 bindings objects only allow the specification's binding names and x- extensions, but SerializeV3 wrote every binding under its BindingKey, so a document with a custom binding (or the deprecated mqtt5 binding) failed validation against the AsyncAPI JSON Schema, while the reader accepted it without diagnostics. When serializing AsyncAPI 3, write a binding whose key is not a specification binding name as x-; AsyncApiBindingKeys exposes the name list and the mapping. When reading AsyncAPI 3, load x- as the binding when a parser for is registered, and report an error for a registered custom binding under its bare key (it is still read). AsyncAPI 2 allows any key and is unchanged. Closes #66 --- .../BindingDeserializer.cs | 58 +++++- .../V3/AsyncApiChannelBindingDeserializer.cs | 2 +- .../V3/AsyncApiMessageBindingDeserializer.cs | 2 +- .../AsyncApiOperationBindingDeserializer.cs | 2 +- .../V3/AsyncApiServerBindingDeserializer.cs | 2 +- .../Models/AsyncApiBindingKeys.cs | 52 +++++ .../Models/AsyncApiBindings{TBinding}.cs | 9 +- .../Bindings/CustomBindingKey_Should.cs | 178 ++++++++++++++++++ .../Bindings/MQTT5/MQTT5Bindings_Should.cs | 11 +- .../Registration/BindingsCollection_Should.cs | 4 +- website/docs/bindings/mqtt5.md | 2 +- website/docs/guides/custom-bindings.mdx | 8 +- 12 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs create mode 100644 test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs diff --git a/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs index a8275f2..ad42ea2 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,47 @@ 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))) + var key = property.Name; + if (version == AsyncApiVersion.AsyncApi3_0 && TryGetCustomBindingKey(property.Name, 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(property.Name))) { + 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 && key == property.Name && !AsyncApiBindingKeys.IsSpecificationKey(key)) + { + // 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 +83,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..ae336e1 --- /dev/null +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs @@ -0,0 +1,52 @@ +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 named by a bindings object in the AsyncAPI 3.0 and 3.1 JSON schemas. + private static readonly HashSet SpecificationKeys = new HashSet(StringComparer.Ordinal) + { + "amqp", "amqp1", "anypointmq", "googlepubsub", "http", "ibmmq", "jms", "kafka", "mqtt", "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..b752ef2 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; @@ -45,12 +46,10 @@ public virtual void SerializeV3(IAsyncApiWriter writer) foreach (var binding in this) { - var bindingType = binding.Key; - var bindingValue = binding.Value; - - writer.WritePropertyName(bindingType); + // AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension. + 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..3631dc0 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs @@ -0,0 +1,178 @@ +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 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_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("kafka", true)] + [TestCase("ws", true)] + [TestCase("ros2", true)] + [TestCase("mqtt5", false)] + [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", "x-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()); + return new AsyncApiStringReader(settings).ReadFragment(input, version, out diagnostic); + } + } +} diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs index f2b828e..da9c82b 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs @@ -54,9 +54,12 @@ public void ServerBinding_ReadsAndRoundTripsSchemaAndReference(AsyncApiVersion v var parsers = ByteBard.AsyncAPI.Bindings.BindingsCollection.MQTT; parsers.Add(new MQTT5ServerBinding()); var reader = new AsyncApiStringReader(new AsyncApiReaderSettings { Bindings = parsers }); - var yaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + """ + + // AsyncAPI 3 dropped the mqtt5 binding, so a 3.x document carries it as an extension. + var key = version == AsyncApiVersion.AsyncApi2_0 ? "mqtt5" : "x-mqtt5"; + var yaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + $$""" bindings: - mqtt5: + {{key}}: sessionExpiryInterval: $ref: '#/components/schemas/interval' """; @@ -72,9 +75,9 @@ public void ServerBinding_ReadsAndRoundTripsSchemaAndReference(AsyncApiVersion v roundTripDiagnostics.Errors.Should().BeEmpty(); (reparsed.Bindings["mqtt5"] as MQTT5ServerBinding).SessionExpiryInterval.Should().BeOfType().Which.Schema.Should().BeOfType(); - var schemaYaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + """ + var schemaYaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + $$""" bindings: - mqtt5: + {{key}}: sessionExpiryInterval: type: integer minimum: 1 diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs index 11c19e2..b5bdf89 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs @@ -57,7 +57,9 @@ public void Reader_uses_all_to_parse_field_and_new_server_bindings(AsyncApiVersi ros2.RmwImplementation.Should().Be("rmw_fastrtps_cpp"); ros2.DomainId.Should().Be(7); - var mqtt5Binding = reader.ReadFragment(serverAddress + "protocol: mqtt5\nbindings:\n mqtt5: {}\n", version, out var mqtt5Diagnostics); + // AsyncAPI 3 dropped the mqtt5 binding, so a 3.x document carries it as an extension. + var mqtt5Key = version == AsyncApiVersion.AsyncApi2_0 ? "mqtt5" : "x-mqtt5"; + var mqtt5Binding = reader.ReadFragment(serverAddress + "protocol: mqtt5\nbindings:\n " + mqtt5Key + ": {}\n", version, out var mqtt5Diagnostics); mqtt5Diagnostics.Errors.Should().BeEmpty(); mqtt5Diagnostics.Warnings.Should().BeEmpty(); mqtt5Binding.Bindings.Should().ContainSingle(binding => binding.Key == "mqtt5"); diff --git a/website/docs/bindings/mqtt5.md b/website/docs/bindings/mqtt5.md index a6f8567..3de3631 100644 --- a/website/docs/bindings/mqtt5.md +++ b/website/docs/bindings/mqtt5.md @@ -87,7 +87,7 @@ if (referenceServer.Bindings.TryGetValue(out var referenced) `TryGetValue` returns `false` if no binding with that type's `BindingKey` is present; the union pattern then distinguishes the scalar and schema values. -The union is also used by MQTT's server and operation fields. MQTT 5's binding is deprecated, and `BindingsCollection.MQTT5` has no channel, operation, or message parser. AsyncAPI 2.x and 3.x serialization are supported; specify a target version when serializing. See the [shared guide](index.md). +The union is also used by MQTT's server and operation fields. MQTT 5's binding is deprecated, and `BindingsCollection.MQTT5` has no channel, operation, or message parser. AsyncAPI 2.x and 3.x serialization are supported; specify a target version when serializing. AsyncAPI 3 has no `mqtt5` binding, so a 3.x document carries it as `x-mqtt5` (see [custom bindings](/docs/guides/custom-bindings)). See the [shared guide](index.md). ## Object model diff --git a/website/docs/guides/custom-bindings.mdx b/website/docs/guides/custom-bindings.mdx index 3c67ba2..8d6914a 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. 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`. + +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. From 8b93442ae941cdaf4d6f17c8326f4dd163aa6b00 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel <2244074+bielu@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:11:39 +0000 Subject: [PATCH 2/4] fix: read a binding registered under an x- key as that binding A custom binding may already use an x- key (BindingKey => "x-signalr") so its documents validate with any reader. Such a key matched the extension pattern first and was read as a generic extension. Look up the registered parsers before treating an x- key as an extension, in both versions. --- .../BindingDeserializer.cs | 22 +++++--- .../Bindings/CustomBindingKey_Should.cs | 56 +++++++++++++++++++ website/docs/guides/custom-bindings.mdx | 2 +- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs index ad42ea2..7106b9b 100644 --- a/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs @@ -38,16 +38,20 @@ internal static AsyncApiBindings LoadBindings( var bindingType = typeof(TBinding).Name.Substring(1); foreach (var property in mapNode) { + // A registered key is read as that binding, even when it carries the x- prefix itself. var key = property.Name; - if (version == AsyncApiVersion.AsyncApi3_0 && TryGetCustomBindingKey(property.Name, parsers, out var customKey)) + if (!parsers.ContainsKey(key)) { - // 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(property.Name))) - { - property.ParseField(bindings, patternFields); - 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(key, out var parser)) @@ -58,7 +62,7 @@ internal static AsyncApiBindings LoadBindings( continue; } - if (version == AsyncApiVersion.AsyncApi3_0 && key == property.Name && !AsyncApiBindingKeys.IsSpecificationKey(key)) + 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-. diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs index 3631dc0..20b26eb 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs @@ -7,6 +7,8 @@ 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; @@ -147,6 +149,28 @@ public void V3_ExtensionWithoutRegisteredBinding_ReadsAsExtension() 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)] @@ -172,7 +196,39 @@ private static AsyncApiChannel ReadChannel(string input, AsyncApiVersion version 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 8d6914a..cd0771d 100644 --- a/website/docs/guides/custom-bindings.mdx +++ b/website/docs/guides/custom-bindings.mdx @@ -51,7 +51,7 @@ var document = new AsyncApiStringReader(settings).Read(input, out var diagnostic ## 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. 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`. +AsyncAPI 3 bindings objects only allow the binding names the specification lists (`amqp`, `http`, `kafka`, `ws` and so on) and `x-` extensions. 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. From 785bad7063e74dd129ff094000e2c97ce458ecb1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:17:34 +0000 Subject: [PATCH 3/4] fix: keep mqtt5 and mercure as AsyncAPI 3 binding names The AsyncAPI 3.0 and 3.1 specification text lists mqtt5 and mercure as fixed fields of the bindings objects; only the JSON schemas leave them out. Follow the normative text, so mqtt5 is written and read under its own key in 3.x instead of x-mqtt5. Co-Authored-By: Claude Opus 5.5 --- src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs | 7 ++++--- .../Bindings/CustomBindingKey_Should.cs | 5 +++-- .../Bindings/MQTT5/MQTT5Bindings_Should.cs | 11 ++++------- .../Registration/BindingsCollection_Should.cs | 4 +--- website/docs/bindings/mqtt5.md | 2 +- website/docs/guides/custom-bindings.mdx | 2 +- 6 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs index ae336e1..b5f87d2 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiBindingKeys.cs @@ -15,11 +15,12 @@ /// public static class AsyncApiBindingKeys { - // Every binding named by a bindings object in the AsyncAPI 3.0 and 3.1 JSON schemas. + // 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", "mqtt", "nats", "pulsar", - "redis", "ros2", "sns", "solace", "sqs", "stomp", "ws", + "amqp", "amqp1", "anypointmq", "googlepubsub", "http", "ibmmq", "jms", "kafka", "mercure", "mqtt", "mqtt5", + "nats", "pulsar", "redis", "ros2", "sns", "solace", "sqs", "stomp", "ws", }; /// diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs index 20b26eb..e57f41c 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs @@ -174,7 +174,8 @@ public void BindingKeyWithExtensionPrefix_ReadsAndWritesAsBinding(AsyncApiVersio [TestCase("kafka", true)] [TestCase("ws", true)] [TestCase("ros2", true)] - [TestCase("mqtt5", false)] + [TestCase("mqtt5", true)] + [TestCase("mercure", true)] [TestCase("signalr", false)] [TestCase("x-signalr", false)] public void IsSpecificationKey_ReturnsWhetherAsyncApi3DefinesTheBinding(string key, bool expected) @@ -185,7 +186,7 @@ public void IsSpecificationKey_ReturnsWhetherAsyncApi3DefinesTheBinding(string k [TestCase("kafka", "kafka")] [TestCase("signalr", "x-signalr")] [TestCase("x-signalr", "x-signalr")] - [TestCase("mqtt5", "x-mqtt5")] + [TestCase("mqtt5", "mqtt5")] public void ToV3Key_PrefixesKeysAsyncApi3DoesNotDefine(string key, string expected) { AsyncApiBindingKeys.ToV3Key(key).Should().Be(expected); diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs index da9c82b..f2b828e 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/MQTT5/MQTT5Bindings_Should.cs @@ -54,12 +54,9 @@ public void ServerBinding_ReadsAndRoundTripsSchemaAndReference(AsyncApiVersion v var parsers = ByteBard.AsyncAPI.Bindings.BindingsCollection.MQTT; parsers.Add(new MQTT5ServerBinding()); var reader = new AsyncApiStringReader(new AsyncApiReaderSettings { Bindings = parsers }); - - // AsyncAPI 3 dropped the mqtt5 binding, so a 3.x document carries it as an extension. - var key = version == AsyncApiVersion.AsyncApi2_0 ? "mqtt5" : "x-mqtt5"; - var yaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + $$""" + var yaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + """ bindings: - {{key}}: + mqtt5: sessionExpiryInterval: $ref: '#/components/schemas/interval' """; @@ -75,9 +72,9 @@ public void ServerBinding_ReadsAndRoundTripsSchemaAndReference(AsyncApiVersion v roundTripDiagnostics.Errors.Should().BeEmpty(); (reparsed.Bindings["mqtt5"] as MQTT5ServerBinding).SessionExpiryInterval.Should().BeOfType().Which.Schema.Should().BeOfType(); - var schemaYaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + $$""" + var schemaYaml = (version == AsyncApiVersion.AsyncApi2_0 ? "url: localhost\nprotocol: mqtt\n" : "host: localhost\nprotocol: mqtt\n") + """ bindings: - {{key}}: + mqtt5: sessionExpiryInterval: type: integer minimum: 1 diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs index b5bdf89..11c19e2 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/Registration/BindingsCollection_Should.cs @@ -57,9 +57,7 @@ public void Reader_uses_all_to_parse_field_and_new_server_bindings(AsyncApiVersi ros2.RmwImplementation.Should().Be("rmw_fastrtps_cpp"); ros2.DomainId.Should().Be(7); - // AsyncAPI 3 dropped the mqtt5 binding, so a 3.x document carries it as an extension. - var mqtt5Key = version == AsyncApiVersion.AsyncApi2_0 ? "mqtt5" : "x-mqtt5"; - var mqtt5Binding = reader.ReadFragment(serverAddress + "protocol: mqtt5\nbindings:\n " + mqtt5Key + ": {}\n", version, out var mqtt5Diagnostics); + var mqtt5Binding = reader.ReadFragment(serverAddress + "protocol: mqtt5\nbindings:\n mqtt5: {}\n", version, out var mqtt5Diagnostics); mqtt5Diagnostics.Errors.Should().BeEmpty(); mqtt5Diagnostics.Warnings.Should().BeEmpty(); mqtt5Binding.Bindings.Should().ContainSingle(binding => binding.Key == "mqtt5"); diff --git a/website/docs/bindings/mqtt5.md b/website/docs/bindings/mqtt5.md index 3de3631..a6f8567 100644 --- a/website/docs/bindings/mqtt5.md +++ b/website/docs/bindings/mqtt5.md @@ -87,7 +87,7 @@ if (referenceServer.Bindings.TryGetValue(out var referenced) `TryGetValue` returns `false` if no binding with that type's `BindingKey` is present; the union pattern then distinguishes the scalar and schema values. -The union is also used by MQTT's server and operation fields. MQTT 5's binding is deprecated, and `BindingsCollection.MQTT5` has no channel, operation, or message parser. AsyncAPI 2.x and 3.x serialization are supported; specify a target version when serializing. AsyncAPI 3 has no `mqtt5` binding, so a 3.x document carries it as `x-mqtt5` (see [custom bindings](/docs/guides/custom-bindings)). See the [shared guide](index.md). +The union is also used by MQTT's server and operation fields. MQTT 5's binding is deprecated, and `BindingsCollection.MQTT5` has no channel, operation, or message parser. AsyncAPI 2.x and 3.x serialization are supported; specify a target version when serializing. See the [shared guide](index.md). ## Object model diff --git a/website/docs/guides/custom-bindings.mdx b/website/docs/guides/custom-bindings.mdx index cd0771d..fda8568 100644 --- a/website/docs/guides/custom-bindings.mdx +++ b/website/docs/guides/custom-bindings.mdx @@ -51,7 +51,7 @@ var document = new AsyncApiStringReader(settings).Read(input, out var diagnostic ## 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. 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. +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. From a1a3bb598f6188df7bad0cc64f34ec0077579673 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:31:33 +0000 Subject: [PATCH 4/4] fix: refuse to write clashing binding names in AsyncAPI 3 A custom binding "my" is written as "x-my" in AsyncAPI 3, which can clash with an extension or another binding of that name. Throw an AsyncApiWriterException instead of writing a duplicate property. Co-Authored-By: Claude Opus 5.5 --- .../Models/AsyncApiBindings{TBinding}.cs | 22 ++++++++- .../Bindings/CustomBindingKey_Should.cs | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs index b752ef2..ebe0a02 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs @@ -42,11 +42,31 @@ public virtual void SerializeV3(IAsyncApiWriter writer) throw new ArgumentNullException(nameof(writer)); } + // 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 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.WriteStartObject(); foreach (var binding in this) { - // AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension. writer.WritePropertyName(AsyncApiBindingKeys.ToV3Key(binding.Key)); binding.Value.SerializeV3(writer); diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs index e57f41c..4576583 100644 --- a/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs @@ -64,6 +64,52 @@ public void V2_CustomBinding_SerializesUnderItsOwnKey() 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() {