Skip to content

Commit 925a9ab

Browse files
bieluclaudeVisualBean
authored
fix: say which value failed to parse and why references could not be resolved (#54)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Alex Wichmann <VisualBean@users.noreply.github.com>
1 parent d6fb3ce commit 925a9ab

5 files changed

Lines changed: 156 additions & 46 deletions

File tree

‎src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ private async Task ResolveReferencesAsync(AsyncApiDiagnostic diagnostic, IAsyncA
197197
continue;
198198
}
199199

200+
var errorCount = diagnostic.Errors.Count;
200201
IAsyncApiSerializable component = null;
201202
if (reference.Reference.IsExternal)
202203
{
@@ -215,7 +216,12 @@ private async Task ResolveReferencesAsync(AsyncApiDiagnostic diagnostic, IAsyncA
215216

216217
if (component == null)
217218
{
218-
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'"));
219+
// Only add a generic error when resolving did not already explain what went wrong.
220+
if (diagnostic.Errors.Count == errorCount)
221+
{
222+
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'"));
223+
}
224+
219225
continue;
220226
}
221227

@@ -242,6 +248,7 @@ private void ResolveReferences(AsyncApiDiagnostic diagnostic, IAsyncApiSerializa
242248
continue;
243249
}
244250

251+
var errorCount = diagnostic.Errors.Count;
245252
IAsyncApiSerializable component = null;
246253
if (reference.Reference.IsExternal)
247254
{
@@ -260,7 +267,12 @@ private void ResolveReferences(AsyncApiDiagnostic diagnostic, IAsyncApiSerializa
260267

261268
if (component == null)
262269
{
263-
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'"));
270+
// Only add a generic error when resolving did not already explain what went wrong.
271+
if (diagnostic.Errors.Count == errorCount)
272+
{
273+
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'"));
274+
}
275+
264276
continue;
265277
}
266278

@@ -349,9 +361,9 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
349361
{
350362
json = this.ReadToJson(stream);
351363
}
352-
catch
364+
catch (Exception ex)
353365
{
354-
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference: '{reference.Reference.Reference}'"));
366+
diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, $"Unable to deserialize reference '{reference.Reference.Reference}': {ex.Message}"));
355367
return null;
356368
}
357369

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace ByteBard.AsyncAPI.Readers.ParseNodes
22
{
3-
using System;
3+
using System.Globalization;
44
using System.Text.Json.Nodes;
55

66
public static class JsonPointerExtensions
@@ -12,29 +12,29 @@ public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNo
1212
return baseJsonNode;
1313
}
1414

15-
try
15+
var pointer = baseJsonNode;
16+
foreach (var token in currentPointer.Tokens)
1617
{
17-
var pointer = baseJsonNode;
18-
foreach (var token in currentPointer.Tokens)
18+
if (pointer is JsonArray sequence)
1919
{
20-
var sequence = pointer as JsonArray;
21-
22-
if (sequence != null && int.TryParse(token, out var tokenValue))
23-
{
24-
pointer = sequence[tokenValue];
25-
}
26-
else if (pointer is JsonObject map && !map.TryGetPropertyValue(token, out pointer))
20+
// RFC 6901 array indices are "0" or digits without a leading zero.
21+
if ((token.Length > 1 && token[0] == '0')
22+
|| !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index)
23+
|| index >= sequence.Count)
2724
{
2825
return null;
2926
}
30-
}
3127

32-
return pointer;
33-
}
34-
catch (Exception)
35-
{
36-
return null;
28+
pointer = sequence[index];
29+
}
30+
else if (pointer is not JsonObject map || !map.TryGetPropertyValue(token, out pointer))
31+
{
32+
// The token walks into a scalar or a missing property.
33+
return null;
34+
}
3735
}
36+
37+
return pointer;
3838
}
3939
}
4040
}

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes
22
{
33
using System;
44
using System.Globalization;
5+
using System.Linq;
56
using System.Text.Json.Nodes;
67
using ByteBard.AsyncAPI.Exceptions;
78
using ByteBard.AsyncAPI.Models;
@@ -53,7 +54,7 @@ public override int GetIntegerValue()
5354
return value;
5455
}
5556

56-
throw new AsyncApiReaderException("Value could not parse to integer.");
57+
throw new AsyncApiReaderException(this.DescribeInvalidInteger("integer", int.MinValue, int.MaxValue), this.Context);
5758
}
5859

5960
public override int? GetIntegerValueOrDefault(int? defaultValue = null)
@@ -73,7 +74,7 @@ public override long GetLongValue()
7374
return value;
7475
}
7576

