Skip to content

Commit a1a3bb5

Browse files
bieluclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 785bad7 commit a1a3bb5

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,31 @@ public virtual void SerializeV3(IAsyncApiWriter writer)
4242
throw new ArgumentNullException(nameof(writer));
4343
}
4444

45+
// AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension.
46+
// Its x- name can then clash with an extension (or another binding) of the same name; refuse to write a
47+
// document with a duplicate property rather than silently dropping one of them.
48+
var names = new Dictionary<string, string>(StringComparer.Ordinal);
49+
foreach (var extension in this.Extensions.Keys)
50+
{
51+
names[extension] = $"extension '{extension}'";
52+
}
53+
54+
foreach (var binding in this)
55+
{
56+
var name = AsyncApiBindingKeys.ToV3Key(binding.Key);
57+
if (names.TryGetValue(name, out var existing))
58+
{
59+
throw new AsyncApiWriterException(
60+
$"Binding '{binding.Key}' is written as '{name}' in AsyncAPI 3, which clashes with {existing}.");
61+
}
62+
63+
names[name] = $"binding '{binding.Key}'";
64+
}
65+
4566
writer.WriteStartObject();
4667

4768
foreach (var binding in this)
4869
{
49-
// AsyncAPI 3 only allows the specification's binding names, so a custom binding is written as an extension.
5070
writer.WritePropertyName(AsyncApiBindingKeys.ToV3Key(binding.Key));
5171

5272
binding.Value.SerializeV3(writer);

‎test/ByteBard.AsyncAPI.Tests/Bindings/CustomBindingKey_Should.cs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,52 @@ public void V2_CustomBinding_SerializesUnderItsOwnKey()
6464
actual.Should().NotContain("x-my");
6565
}
6666

67+
[Test]
68+
public void V3_CustomBindingClashingWithExtension_Throws()
69+
{
70+
// Arrange
71+
var channel = new AsyncApiChannel();
72+
channel.Bindings.Add(new MyBinding { Custom = "someValue" });
73+
channel.Bindings.Extensions.Add("x-my", new AsyncApiAny("extensionValue"));
74+
75+
// Act
76+
var act = () => channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
77+
78+
// Assert
79+
act.Should().Throw<AsyncApiWriterException>().WithMessage("*'my'*'x-my'*extension 'x-my'*");
80+
}
81+
82+
[Test]
83+
public void V3_CustomBindingClashingWithPrefixedBinding_Throws()
84+
{
85+
// Arrange
86+
var channel = new AsyncApiChannel();
87+
channel.Bindings.Add(new MyBinding { Custom = "someValue" });
88+
channel.Bindings.Add("x-my", new MyBinding { Custom = "otherValue" });
89+
90+
// Act
91+
var act = () => channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0);
92+
93+
// Assert
94+
act.Should().Throw<AsyncApiWriterException>().WithMessage("*clashes with binding*");
95+
}
96+
97+
[Test]
98+
public void V2_CustomBindingAndSameNamedExtension_SerializesBoth()
99+
{
100+
// Arrange
101+
var channel = new AsyncApiChannel();
102+
channel.Bindings.Add(new MyBinding { Custom = "someValue" });
103+
channel.Bindings.Extensions.Add("x-my", new AsyncApiAny("extensionValue"));
104+
105+
// Act
106+
var actual = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0);
107+
108+
// Assert
109+
actual.Should().Contain(" my:");
110+
actual.Should().Contain("x-my: extensionValue");
111+
}
112+
67113
[Test]
68114
public void V3_CustomBindingUnderExtensionKey_ReadsAsBindingWithoutDiagnostics()
69115
{

0 commit comments

Comments
 (0)