From f325f90b334aa88f06ac9370cacb90f5e2b4c181 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:41:01 +0100 Subject: [PATCH] fix: parse integers with the reader culture instead of the current culture ValueNode.GetIntegerValue/GetLongValue (and their OrDefault variants) called int/long.TryParse without a culture, so they used CultureInfo.CurrentCulture, while GetScalarValue formats the value with Settings.CultureInfo (invariant by default). On machines whose current culture has a non-ASCII negative sign (ar-*, he-IL, fa-IR, ... - 57 of 890 .NET cultures), every negative integer failed to parse: e.g. ROS2 `deadline: -1` was silently dropped and IBM MQ `expiry: -5` produced "Value could not parse to long." Both now parse with Settings.CultureInfo, matching how the JSON Schema and Avro deserializers already parse numbers. Also switch Avro decimal `precision`/`scale` from int.Parse to GetIntegerValue: int.Parse threw FormatException for a non-integer value, which escaped the reader instead of becoming a diagnostic. Co-Authored-By: Claude Opus 5.5 --- .../ParseNodes/ValueNode.cs | 9 +- .../Schemas/AsyncApiAvroSchemaDeserializer.cs | 4 +- .../IntegerParsing_Should.cs | 92 +++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/IntegerParsing_Should.cs diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs index e36580b..78fa352 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs @@ -1,6 +1,7 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes { using System; + using System.Globalization; using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Models; @@ -47,7 +48,7 @@ public override string GetScalarValueOrDefault(string defaultValue = null) public override int GetIntegerValue() { - if (int.TryParse(this.GetScalarValue(), out int value)) + if (int.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out int value)) { return value; } @@ -57,7 +58,7 @@ public override int GetIntegerValue() public override int? GetIntegerValueOrDefault(int? defaultValue = null) { - if (int.TryParse(this.GetScalarValue(), out int value)) + if (int.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out int value)) { return value; } @@ -67,7 +68,7 @@ public override int GetIntegerValue() public override long GetLongValue() { - if (long.TryParse(this.GetScalarValue(), out long value)) + if (long.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out long value)) { return value; } @@ -77,7 +78,7 @@ public override long GetLongValue() public override long? GetLongValueOrDefault(long? defaultValue = null) { - if (long.TryParse(this.GetScalarValue(), out long value)) + if (long.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out long value)) { return value; } diff --git a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs index fb749e8..547009a 100644 --- a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs @@ -70,8 +70,8 @@ public class AsyncApiAvroSchemaDeserializer { { "type", (a, n) => { } }, { "logicalType", (a, n) => { } }, - { "precision", (a, n) => a.Precision = int.Parse(n.GetScalarValue()) }, - { "scale", (a, n) => a.Scale = int.Parse(n.GetScalarValue()) }, + { "precision", (a, n) => a.Precision = n.GetIntegerValue() }, + { "scale", (a, n) => a.Scale = n.GetIntegerValue() }, }; private static readonly FixedFieldMap UUIDFixedFields = new() diff --git a/test/ByteBard.AsyncAPI.Tests/IntegerParsing_Should.cs b/test/ByteBard.AsyncAPI.Tests/IntegerParsing_Should.cs new file mode 100644 index 0000000..01a0104 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/IntegerParsing_Should.cs @@ -0,0 +1,92 @@ +namespace ByteBard.AsyncAPI.Tests +{ + using System.Globalization; + using FluentAssertions; + using ByteBard.AsyncAPI.Bindings; + using ByteBard.AsyncAPI.Bindings.IBMMQ; + using ByteBard.AsyncAPI.Bindings.ROS2; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using NUnit.Framework; + + [NonParallelizable] + internal class IntegerParsing_Should : TestBase + { + private CultureInfo originalCulture; + + [SetUp] + public void SetCulture() + { + this.originalCulture = CultureInfo.CurrentCulture; + } + + [TearDown] + public void RestoreCulture() + { + CultureInfo.CurrentCulture = this.originalCulture; + } + + // These cultures use a negative sign other than a plain '-' (e.g. U+061C U+002D for ar-SA). + [TestCase("ar-SA")] + [TestCase("he-IL")] + [TestCase("fa-IR")] + public void NegativeIntegers_AreParsed_RegardlessOfCurrentCulture(string culture) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + var yaml = + """ + action: send + channel: + $ref: '#/channels/camera' + bindings: + ros2: + qosPolicies: + lifespan: -1 + deadline: -1 + leaseDuration: -1 + """; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.ROS2 }; + var operation = new AsyncApiStringReader(settings).ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().BeEmpty(); + var qos = ((ROS2OperationBinding)operation.Bindings["ros2"]).QosPolicies; + qos.Lifespan.Should().Be(-1); + qos.Deadline.Should().Be(-1); + qos.LeaseDuration.Should().Be(-1); + } + + [TestCase("ar-SA")] + [TestCase("he-IL")] + public void NegativeLongs_AreParsed_RegardlessOfCurrentCulture(string culture) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + var json = """{ "bindings": { "ibmmq": { "expiry": -5 } } }"""; + + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.IBMMQ }; + var message = new AsyncApiStringReader(settings).ReadFragment(json, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + diagnostics.Errors.Should().BeEmpty(); + ((IBMMQMessageBinding)message.Bindings["ibmmq"]).Expiry.Should().Be(-5); + } + + [TestCase("precision")] + [TestCase("scale")] + public void AvroDecimal_WithNonIntegerSize_ReportsErrorInsteadOfThrowing(string field) + { + var input = $$""" + { + "type": "bytes", + "logicalType": "decimal", + "{{field}}": "abc" + } + """; + + AsyncApiDiagnostic diagnostics = null; + var act = () => new AsyncApiStringReader().ReadFragment(input, AsyncApiVersion.AsyncApi2_0, out diagnostics); + + act.Should().NotThrow(); + diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain("could not parse to integer"); + } + } +}