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..a287b95 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs @@ -1,6 +1,6 @@ namespace ByteBard.AsyncAPI.Readers.ParseNodes { - using System; + using System.Globalization; using System.Text.Json.Nodes; public static class JsonPointerExtensions @@ -12,29 +12,29 @@ 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)) + // 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; } - } - 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..602a0a1 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,24 @@ 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(); + 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 + ? $"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..ace8706 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()); } + "uniqueItems", (a, n) => { a.UniqueItems = n.GetBooleanValue(); } }, { "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(); } @@ -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,25 +208,13 @@ 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(); } }, }; - 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..2a24180 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs @@ -0,0 +1,90 @@ +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("+-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}'"; + + 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"); + } + + [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() + { + 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("/tags/-0")] + [TestCase("/tags/+0")] + [TestCase("/tags/ 0")] + [TestCase("/tags/00")] + [TestCase("/tags/01")] + [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"); + } + } +}