Skip to content

Commit 5c50232

Browse files
committed
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-<key>; AsyncApiBindingKeys exposes the name list and the mapping. When reading AsyncAPI 3, load x-<key> as the binding when a parser for <key> 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
1 parent cc1e7a5 commit 5c50232

12 files changed

Lines changed: 305 additions & 25 deletions

‎src/ByteBard.AsyncAPI.Readers/BindingDeserializer.cs‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
namespace ByteBard.AsyncAPI.Readers
22
{
3+
using System;
34
using System.Collections.Generic;
45
using System.Linq;
6+
using ByteBard.AsyncAPI.Bindings;
57
using ByteBard.AsyncAPI.Exceptions;
68
using ByteBard.AsyncAPI.Extensions;
79
using ByteBard.AsyncAPI.Models;
@@ -25,29 +27,47 @@ public static T LoadBinding<T>(string nodeName, ParseNode node, FixedFieldMap<T>
2527
internal static AsyncApiBindings<TBinding> LoadBindings<TBinding>(
2628
MapNode mapNode,
2729
IDictionary<string, IBindingParser<TBinding>> parsers,
28-
PatternFieldMap<AsyncApiBindings<TBinding>> patternFields)
30+
PatternFieldMap<AsyncApiBindings<TBinding>> patternFields,
31+
AsyncApiVersion version = AsyncApiVersion.AsyncApi2_0)
2932
where TBinding : IBinding
3033
{
3134
var context = mapNode.Context;
3235
var bindings = new AsyncApiBindings<TBinding>();
36+
37+
// TBinding is the binding interface (e.g. IChannelBinding), so drop the leading "I" for the diagnostics.
38+
var bindingType = typeof(TBinding).Name.Substring(1);
3339
foreach (var property in mapNode)
3440
{
35-
if (patternFields.Keys.Any(isMatch => isMatch(property.Name)))
41+
var key = property.Name;
42+
if (version == AsyncApiVersion.AsyncApi3_0 && TryGetCustomBindingKey(property.Name, parsers, out var customKey))
43+
{
44+
// AsyncAPI 3 writes a custom binding as an extension (x-<key>); read it back as the binding.
45+
key = customKey;
46+
}
47+
else if (patternFields.Keys.Any(isMatch => isMatch(property.Name)))
3648
{
49+
property.ParseField(bindings, patternFields);
3750
continue;
3851
}
3952

40-
if (!parsers.TryGetValue(property.Name, out var parser))
53+
if (!parsers.TryGetValue(key, out var parser))
4154
{
42-
// TBinding is the binding interface (e.g. IChannelBinding), so drop the leading "I" for the diagnostic.
43-
var bindingType = typeof(TBinding).Name.Substring(1);
44-
4555
// Reported as an error because the binding is dropped from the model and cannot be written back.
4656
context.Diagnostic.Errors.Add(
47-
new AsyncApiError(context.GetLocation(), $"{bindingType} {property.Name} is not found"));
57+
new AsyncApiError(context.GetLocation(), $"{bindingType} {key} is not found"));
4858
continue;
4959
}
5060

61+
if (version == AsyncApiVersion.AsyncApi3_0 && key == property.Name && !AsyncApiBindingKeys.IsSpecificationKey(key))
62+
{
63+
// AsyncAPI 3 bindings objects only allow the specification's binding names and extensions. The binding
64+
// is still read, and written back as x-<key>.
65+
context.Diagnostic.Errors.Add(
66+
new AsyncApiError(
67+
context.GetLocation(),
68+
$"{bindingType} {key} is not an AsyncAPI 3 binding name; write a custom binding as {AsyncApiBindingKeys.ToV3Key(key)}"));
69+
}
70+
5171
try
5272
{
5373
var binding = parser.LoadBinding(property);
@@ -63,11 +83,31 @@ internal static AsyncApiBindings<TBinding> LoadBindings<TBinding>(
6383
}
6484
}
6585

66-
mapNode.ParseFields(bindings, patternFields);
67-
6886
return bindings;
6987
}
7088

89+
private static bool TryGetCustomBindingKey<TBinding>(
90+
string propertyName,
91+
IDictionary<string, IBindingParser<TBinding>> parsers,
92+
out string key)
93+
where TBinding : IBinding
94+
{
95+
key = null;
96+
if (!propertyName.StartsWith(AsyncApiConstants.ExtensionFieldNamePrefix, StringComparison.Ordinal))
97+
{
98+
return false;
99+
}
100+
101+
var candidate = propertyName.Substring(AsyncApiConstants.ExtensionFieldNamePrefix.Length);
102+
if (AsyncApiBindingKeys.IsSpecificationKey(candidate) || !parsers.ContainsKey(candidate))
103+
{
104+
return false;
105+
}
106+
107+
key = candidate;
108+
return true;
109+
}
110+
71111
private static PatternFieldMap<T> BindingPatternExtensionFields<T>()
72112
where T : IBinding, new()
73113
{

‎src/ByteBard.AsyncAPI.Readers/V3/AsyncApiChannelBindingDeserializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal static AsyncApiBindings<IChannelBinding> LoadChannelBindings(ParseNode
2222
return new AsyncApiBindingsReference<IChannelBinding>(pointer);
2323
}
2424

25-
return BindingDeserializer.LoadBindings(mapNode, node.Context.ChannelBindingParsers, channelBindingPatternFields);
25+
return BindingDeserializer.LoadBindings(mapNode, node.Context.ChannelBindingParsers, channelBindingPatternFields, AsyncApiVersion.AsyncApi3_0);
2626
}
2727
}
2828
}

