Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ private async Task ResolveReferencesAsync(AsyncApiDiagnostic diagnostic, IAsyncA
continue;
}

var errorCount = diagnostic.Errors.Count;
IAsyncApiSerializable component = null;
if (reference.Reference.IsExternal)
{
Expand All @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit on the simple side, but lets run with it for now.

{
diagnostic.Errors.Add(new AsyncApiError(string.Empty, $"Unable to deserialize reference '{reference.Reference.Reference}'"));
}

continue;
}

Expand All @@ -242,6 +248,7 @@ private void ResolveReferences(AsyncApiDiagnostic diagnostic, IAsyncApiSerializa
continue;
}

var errorCount = diagnostic.Errors.Count;
IAsyncApiSerializable component = null;
if (reference.Reference.IsExternal)
{
Expand All @@ -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;
}

Expand Down Expand Up @@ -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}"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh how I love the naming rules i made up for the reference heirachy. reference.Reference.Reference

return null;
}

Expand Down
34 changes: 17 additions & 17 deletions src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace ByteBard.AsyncAPI.Readers.ParseNodes
{
using System;
using System.Globalization;
using System.Text.Json.Nodes;

public static class JsonPointerExtensions
Expand All @@ -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;
}
}
}
26 changes: 23 additions & 3 deletions src/ByteBard.AsyncAPI.Readers/ParseNodes/ValueNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -111,5 +112,24 @@ public override AsyncApiAny CreateAny()
var value = this.GetScalarValue();
return new AsyncApiAny(this.node);
}

/// <summary>
/// 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.
/// </summary>
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.";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
Expand All @@ -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); }
Expand Down Expand Up @@ -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<AsyncApiJsonSchema> schemaPatternFields =
new()
{
Expand Down
90 changes: 90 additions & 0 deletions test/ByteBard.AsyncAPI.Tests/ReaderErrorDetails_Should.cs
Original file line number Diff line number Diff line change
@@ -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<AsyncApiJsonSchema>(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<AsyncApiJsonSchema>(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<string>().Should().Be("a");
}
}
}
Loading