Skip to content

Commit 199ade8

Browse files
bieluclaude
andcommitted
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>
1 parent 70e73db commit 199ade8

5 files changed

Lines changed: 121 additions & 42 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: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
namespace ByteBard.AsyncAPI.Readers.ParseNodes
22
{
3-
using System;
43
using System.Text.Json.Nodes;
54

65
public static class JsonPointerExtensions
@@ -12,29 +11,26 @@ public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNo
1211
return baseJsonNode;
1312
}
1413

15-
try
14+
var pointer = baseJsonNode;
15+
foreach (var token in currentPointer.Tokens)
1616
{
17-
var pointer = baseJsonNode;
18-
foreach (var token in currentPointer.Tokens)
17+
if (pointer is JsonArray sequence)
1918
{
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))
19+
if (!int.TryParse(token, out var index) || index < 0 || index >= sequence.Count)
2720
{
2821
return null;
2922
}
30-
}
3123

32-
return pointer;
33-
}
34-
catch (Exception)
35-
{
36-
return null;
24+
pointer = sequence[index];
25+
}
26+
else if (pointer is not JsonObject map || !map.TryGetPropertyValue(token, out pointer))
27+
{
28+
// The token walks into a scalar or a missing property.
29+
return null;
30+
}
3731
}
32+
33+
return pointer;
3834
}
3935
}
4036
}

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

Lines changed: 18 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,19 @@ 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().TrimStart('+', '-');
123+
var isWholeNumber = !string.IsNullOrEmpty(digits) && digits.All(c => c >= '0' && c <= '9');
124+
125+
return isWholeNumber
126+
? $"Value '{value}' could not parse to {typeName}. It must be between {min} and {max}."
127+
: $"Value '{value}' could not parse to {typeName}. Expected a whole number.";
128+
}
114129
}
115130
}

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

Lines changed: 6 additions & 18 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
{
9898
"uniqueItems", (a, n) => { a.UniqueItems = bool.Parse(n.GetScalarValue()); }
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(); }
@@ -215,18 +215,6 @@ public class AsyncApiJsonSchemaDeserializer
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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("99999999999", "Value '99999999999' could not parse to integer. It must be between -2147483648 and 2147483647.")]
15+
public void SchemaIntegerField_ReportsTheValueAndWhyItIsInvalid(string value, string expectedMessage)
16+
{
17+
var yaml = $"type: string\nmaxLength: '{value}'";
18+
19+
new AsyncApiStringReader().ReadFragment<AsyncApiJsonSchema>(yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostics);
20+
21+
var error = diagnostics.Errors.Should().ContainSingle().Subject;
22+
error.Message.Should().Be(expectedMessage);
23+
error.Pointer.Should().Be("#/maxLength");
24+
}
25+
26+
[Test]
27+
public void MissingLocalReference_IsReportedOnce()
28+
{
29+
var yaml =
30+
"""
31+
asyncapi: 3.0.0
32+
info:
33+
title: test
34+
version: 1.0.0
35+
channels:
36+
users:
37+
messages:
38+
created:
39+
payload:
40+
$ref: '#/components/schemas/Missing'
41+
""";
42+
43+
new AsyncApiStringReader().Read(yaml, out var diagnostics);
44+
45+
diagnostics.Errors.Should().ContainSingle()
46+
.Which.Message.Should().Be("Could not resolve reference fragment.");
47+
}
48+
49+
[TestCase("/info/title/nested")]
50+
[TestCase("/tags/5")]
51+
[TestCase("/tags/name")]
52+
[TestCase("/missing")]
53+
public void Find_ReturnsNull_WhenThePointerDoesNotLeadToANode(string pointer)
54+
{
55+
var json = JsonNode.Parse("""{ "info": { "title": "test" }, "tags": [ { "name": "a" } ] }""");
56+
57+
new JsonPointer(pointer).Find(json).Should().BeNull();
58+
}
59+
60+
[Test]
61+
public void Find_FollowsObjectsAndArrays()
62+
{
63+
var json = JsonNode.Parse("""{ "tags": [ { "name": "a" } ] }""");
64+
65+
new JsonPointer("/tags/0/name").Find(json).GetValue<string>().Should().Be("a");
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)