Skip to content

Commit f325f90

Browse files
bieluclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 150d6d7 commit f325f90

3 files changed

Lines changed: 99 additions & 6 deletions

File tree

‎src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace ByteBard.AsyncAPI.Readers.ParseNodes
22
{
33
using System;
4+
using System.Globalization;
45
using System.Text.Json.Nodes;
56
using ByteBard.AsyncAPI.Exceptions;
67
using ByteBard.AsyncAPI.Models;
@@ -47,7 +48,7 @@ public override string GetScalarValueOrDefault(string defaultValue = null)
4748

4849
public override int GetIntegerValue()
4950
{
50-
if (int.TryParse(this.GetScalarValue(), out int value))
51+
if (int.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out int value))
5152
{
5253
return value;
5354
}
@@ -57,7 +58,7 @@ public override int GetIntegerValue()
5758

5859
public override int? GetIntegerValueOrDefault(int? defaultValue = null)
5960
{
60-
if (int.TryParse(this.GetScalarValue(), out int value))
61+
if (int.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out int value))
6162
{
6263
return value;
6364
}
@@ -67,7 +68,7 @@ public override int GetIntegerValue()
6768

6869
public override long GetLongValue()
6970
{
70-
if (long.TryParse(this.GetScalarValue(), out long value))
71+
if (long.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out long value))
7172
{
7273
return value;
7374
}
@@ -77,7 +78,7 @@ public override long GetLongValue()
7778

7879
public override long? GetLongValueOrDefault(long? defaultValue = null)
7980
{
80-
if (long.TryParse(this.GetScalarValue(), out long value))
81+
if (long.TryParse(this.GetScalarValue(), NumberStyles.Integer, this.Context.Settings.CultureInfo, out long value))
8182
{
8283
return value;
8384
}

‎src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiAvroSchemaDeserializer.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ public class AsyncApiAvroSchemaDeserializer
7070
{
7171
{ "type", (a, n) => { } },
7272
{ "logicalType", (a, n) => { } },
73-
{ "precision", (a, n) => a.Precision = int.Parse(n.GetScalarValue()) },
74-
{ "scale", (a, n) => a.Scale = int.Parse(n.GetScalarValue()) },
73+
{ "precision", (a, n) => a.Precision = n.GetIntegerValue() },
74+
{ "scale", (a, n) => a.Scale = n.GetIntegerValue() },
7575
};
7676

7777
private static readonly FixedFieldMap<AvroUUID> UUIDFixedFields = new()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
namespace ByteBard.AsyncAPI.Tests
2+
{
3+
using System.Globalization;
4+
using FluentAssertions;
5+
using ByteBard.AsyncAPI.Bindings;
6+
using ByteBard.AsyncAPI.Bindings.IBMMQ;
7+
using ByteBard.AsyncAPI.Bindings.ROS2;
8+
using ByteBard.AsyncAPI.Models;
9+
using ByteBard.AsyncAPI.Readers;
10+
using NUnit.Framework;
11+
12+
[NonParallelizable]
13+
internal class IntegerParsing_Should : TestBase
14+
{
15+
private CultureInfo originalCulture;
16+
17+
[SetUp]
18+
public void SetCulture()
19+
{
20+
this.originalCulture = CultureInfo.CurrentCulture;
21+
}
22+
23+
[TearDown]
24+
public void RestoreCulture()
25+
{
26+
CultureInfo.CurrentCulture = this.originalCulture;
27+
}
28+
29+
// These cultures use a negative sign other than a plain '-' (e.g. U+061C U+002D for ar-SA).
30+
[TestCase("ar-SA")]
31+
[TestCase("he-IL")]
32+
[TestCase("fa-IR")]
33+
public void NegativeIntegers_AreParsed_RegardlessOfCurrentCulture(string culture)
34+
{
35+
CultureInfo.CurrentCulture = new CultureInfo(culture);
36+
var yaml =
37+
"""
38+
action: send
39+
channel:
40+
$ref: '#/channels/camera'
41+
bindings:
42+
ros2:
43+
qosPolicies:
44+
lifespan: -1
45+
deadline: -1
46+
leaseDuration: -1
47+
""";
48+
49+
var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.ROS2 };
50+
var operation = new AsyncApiStringReader(settings).ReadFragment<AsyncApiOperation>(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics);
51+
52+
diagnostics.Errors.Should().BeEmpty();
53+
var qos = ((ROS2OperationBinding)operation.Bindings["ros2"]).QosPolicies;
54+
qos.Lifespan.Should().Be(-1);
55+
qos.Deadline.Should().Be(-1);
56+
qos.LeaseDuration.Should().Be(-1);
57+
}
58+
59+
[TestCase("ar-SA")]
60+
[TestCase("he-IL")]
61+
public void NegativeLongs_AreParsed_RegardlessOfCurrentCulture(string culture)
62+
{
63+
CultureInfo.CurrentCulture = new CultureInfo(culture);
64+
var json = """{ "bindings": { "ibmmq": { "expiry": -5 } } }""";
65+
66+
var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.IBMMQ };
67+
var message = new AsyncApiStringReader(settings).ReadFragment<AsyncApiMessage>(json, AsyncApiVersion.AsyncApi3_0, out var diagnostics);
68+
69+
diagnostics.Errors.Should().BeEmpty();
70+
((IBMMQMessageBinding)message.Bindings["ibmmq"]).Expiry.Should().Be(-5);
71+
}
72+
73+
[TestCase("precision")]
74+
[TestCase("scale")]
75+
public void AvroDecimal_WithNonIntegerSize_ReportsErrorInsteadOfThrowing(string field)
76+
{
77+
var input = $$"""
78+
{
79+
"type": "bytes",
80+
"logicalType": "decimal",
81+
"{{field}}": "abc"
82+
}
83+
""";
84+
85+
AsyncApiDiagnostic diagnostics = null;
86+
var act = () => new AsyncApiStringReader().ReadFragment<AsyncApiAvroSchema>(input, AsyncApiVersion.AsyncApi2_0, out diagnostics);
87+
88+
act.Should().NotThrow();
89+
diagnostics.Errors.Should().ContainSingle().Which.Message.Should().Contain("could not parse to integer");
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)