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"); + } + } +}