‎src/ByteBard.AsyncAPI.Readers/V3/AsyncApiMessageBindingDeserializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal static AsyncApiBindings<IMessageBinding> LoadMessageBindings(ParseNode
2222
return new AsyncApiBindingsReference<IMessageBinding>(pointer);
2323
}
2424

25-
return BindingDeserializer.LoadBindings(mapNode, node.Context.MessageBindingParsers, messageBindingPatternFields);
25+
return BindingDeserializer.LoadBindings(mapNode, node.Context.MessageBindingParsers, messageBindingPatternFields, AsyncApiVersion.AsyncApi3_0);
2626
}
2727
}
2828
}

‎src/ByteBard.AsyncAPI.Readers/V3/AsyncApiOperationBindingDeserializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal static AsyncApiBindings<IOperationBinding> LoadOperationBindings(ParseN
2222
return new AsyncApiBindingsReference<IOperationBinding>(pointer);
2323
}
2424

25-
return BindingDeserializer.LoadBindings(mapNode, node.Context.OperationBindingParsers, operationBindingPatternFields);
25+
return BindingDeserializer.LoadBindings(mapNode, node.Context.OperationBindingParsers, operationBindingPatternFields, AsyncApiVersion.AsyncApi3_0);
2626
}
2727
}
2828
}

‎src/ByteBard.AsyncAPI.Readers/V3/AsyncApiServerBindingDeserializer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal static AsyncApiBindings<IServerBinding> LoadServerBindings(ParseNode no
2222
return new AsyncApiBindingsReference<IServerBinding>(pointer);
2323
}
2424

25-
return BindingDeserializer.LoadBindings(mapNode, node.Context.ServerBindingParsers, serverBindingPatternFields);
25+
return BindingDeserializer.LoadBindings(mapNode, node.Context.ServerBindingParsers, serverBindingPatternFields, AsyncApiVersion.AsyncApi3_0);
2626
}
2727
}
2828
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
namespace ByteBard.AsyncAPI.Bindings
2+
{
3+
using System;
4+
using System.Collections.Generic;
5+
using ByteBard.AsyncAPI.Models;
6+
using ByteBard.AsyncAPI.Models.Interfaces;
7+
8+
/// <summary>
9+
/// The binding names AsyncAPI 3 defines, and the key a binding is written under in an AsyncAPI 3 document.
10+
/// </summary>
11+
/// <remarks>
12+
/// In AsyncAPI 3 the server, channel, operation and message bindings objects only allow the protocol names
13+
/// the specification lists, plus <c>x-</c> extensions. A custom binding (one whose <see cref="IBinding.BindingKey"/>
14+
/// is not listed) is therefore written as <c>x-&lt;key&gt;</c>. AsyncAPI 2 allows any key, so it keeps the key as is.
15+
/// </remarks>
16+
public static class AsyncApiBindingKeys
17+
{
18+
// Every binding named by a bindings object in the AsyncAPI 3.0 and 3.1 JSON schemas.
19+
private static readonly HashSet<string> SpecificationKeys = new HashSet<string>(StringComparer.Ordinal)
20+
{
21+
"amqp", "amqp1", "anypointmq", "googlepubsub", "http", "ibmmq", "jms", "kafka", "mqtt", "nats", "pulsar",
22+
"redis", "ros2", "sns", "solace", "sqs", "stomp", "ws",
23+
};
24+
25+
/// <summary>
26+
/// Whether AsyncAPI 3 defines a binding with this name.
27+
/// </summary>
28+
/// <param name="key">The binding key.</param>
29+
/// <returns><c>true</c> for a binding the specification lists; <c>false</c> otherwise, including for <c>x-</c> keys.</returns>
30+
public static bool IsSpecificationKey(string key)
31+
{
32+
return key != null && SpecificationKeys.Contains(key);
33+
}
34+
35+
/// <summary>
36+
/// The property name a binding is written under in an AsyncAPI 3 bindings object.
37+
/// </summary>
38+
/// <param name="key">The binding key.</param>
39+
/// <returns>The key itself for a specification binding or an <c>x-</c> key; otherwise the key with an <c>x-</c> prefix.</returns>
40+
public static string ToV3Key(string key)
41+
{
42+
if (key is null)
43+
{
44+
throw new ArgumentNullException(nameof(key));
45+
}
46+
47+
return IsSpecificationKey(key) || key.StartsWith(AsyncApiConstants.ExtensionFieldNamePrefix, StringComparison.Ordinal)
48+
? key
49+
: AsyncApiConstants.ExtensionFieldNamePrefix + key;
50+
}
51+
}
52+
}

