From 86c9e348a9d1d8e690bcb6ad2f39c085d57b43db Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:25:19 +0100 Subject: [PATCH] fix: report invalid MQTT and ROS2 binding scalars as errors Several MQTT and ROS2 binding fields were parsed with Get*ValueOrDefault(), which returns null for a value that cannot be parsed. A document with e.g. `cleanSession: yes-please` or `keepAlive: abc` was read with no diagnostics and the value was silently dropped, so writing it back lost data. These fields now use GetIntegerValue()/GetBooleanValue(), which report "Value could not parse to ..." in diagnostic.Errors, matching the other bindings (IBM MQ, JMS, Kafka, ...): - MQTT server: cleanSession, keepAlive, lastWill.qos - MQTT message: payloadFormatIndicator - ROS2 server: domainId - ROS2 QoS policies: lifespan, deadline, leaseDuration Co-Authored-By: Claude Opus 5.5 --- .../MQTT/MQTTMessageBinding.cs | 2 +- .../MQTT/MQTTServerBinding.cs | 6 +- .../ROS2/ROS2QosPolicies.cs | 6 +- .../ROS2/ROS2ServerBinding.cs | 2 +- .../Bindings/BindingInvalidScalars_Should.cs | 69 +++++++++++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/Bindings/BindingInvalidScalars_Should.cs diff --git a/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTMessageBinding.cs b/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTMessageBinding.cs index 93214f6..473bf23 100644 --- a/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTMessageBinding.cs +++ b/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTMessageBinding.cs @@ -97,7 +97,7 @@ private void SerializeProperties(IAsyncApiWriter writer, AsyncApiVersion version protected override FixedFieldMap FixedFieldMap => new() { { "bindingVersion", (a, n) => { a.BindingVersion = n.GetScalarValue(); } }, - { "payloadFormatIndicator", (a, n) => { a.PayloadFormatIndicator = n.GetIntegerValueOrDefault(); } }, + { "payloadFormatIndicator", (a, n) => { a.PayloadFormatIndicator = n.GetIntegerValue(); } }, { "correlationData", (a, n) => { a.CorrelationData = AsyncApiJsonSchemaDeserializer.LoadSchema(n); } }, { "contentType", (a, n) => { a.ContentType = n.GetScalarValue(); } }, { "responseTopic", (a, n) => { a.ResponseTopicValue = MQTTStringOrSchema.Parse(n); } }, diff --git a/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTServerBinding.cs b/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTServerBinding.cs index 0876fa9..e916fef 100644 --- a/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTServerBinding.cs +++ b/src/ByteBard.AsyncAPI.Bindings/MQTT/MQTTServerBinding.cs @@ -65,9 +65,9 @@ public int? MaximumPacketSize { { "bindingVersion", (a, n) => { a.BindingVersion = n.GetScalarValue(); } }, { "clientId", (a, n) => { a.ClientId = n.GetScalarValue(); } }, - { "cleanSession", (a, n) => { a.CleanSession = n.GetBooleanValueOrDefault(); } }, + { "cleanSession", (a, n) => { a.CleanSession = n.GetBooleanValue(); } }, { "lastWill", (a, n) => { a.LastWill = n.ParseMap(LastWillFixedFields); } }, - { "keepAlive", (a, n) => { a.KeepAlive = n.GetIntegerValueOrDefault(); } }, + { "keepAlive", (a, n) => { a.KeepAlive = n.GetIntegerValue(); } }, { "sessionExpiryInterval", (a, n) => { a.SessionExpiryIntervalValue = MQTTIntegerOrSchema.Parse(n); } }, { "maximumPacketSize", (a, n) => { a.MaximumPacketSizeValue = MQTTIntegerOrSchema.Parse(n); } }, }; @@ -75,7 +75,7 @@ public int? MaximumPacketSize private static FixedFieldMap LastWillFixedFields = new() { { "topic", (a, n) => { a.Topic = n.GetScalarValue(); } }, - { "qos", (a, n) => { a.QoS = (uint?)n.GetIntegerValueOrDefault(); } }, + { "qos", (a, n) => { a.QoS = (uint?)n.GetIntegerValue(); } }, { "message", (a, n) => { a.Message = n.GetScalarValue(); } }, { "retain", (a, n) => { a.Retain = n.GetBooleanValue(); } }, }; diff --git a/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2QosPolicies.cs b/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2QosPolicies.cs index bd33c46..4787fc7 100644 --- a/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2QosPolicies.cs +++ b/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2QosPolicies.cs @@ -21,10 +21,10 @@ public class ROS2QosPolicies : IAsyncApiElement { "reliability", (a, n) => { a.Reliability = n.GetScalarValue(); } }, { "history", (a, n) => { a.History = n.GetScalarValue(); } }, { "durability", (a, n) => { a.Durability = n.GetScalarValue(); } }, - { "lifespan", (a, n) => { a.Lifespan = n.GetIntegerValueOrDefault(); } }, - { "deadline", (a, n) => { a.Deadline = n.GetIntegerValueOrDefault(); } }, + { "lifespan", (a, n) => { a.Lifespan = n.GetIntegerValue(); } }, + { "deadline", (a, n) => { a.Deadline = n.GetIntegerValue(); } }, { "liveliness", (a, n) => { a.Liveliness = n.GetScalarValue(); } }, - { "leaseDuration", (a, n) => { a.LeaseDuration = n.GetIntegerValueOrDefault(); } }, + { "leaseDuration", (a, n) => { a.LeaseDuration = n.GetIntegerValue(); } }, }; /// diff --git a/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2ServerBinding.cs b/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2ServerBinding.cs index 9512645..bfefc06 100644 --- a/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2ServerBinding.cs +++ b/src/ByteBard.AsyncAPI.Bindings/ROS2/ROS2ServerBinding.cs @@ -18,7 +18,7 @@ public class ROS2ServerBinding : ServerBinding protected override FixedFieldMap FixedFieldMap => new() { { "rmwImplementation", (a, n) => { a.RmwImplementation = n.GetScalarValue(); } }, - { "domainId", (a, n) => { a.DomainId = n.GetIntegerValueOrDefault(); } }, + { "domainId", (a, n) => { a.DomainId = n.GetIntegerValue(); } }, }; /// diff --git a/test/ByteBard.AsyncAPI.Tests/Bindings/BindingInvalidScalars_Should.cs b/test/ByteBard.AsyncAPI.Tests/Bindings/BindingInvalidScalars_Should.cs new file mode 100644 index 0000000..b88b881 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Bindings/BindingInvalidScalars_Should.cs @@ -0,0 +1,69 @@ +namespace ByteBard.AsyncAPI.Tests.Bindings +{ + using FluentAssertions; + using ByteBard.AsyncAPI.Bindings; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using NUnit.Framework; + + internal class BindingInvalidScalars_Should : TestBase + { + [TestCase("mqtt", "cleanSession: yes-please", "bool")] + [TestCase("mqtt", "keepAlive: abc", "integer")] + [TestCase("mqtt", "lastWill:\n topic: /wills\n qos: high", "integer")] + [TestCase("ros2", "domainId: abc", "integer")] + public void ServerBinding_WithInvalidScalar_ReportsError(string bindingKey, string field, string expectedType) + { + var yaml = + "host: localhost\n" + + "protocol: " + bindingKey + "\n" + + "bindings:\n" + + " " + bindingKey + ":\n" + + Indent(field, 4); + + new AsyncApiStringReader(Settings()).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain($"could not parse to {expectedType}"); + } + + [Test] + public void MessageBinding_WithInvalidPayloadFormatIndicator_ReportsError() + { + var yaml = + "bindings:\n" + + " mqtt:\n" + + " payloadFormatIndicator: abc\n"; + + new AsyncApiStringReader(Settings()).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain("could not parse to integer"); + } + + [TestCase("lifespan")] + [TestCase("deadline")] + [TestCase("leaseDuration")] + public void ROS2OperationBinding_WithInvalidQosDuration_ReportsError(string field) + { + var yaml = + "action: send\n" + + "channel:\n" + + " $ref: '#/channels/camera'\n" + + "bindings:\n" + + " ros2:\n" + + " qosPolicies:\n" + + " " + field + ": forever\n"; + + new AsyncApiStringReader(Settings()).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain("could not parse to integer"); + } + + private static AsyncApiReaderSettings Settings() => new() { Bindings = BindingsCollection.All }; + + private static string Indent(string value, int spaces) + { + var prefix = new string(' ', spaces); + return prefix + value.Replace("\n", "\n" + prefix) + "\n"; + } + } +}