76-
throw new AsyncApiReaderException("Value could not parse to long.");
77+
throw new AsyncApiReaderException(this.DescribeInvalidInteger("long", long.MinValue, long.MaxValue), this.Context);
7778
}
7879

7980
public override long? GetLongValueOrDefault(long? defaultValue = null)
@@ -93,7 +94,7 @@ public override bool GetBooleanValue()
9394
return value;
9495
}
9596

96-
throw new AsyncApiReaderException("Value could not parse to bool.");
97+
throw new AsyncApiReaderException($"Value '{this.GetScalarValue()}' could not parse to bool. Expected true or false.", this.Context);
9798
}
9899

99100
public override bool? GetBooleanValueOrDefault(bool? defaultValue = null)
@@ -111,5 +112,24 @@ public override AsyncApiAny CreateAny()
111112
var value = this.GetScalarValue();
112113
return new AsyncApiAny(this.node);
113114
}
115+
116+
/// <summary>
117+
/// 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.
118+
/// </summary>
119+
private string DescribeInvalidInteger(string typeName, long min, long max)
120+
{
121+
var value = this.GetScalarValue();
122+
var digits = value?.Trim();
123+
if (digits?.Length > 0 && (digits[0] == '+' || digits[0] == '-'))
124+
{
125+
digits = digits.Substring(1);
126+
}
127+
128+
var isWholeNumber = !string.IsNullOrEmpty(digits) && digits.All(c => c >= '0' && c <= '9');
129+
130+
return isWholeNumber
131+
? $"Value '{value}' could not parse to {typeName}. It must be between {min} and {max}."
132+
: $"Value '{value}' could not parse to {typeName}. Expected a whole number.";
133+
}
114134
}
115135
}

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

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,30 +80,30 @@ public class AsyncApiJsonSchemaDeserializer
8080
}
8181
},
8282
{
83-
"maxLength", (a, n) => { a.MaxLength = ParseInteger(n); }
83+
"maxLength", (a, n) => { a.MaxLength = n.GetIntegerValue(); }
8484
},
8585
{
86-
"minLength", (a, n) => { a.MinLength = ParseInteger(n); }
86+
"minLength", (a, n) => { a.MinLength = n.GetIntegerValue(); }
8787
},
8888
{
8989
"pattern", (a, n) => { a.Pattern = n.GetScalarValue(); }
9090
},
9191
{
92-
"maxItems", (a, n) => { a.MaxItems = ParseInteger(n); }
92+
"maxItems", (a, n) => { a.MaxItems = n.GetIntegerValue(); }
9393
},
9494
{
95-
"minItems", (a, n) => { a.MinItems = ParseInteger(n); }
95+
"minItems", (a, n) => { a.MinItems = n.GetIntegerValue(); }
9696
},
9797
{
98-
"uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); }
98+
"uniqueItems", (a, n) => { a.UniqueItems = n.GetBooleanValue(); }
9999
},
100100
{
101101
"maxProperties",
102-
(a, n) => { a.MaxProperties = ParseInteger(n); }
102+
(a, n) => { a.MaxProperties = n.GetIntegerValue(); }
103103
},
104104
{
105105
"minProperties",
106-
(a, n) => { a.MinProperties = ParseInteger(n); }
106+
(a, n) => { a.MinProperties = n.GetIntegerValue(); }
107107
},
108108
{
109109
"enum", (a, n) => { a.Enum = n.CreateListOfAny(); }
@@ -124,10 +124,10 @@ public class AsyncApiJsonSchemaDeserializer
124124
"else", (a, n) => { a.Else = LoadSchema(n); }
125125
},
126126
{
127-
"readOnly", (a, n) => { a.ReadOnly = bool.Parse(n.GetScalarValue()); }
127+
"readOnly", (a, n) => { a.ReadOnly = n.GetBooleanValue(); }
128128
},
129129
{
130-
"writeOnly", (a, n) => { a.WriteOnly = bool.Parse(n.GetScalarValue()); }
130+
"writeOnly", (a, n) => { a.WriteOnly = n.GetBooleanValue(); }
131131
},
132132
{
133133
"properties", (a, n) => { a.Properties = n.CreateMap(LoadSchema); }
@@ -208,25 +208,13 @@ public class AsyncApiJsonSchemaDeserializer
208208
"externalDocs", (a, n) => { a.ExternalDocs = AsyncApiV2Deserializer.LoadExternalDocs(n); }
209209
},
210210
{
211-
"deprecated", (a, n) => { a.Deprecated = bool.Parse(n.GetScalarValue()); }
211+
"deprecated", (a, n) => { a.Deprecated = n.GetBooleanValue(); }
212212
},
213213
{
214214
"nullable", (a, n) => { a.Nullable = n.GetBooleanValue(); }
215215
},
216216
};
217217

