From 199ade86b774b6c7637946f1b9161931f0689e9f Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:33:58 +0100 Subject: [PATCH 1/3] fix: say which value failed to parse and why references could not be resolved - Integer, long and bool scalar errors now include the value and the reason (not a whole number vs. out of range) and the location of the field. Schema integer fields used a catch-all that reported every failure as "The value is out of range"; they now use the same helper. - A reference whose document cannot be read now reports the underlying error, and a failed reference is no longer reported twice (once with the specific reason, then again as "Unable to deserialize reference"). - JsonPointer.Find no longer swallows exceptions. It also returned the scalar it walked into for pointers such as /info/title/nested instead of null. Co-Authored-By: Claude Opus 5.5 --- .../AsyncApiJsonDocumentReader.cs | 20 ++++-- .../ParseNodes/JsonPointerExtensions.cs | 30 ++++---- .../ParseNodes/ValueNode.cs | 21 +++++- .../Schemas/AsyncApiJsonSchemaDeserializer.cs | 24 ++----- .../ReaderErrorDetails_Should.cs | 68 +++++++++++++++++++ 5 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs index e51742d..0229808 100644 --- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs +++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs @@ -197,6 +197,7 @@ private async Task ResolveReferencesAsync(AsyncApiDiagnostic diagnostic, IAsyncA continue; } + var errorCount = diagnostic.Errors.Count; IAsyncApiSerializable component = null; if (reference.Reference.IsExternal) { @@ -215,7 +216,12 @@ private async Task ResolveReferencesAsync(AsyncApiDiagnostic diagnostic, IAsyncA if (component == null) { - diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'")); + // Only add a generic error when resolving did not already explain what went wrong. + if (diagnostic.Errors.Count == errorCount) + { + diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'")); + } + continue; } @@ -242,6 +248,7 @@ private void ResolveReferences(AsyncApiDiagnostic diagnostic, IAsyncApiSerializa continue; } + var errorCount = diagnostic.Errors.Count; IAsyncApiSerializable component = null; if (reference.Reference.IsExternal) { @@ -260,7 +267,12 @@ private void ResolveReferences(AsyncApiDiagnostic diagnostic, IAsyncApiSerializa if (component == null) { - diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'")); + // Only add a generic error when resolving did not already explain what went wrong. + if (diagnostic.Errors.Count == errorCount) + { + diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'")); + } + continue; } @@ -349,9 +361,9 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef { json = this.ReadToJson(stream); } - catch + catch (Exception ex) { - diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference: '{reference.Reference.Reference}'")); + diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, $"Unable to deserialize reference '{reference.Reference.Reference}': {ex.Message}")); return null; } diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs index 70fb101..74f5cd9 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs @@ -1,6 +1,5 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes { - using System; using System.Text.Json.Nodes; public static class JsonPointerExtensions @@ -12,29 +11,26 @@ public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNo return baseJsonNode; } - try + var pointer = baseJsonNode; + foreach (var token in currentPointer.Tokens) { - var pointer = baseJsonNode; - foreach (var token in currentPointer.Tokens) + if (pointer is JsonArray sequence) { - var sequence = pointer as JsonArray; - - if (sequence != null && int.TryParse(token, out var tokenValue)) - { - pointer = sequence[tokenValue]; - } - else if (pointer is JsonObject map && !map.TryGetPropertyValue(token, out pointer)) + if (!int.TryParse(token, out var index) || index < 0 || index >= sequence.Count) { return null; } - } - return pointer; - } - catch (Exception) - { - return null; + pointer = sequence[index]; + } + else if (pointer is not JsonObject map || !map.TryGetPropertyValue(token, out pointer)) + { + // The token walks into a scalar or a missing property. + return null; + } } + + return pointer; } } } \ No newline at end of file diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs index 78fa352..03a4d36 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs @@ -2,6 +2,7 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes { using System; using System.Globalization; + using System.Linq; using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Models; @@ -53,7 +54,7 @@ public override int GetIntegerValue() return value; } - throw new AsyncApiReaderException("Value could not parse to integer."); + throw new AsyncApiReaderException(this.DescribeInvalidInteger("integer", int.MinValue, int.MaxValue), this.Context); } public override int? GetIntegerValueOrDefault(int? defaultValue = null) @@ -73,7 +74,7 @@ public override long GetLongValue() return value; } - throw new AsyncApiReaderException("Value could not parse to long."); + throw new AsyncApiReaderException(this.DescribeInvalidInteger("long", long.MinValue, long.MaxValue), this.Context); } public override long? GetLongValueOrDefault(long? defaultValue = null) @@ -93,7 +94,7 @@ public override bool GetBooleanValue() return value; } - throw new AsyncApiReaderException("Value could not parse to bool."); + throw new AsyncApiReaderException($"Value '{this.GetScalarValue()}' could not parse to bool. Expected true or false.", this.Context); } public override bool? GetBooleanValueOrDefault(bool? defaultValue = null) @@ -111,5 +112,19 @@ public override AsyncApiAny CreateAny() var value = this.GetScalarValue(); return new AsyncApiAny(this.node); } + + /// + /// Describes why the scalar is not a valid integer of the given type, telling an out-of-range number apart from a value that is not a whole number. + /// + private string DescribeInvalidInteger(string typeName, long min, long max) + { + var value = this.GetScalarValue(); + var digits = value?.Trim().TrimStart('+', '-'); + var isWholeNumber = !string.IsNullOrEmpty(digits) && digits.All(c => c >= '0' && c <= '9'); + + return isWholeNumber + ? $"Value '{value}' could not parse to {typeName}. It must be between {min} and {max}." + : $"Value '{value}' could not parse to {typeName}. Expected a whole number."; + } } } \ No newline at end of file diff --git a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs index 4813619..2bf4c58 100644 --- a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs @@ -80,30 +80,30 @@ public class AsyncApiJsonSchemaDeserializer } }, { - "maxLength", (a, n) => { a.MaxLength = ParseInteger(n); } + "maxLength", (a, n) => { a.MaxLength = n.GetIntegerValue(); } }, { - "minLength", (a, n) => { a.MinLength = ParseInteger(n); } + "minLength", (a, n) => { a.MinLength = n.GetIntegerValue(); } }, { "pattern", (a, n) => { a.Pattern = n.GetScalarValue(); } }, { - "maxItems", (a, n) => { a.MaxItems = ParseInteger(n); } + "maxItems", (a, n) => { a.MaxItems = n.GetIntegerValue(); } }, { - "minItems", (a, n) => { a.MinItems = ParseInteger(n); } + "minItems", (a, n) => { a.MinItems = n.GetIntegerValue(); } }, { "uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); } }, { "maxProperties", - (a, n) => { a.MaxProperties = ParseInteger(n); } + (a, n) => { a.MaxProperties = n.GetIntegerValue(); } }, { "minProperties", - (a, n) => { a.MinProperties = ParseInteger(n); } + (a, n) => { a.MinProperties = n.GetIntegerValue(); } }, { "enum", (a, n) => { a.Enum = n.CreateListOfAny(); } @@ -215,18 +215,6 @@ public class AsyncApiJsonSchemaDeserializer }, }; - private static int ParseInteger(ParseNode node) - { - try - { - return int.Parse(node.GetScalarValue(), node.Context.Settings.CultureInfo); - } - catch (Exception e) - { - throw new AsyncApiReaderException("The value is out of range.", node.Context); - } - } - private static readonly PatternFieldMap schemaPatternFields = new() { diff --git a/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs new file mode 100644 index 0000000..ac9cd66 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs @@ -0,0 +1,68 @@ +namespace ByteBard.AsyncAPI.Tests +{ + using System.Text.Json.Nodes; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using ByteBard.AsyncAPI.Readers.ParseNodes; + using FluentAssertions; + using NUnit.Framework; + + internal class ReaderErrorDetails_Should : TestBase + { + [TestCase("abc", "Value 'abc' could not parse to integer. Expected a whole number.")] + [TestCase("1.5", "Value '1.5' could not parse to integer. Expected a whole number.")] + [TestCase("99999999999", "Value '99999999999' could not parse to integer. It must be between -2147483648 and 2147483647.")] + public void SchemaIntegerField_ReportsTheValueAndWhyItIsInvalid(string value, string expectedMessage) + { + var yaml = $"type: string\nmaxLength: '{value}'"; + + new AsyncApiStringReader().ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + var error = diagnostics.Errors.Should().ContainSingle().Subject; + error.Message.Should().Be(expectedMessage); + error.Pointer.Should().Be("#/maxLength"); + } + + [Test] + public void MissingLocalReference_IsReportedOnce() + { + var yaml = + """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + channels: + users: + messages: + created: + payload: + $ref: '#/components/schemas/Missing' + """; + + new AsyncApiStringReader().Read(yaml, out var diagnostics); + + diagnostics.Errors.Should().ContainSingle() + .Which.Message.Should().Be("Could not resolve reference fragment."); + } + + [TestCase("/info/title/nested")] + [TestCase("/tags/5")] + [TestCase("/tags/name")] + [TestCase("/missing")] + public void Find_ReturnsNull_WhenThePointerDoesNotLeadToANode(string pointer) + { + var json = JsonNode.Parse("""{ "info": { "title": "test" }, "tags": [ { "name": "a" } ] }"""); + + new JsonPointer(pointer).Find(json).Should().BeNull(); + } + + [Test] + public void Find_FollowsObjectsAndArrays() + { + var json = JsonNode.Parse("""{ "tags": [ { "name": "a" } ] }"""); + + new JsonPointer("/tags/0/name").Find(json).GetValue().Should().Be("a"); + } + } +} From 0c634445afb67be0e00165abd63662c1cb6e8195 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:16:05 +0100 Subject: [PATCH 2/3] fix: accept only unsigned digits as pointer array indices and strip one integer sign Parse JSON pointer array tokens with NumberStyles.None and the invariant culture, so tokens such as "-0", "+0" or " 0" no longer resolve to an element. When explaining an invalid integer, strip at most one leading sign so values such as "+-1" are reported as malformed rather than out of range. Co-Authored-By: Claude Opus 5.5 --- .../ParseNodes/JsonPointerExtensions.cs | 3 ++- src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs | 7 ++++++- test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs index 74f5cd9..dc5437c 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs @@ -1,5 +1,6 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes { + using System.Globalization; using System.Text.Json.Nodes; public static class JsonPointerExtensions @@ -16,7 +17,7 @@ public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNo { if (pointer is JsonArray sequence) { - if (!int.TryParse(token, out var index) || index < 0 || index >= sequence.Count) + if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) || index >= sequence.Count) { return null; } diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs index 03a4d36..602a0a1 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs @@ -119,7 +119,12 @@ public override AsyncApiAny CreateAny() private string DescribeInvalidInteger(string typeName, long min, long max) { var value = this.GetScalarValue(); - var digits = value?.Trim().TrimStart('+', '-'); + var digits = value?.Trim(); + if (digits?.Length > 0 && (digits[0] == '+' || digits[0] == '-')) + { + digits = digits.Substring(1); + } + var isWholeNumber = !string.IsNullOrEmpty(digits) && digits.All(c => c >= '0' && c <= '9'); return isWholeNumber diff --git a/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs index ac9cd66..4ca42fa 100644 --- a/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs @@ -11,7 +11,9 @@ internal class ReaderErrorDetails_Should : TestBase { [TestCase("abc", "Value 'abc' could not parse to integer. Expected a whole number.")] [TestCase("1.5", "Value '1.5' could not parse to integer. Expected a whole number.")] - [TestCase("99999999999", "Value '99999999999' could not parse to integer. It must be between -2147483648 and 2147483647.")] + [TestCase("+-1", "Value '+-1' could not parse to integer. Expected a whole number.")] + [TestCase("--1", "Value '--1' could not parse to integer. Expected a whole number.")] + [TestCase("99999999999","Value '99999999999' could not parse to integer. It must be between -2147483648 and 2147483647.")] public void SchemaIntegerField_ReportsTheValueAndWhyItIsInvalid(string value, string expectedMessage) { var yaml = $"type: string\nmaxLength: '{value}'"; @@ -49,6 +51,9 @@ public void MissingLocalReference_IsReportedOnce() [TestCase("/info/title/nested")] [TestCase("/tags/5")] [TestCase("/tags/name")] + [TestCase("/tags/-0")] + [TestCase("/tags/+0")] + [TestCase("/tags/ 0")] [TestCase("/missing")] public void Find_ReturnsNull_WhenThePointerDoesNotLeadToANode(string pointer) { From 9b361e9063b29d01fe3aeb3f44191eed208b462a Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:38:55 +0100 Subject: [PATCH 3/3] fix: reject leading-zero pointer indices and report invalid schema booleans Array tokens with a leading zero ("01", "00") no longer resolve, per RFC 6901. The uniqueItems, readOnly, writeOnly and deprecated schema fields now use GetBooleanValue, so an invalid value produces a diagnostic naming the value instead of an unhandled FormatException. Co-Authored-By: Claude Opus 5.5 --- .../ParseNodes/JsonPointerExtensions.cs | 5 ++++- .../Schemas/AsyncApiJsonSchemaDeserializer.cs | 8 ++++---- .../ReaderErrorDetails_Should.cs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs index dc5437c..a287b95 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs @@ -17,7 +17,10 @@ public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNo { if (pointer is JsonArray sequence) { - if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) || index >= sequence.Count) + // RFC 6901 array indices are "0" or digits without a leading zero. + if ((token.Length > 1 && token[0] == '0') + || !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) + || index >= sequence.Count) { return null; } diff --git a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs index 2bf4c58..ace8706 100644 --- a/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/Schemas/AsyncApiJsonSchemaDeserializer.cs @@ -95,7 +95,7 @@ public class AsyncApiJsonSchemaDeserializer "minItems", (a, n) => { a.MinItems = n.GetIntegerValue(); } }, { - "uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); } + "uniqueItems", (a, n) => { a.UniqueItems = n.GetBooleanValue(); } }, { "maxProperties", @@ -124,10 +124,10 @@ public class AsyncApiJsonSchemaDeserializer "else", (a, n) => { a.Else = LoadSchema(n); } }, { - "readOnly", (a, n) => { a.ReadOnly = bool.Parse(n.GetScalarValue()); } + "readOnly", (a, n) => { a.ReadOnly = n.GetBooleanValue(); } }, { - "writeOnly", (a, n) => { a.WriteOnly = bool.Parse(n.GetScalarValue()); } + "writeOnly", (a, n) => { a.WriteOnly = n.GetBooleanValue(); } }, { "properties", (a, n) => { a.Properties = n.CreateMap(LoadSchema); } @@ -208,7 +208,7 @@ public class AsyncApiJsonSchemaDeserializer "externalDocs", (a, n) => { a.ExternalDocs = AsyncApiV2Deserializer.LoadExternalDocs(n); } }, { - "deprecated", (a, n) => { a.Deprecated = bool.Parse(n.GetScalarValue()); } + "deprecated", (a, n) => { a.Deprecated = n.GetBooleanValue(); } }, { "nullable", (a, n) => { a.Nullable = n.GetBooleanValue(); } diff --git a/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs index 4ca42fa..2a24180 100644 --- a/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs @@ -25,6 +25,21 @@ public void SchemaIntegerField_ReportsTheValueAndWhyItIsInvalid(string value, st error.Pointer.Should().Be("#/maxLength"); } + [TestCase("uniqueItems")] + [TestCase("readOnly")] + [TestCase("writeOnly")] + [TestCase("deprecated")] + public void SchemaBooleanField_ReportsTheValueAndWhyItIsInvalid(string field) + { + var yaml = $"type: string\n{field}: 'yes please'"; + + new AsyncApiStringReader().ReadFragment(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics); + + var error = diagnostics.Errors.Should().ContainSingle().Subject; + error.Message.Should().Be("Value 'yes please' could not parse to bool. Expected true or false."); + error.Pointer.Should().Be($"#/{field}"); + } + [Test] public void MissingLocalReference_IsReportedOnce() { @@ -54,6 +69,8 @@ public void MissingLocalReference_IsReportedOnce() [TestCase("/tags/-0")] [TestCase("/tags/+0")] [TestCase("/tags/ 0")] + [TestCase("/tags/00")] + [TestCase("/tags/01")] [TestCase("/missing")] public void Find_ReturnsNull_WhenThePointerDoesNotLeadToANode(string pointer) {