‎src/ByteBard.AsyncAPI/Models/AsyncApiBindings{TBinding}.cs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System;
44
using System.Collections;
55
using System.Collections.Generic;
6+
using ByteBard.AsyncAPI.Bindings;
67
using ByteBard.AsyncAPI.Models.Interfaces;
78
using ByteBard.AsyncAPI.Writers;
89

@@ -45,12 +46,10 @@ public virtual void SerializeV3(IAsyncApiWriter writer)
4546

4647
foreach (var binding in this)
4748
{
48-
var bindingType = binding.Key;
49-
var bindingValue = binding.Value;
50-
51-
writer.WritePropertyName(bindingType);
49+
// AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension.
50+
writer.WritePropertyName(AsyncApiBindingKeys.ToV3Key(binding.Key));
5251

53-
bindingValue.SerializeV3(writer);
52+
binding.Value.SerializeV3(writer);
5453
}
5554

5655
writer.WriteExtensions(this.Extensions);
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
namespace ByteBard.AsyncAPI.Tests.Bindings
2+
{
3+
using System.Linq;
4+
using ByteBard.AsyncAPI.Bindings;
5+
using ByteBard.AsyncAPI.Bindings.Kafka;
6+
using ByteBard.AsyncAPI.Models;
7+
using ByteBard.AsyncAPI.Models.Interfaces;
8+
using ByteBard.AsyncAPI.Readers;
9+
using ByteBard.AsyncAPI.Readers.Interface;
10+
using FluentAssertions;
11+
using NUnit.Framework;
12+
13+
public class CustomBindingKey_Should : TestBase
14+
{
15+
[Test]
16+
public void V3_CustomBinding_SerializesUnderExtensionKey()
17+
{
18+
// Arrange
19+
var channel = new AsyncApiChannel();
20+
channel.Bindings.Add(new MyBinding { Custom = "someValue", Any = new AsyncApiAny("anyValue") });
21+
22+
// Act
23+
var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
24+
25+
// Assert
26+
actual.Should().BePlatformAgnosticEquivalentTo(
27+
"""
28+
bindings:
29+
x-my:
30+
custom: someValue
31+
any: anyValue
32+
""");
33+
}
34+
35+
[Test]
36+
public void V3_SpecificationBinding_SerializesUnderItsOwnKey()
37+
{
38+
// Arrange
39+
var channel = new AsyncApiChannel();
40+
channel.Bindings.Add(new KafkaChannelBinding { Topic = "orders" });
41+
42+
// Act
43+
var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
44+
45+
// Assert
46+
actual.Should().Contain("kafka:");
47+
actual.Should().NotContain("x-kafka");
48+
}
49+
50+
[Test]
51+
public void V2_CustomBinding_SerializesUnderItsOwnKey()
52+
{
53+
// Arrange
54+
var channel = new AsyncApiChannel();
55+
channel.Bindings.Add(new MyBinding { Custom = "someValue", Any = new AsyncApiAny("anyValue") });
56+
57+
// Act
58+
var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0);
59+
60+
// Assert
61+
actual.Should().Contain(" my:");
62+
actual.Should().NotContain("x-my");
63+
}
64+
65+
[Test]
66+
public void V3_CustomBindingUnderExtensionKey_ReadsAsBindingWithoutDiagnostics()
67+
{
68+
// Arrange
69+
var input =
70+
"""
71+
bindings:
72+
x-my:
73+
custom: someValue
74+
any: anyValue
75+
""";
76+
77+
// Act
78+
var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic);
79+
80+
// Assert
81+
diagnostic.Errors.Should().BeEmpty();
82+
diagnostic.Warnings.Should().BeEmpty();
83+
channel.Bindings.Keys.Should().BeEquivalentTo("my");
84+
channel.Bindings.Extensions.Should().BeEmpty();
85+
((MyBinding)channel.Bindings["my"]).Custom.Should().Be("someValue");
86+
channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0).Should().BePlatformAgnosticEquivalentTo(input);
87+
}
88+
89+
[Test]
90+
public void V3_CustomBindingWithoutExtensionPrefix_AddsErrorAndReadsBinding()
91+
{
92+
// Arrange
93+
var input =
94+
"""
95+
bindings:
96+
my:
97+
custom: someValue
98+
any: anyValue
99+
""";
100+
101+
// Act
102+
var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic);
103+
104+
// Assert
105+
diagnostic.Errors.Select(e => e.Message).Should().BeEquivalentTo(
106+
"ChannelBinding my is not an AsyncAPI 3 binding name; write a custom binding as x-my");
107+
channel.Bindings.Keys.Should().BeEquivalentTo("my");
108+
}
109+
110+
[Test]
111+
public void V2_CustomBindingWithoutExtensionPrefix_ReadsWithoutDiagnostics()
112+
{
113+
// Arrange
114+
var input =
115+
"""
116+
bindings:
117+
my:
118+
custom: someValue
119+
any: anyValue
120+
""";
121+
122+
// Act
123+
var channel = ReadChannel(input, AsyncApiVersion.AsyncApi2_0, out var diagnostic);
124+
125+
// Assert
126+
diagnostic.Errors.Should().BeEmpty();
127+
channel.Bindings.Keys.Should().BeEquivalentTo("my");
128+
}
129+
130+
[Test]
131+
public void V3_ExtensionWithoutRegisteredBinding_ReadsAsExtension()
132+
{
133+
// Arrange
134+
var input =
135+
"""
136+
bindings:
137+
x-other:
138+
custom: someValue
139+
""";
140+
141+
// Act
142+
var channel = ReadChannel(input, AsyncApiVersion.AsyncApi3_0, out var diagnostic);
143+
144+
// Assert
145+
diagnostic.Errors.Should().BeEmpty();
146+
channel.Bindings.Keys.Should().BeEmpty();
147+
channel.Bindings.Extensions.Keys.Should().BeEquivalentTo("x-other");
148+
}
149+
150+
[TestCase("kafka", true)]
151+
[TestCase("ws", true)]
152+
[TestCase("ros2", true)]
153+
[TestCase("mqtt5", false)]
154+
[TestCase("signalr", false)]
155+
[TestCase("x-signalr", false)]
156+
public void IsSpecificationKey_ReturnsWhetherAsyncApi3DefinesTheBinding(string key, bool expected)
157+
{
158+
AsyncApiBindingKeys.IsSpecificationKey(key).Should().Be(expected);
159+
}
160+
161+
[TestCase("kafka", "kafka")]
162+
[TestCase("signalr", "x-signalr")]
163+
[TestCase("x-signalr", "x-signalr")]
164+
[TestCase("mqtt5", "x-mqtt5")]
165+
public void ToV3Key_PrefixesKeysAsyncApi3DoesNotDefine(string key, string expected)
166+
{
167+
AsyncApiBindingKeys.ToV3Key(key).Should().Be(expected);
168+
}
169+
170+
private static AsyncApiChannel ReadChannel(string input, AsyncApiVersion version, out AsyncApiDiagnostic diagnostic)
171+
{
172+
var settings = new AsyncApiReaderSettings();
173+
settings.Bindings = BindingsCollection.Kafka;
174+
settings.Bindings.Add(new MyBinding());
175+
return new AsyncApiStringReader(settings).ReadFragment<AsyncApiChannel>(input, version, out diagnostic);
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)