218-
private static int ParseInteger(ParseNode node)
219-
{
220-
try
221-
{
222-
return int.Parse(node.GetScalarValue(), node.Context.Settings.CultureInfo);
223-
}
224-
catch (Exception e)
225-
{
226-
throw new AsyncApiReaderException("The value is out of range.", node.Context);
227-
}
228-
}
229-
230218
private static readonly PatternFieldMap<AsyncApiJsonSchema> schemaPatternFields =
231219
new()
232220
{
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
namespace ByteBard.AsyncAPI.Tests
2+
{
3+
using System.Text.Json.Nodes;
4+
using ByteBard.AsyncAPI.Models;
5+
using ByteBard.AsyncAPI.Readers;
6+
using ByteBard.AsyncAPI.Readers.ParseNodes;
7+
using FluentAssertions;
8+
using NUnit.Framework;
9+
10+
internal class ReaderErrorDetails_Should : TestBase
11+
{
12+
[TestCase("abc", "Value 'abc' could not parse to integer. Expected a whole number.")]
13+
[TestCase("1.5", "Value '1.5' could not parse to integer. Expected a whole number.")]
14+
[TestCase("+-1", "Value '+-1' could not parse to integer. Expected a whole number.")]
15+
[TestCase("--1", "Value '--1' could not parse to integer. Expected a whole number.")]
16+
[TestCase("99999999999","Value '99999999999' could not parse to integer. It must be between -2147483648 and 2147483647.")]
17+
public void SchemaIntegerField_ReportsTheValueAndWhyItIsInvalid(string value, string expectedMessage)
18+
{
19+
var yaml = $"type: string\nmaxLength: '{value}'";
20+
21+
new AsyncApiStringReader().ReadFragment<AsyncApiJsonSchema>(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics);
22+
23+
var error = diagnostics.Errors.Should().ContainSingle().Subject;
24+
error.Message.Should().Be(expectedMessage);
25+
error.Pointer.Should().Be("#/maxLength");
26+
}
27+
28+
[TestCase("uniqueItems")]
29+
[TestCase("readOnly")]
30+
[TestCase("writeOnly")]
31+
[TestCase("deprecated")]
32+
public void SchemaBooleanField_ReportsTheValueAndWhyItIsInvalid(string field)
33+
{
34+
var yaml = $"type: string\n{field}: 'yes please'";
35+
36+
new AsyncApiStringReader().ReadFragment<AsyncApiJsonSchema>(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics);
37+
38+
var error = diagnostics.Errors.Should().ContainSingle().Subject;
39+
error.Message.Should().Be("Value 'yes please' could not parse to bool. Expected true or false.");
40+
error.Pointer.Should().Be($"#/{field}");
41+
}
42+
43+
[Test]
44+
public void MissingLocalReference_IsReportedOnce()
45+
{
46+
var yaml =
47+
"""
48+
asyncapi: 3.0.0
49+
info:
50+
title: test
51+
version: 1.0.0
52+
channels:
53+
users:
54+
messages:
55+
created:
56+
payload:
57+
$ref: '#/components/schemas/Missing'
58+
""";
59+
60+
new AsyncApiStringReader().Read(yaml, out var diagnostics);
61+
62+
diagnostics.Errors.Should().ContainSingle()
63+
.Which.Message.Should().Be("Could not resolve reference fragment.");
64+
}
65+
66+
[TestCase("/info/title/nested")]
67+
[TestCase("/tags/5")]
68+
[TestCase("/tags/name")]
69+
[TestCase("/tags/-0")]
70+
[TestCase("/tags/+0")]
71+
[TestCase("/tags/ 0")]
72+
[TestCase("/tags/00")]
73+
[TestCase("/tags/01")]
74+
[TestCase("/missing")]
75+
public void Find_ReturnsNull_WhenThePointerDoesNotLeadToANode(string pointer)
76+
{
77+
var json = JsonNode.Parse("""{ "info": { "title": "test" }, "tags": [ { "name": "a" } ] }""");
78+
79+
new JsonPointer(pointer).Find(json).Should().BeNull();
80+
}
81+
82+
[Test]
83+
public void Find_FollowsObjectsAndArrays()
84+
{
85+
var json = JsonNode.Parse("""{ "tags": [ { "name": "a" } ] }""");
86+
87+
new JsonPointer("/tags/0/name").Find(json).GetValue<string>().Should().Be("a");
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)