diff --git a/Directory.Packages.props b/Directory.Packages.props
index f32b1b6..8e03c77 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,6 @@
-
diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs
index 0229808..9b77719 100644
--- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs
+++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs
@@ -6,12 +6,12 @@ namespace ByteBard.AsyncAPI.Readers
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
- using Json.Pointer;
using ByteBard.AsyncAPI.Exceptions;
using ByteBard.AsyncAPI.Extensions;
using ByteBard.AsyncAPI.Models;
using ByteBard.AsyncAPI.Models.Interfaces;
using ByteBard.AsyncAPI.Readers.Interface;
+ using ByteBard.AsyncAPI.Readers.ParseNodes;
using ByteBard.AsyncAPI.Readers.Services;
using ByteBard.AsyncAPI.Services;
using ByteBard.AsyncAPI.Validations;
@@ -369,8 +369,19 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
if (reference.Reference.IsFragment)
{
- var pointer = JsonPointer.Parse(reference.Reference.FragmentId);
- if (pointer.TryEvaluate(json, out var pointerResult))
+ JsonPointer pointer;
+ try
+ {
+ pointer = JsonPointer.FromUriFragment(reference.Reference.FragmentId);
+ }
+ catch (AsyncApiException ex)
+ {
+ diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, ex.Message));
+ return null;
+ }
+
+ var pointerResult = pointer.Find(json);
+ if (pointerResult != null)
{
json = pointerResult;
}
diff --git a/src/ByteBard.AsyncAPI.Readers/ByteBard.AsyncAPI.Readers.csproj b/src/ByteBard.AsyncAPI.Readers/ByteBard.AsyncAPI.Readers.csproj
index a624a47..ae37cf9 100644
--- a/src/ByteBard.AsyncAPI.Readers/ByteBard.AsyncAPI.Readers.csproj
+++ b/src/ByteBard.AsyncAPI.Readers/ByteBard.AsyncAPI.Readers.csproj
@@ -9,7 +9,6 @@
-
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/ByteBard.AsyncAPI/JsonPointer.cs b/src/ByteBard.AsyncAPI/JsonPointer.cs
index c0bc196..28c8b23 100644
--- a/src/ByteBard.AsyncAPI/JsonPointer.cs
+++ b/src/ByteBard.AsyncAPI/JsonPointer.cs
@@ -2,21 +2,39 @@ namespace ByteBard.AsyncAPI
{
using System;
using System.Linq;
+ using System.Text;
+ using ByteBard.AsyncAPI.Exceptions;
///
- /// JSON pointer.
+ /// JSON pointer as defined by RFC 6901.
///
public class JsonPointer
{
///
/// Initializes the class.
///
- /// Pointer as string.
+ /// Pointer in its JSON string representation (RFC 6901 section 5), e.g. /components/schemas/a~1b.
+ /// An empty string points to the whole document; / points to the member with an empty key.
+ /// The pointer is malformed.
public JsonPointer(string pointer)
{
- this.Tokens = string.IsNullOrEmpty(pointer) || pointer == "/"
- ? new string[0]
- : pointer.Split('/').Skip(1).Select(this.Decode).ToArray();
+ if (pointer == null)
+ {
+ throw new ArgumentNullException(nameof(pointer));
+ }
+
+ if (pointer.Length == 0)
+ {
+ this.Tokens = new string[0];
+ return;
+ }
+
+ if (pointer[0] != '/')
+ {
+ throw new AsyncApiException($"The JSON pointer '{pointer}' is malformed: it must be empty or start with '/'.");
+ }
+
+ this.Tokens = pointer.Substring(1).Split('/').Select(token => Unescape(token, pointer)).ToArray();
}
///
@@ -50,19 +68,63 @@ public JsonPointer ParentPointer
}
///
- /// Decode the string.
+ /// Creates a pointer from its URI fragment identifier representation (RFC 6901 section 6), e.g. the part after #
+ /// in schemas.json#/components/schemas/with%20space. The fragment is percent-decoded before it is parsed.
///
- private string Decode(string token)
+ /// The fragment, without the leading #.
+ /// The fragment is not a valid JSON pointer.
+ public static JsonPointer FromUriFragment(string fragment)
{
- return Uri.UnescapeDataString(token).Replace("~1", "/").Replace("~0", "~");
+ if (fragment == null)
+ {
+ throw new ArgumentNullException(nameof(fragment));
+ }
+
+ return new JsonPointer(Uri.UnescapeDataString(fragment));
}
///
- /// Gets the string representation of this JSON pointer.
+ /// Gets the JSON string representation of this JSON pointer.
///
public override string ToString()
{
- return "/" + string.Join("/", this.Tokens);
+ return string.Concat(this.Tokens.Select(token => "/" + token.Replace("~", "~0").Replace("/", "~1")));
+ }
+
+ private static string Unescape(string token, string pointer)
+ {
+ if (token.IndexOf('~') < 0)
+ {
+ return token;
+ }
+
+ var builder = new StringBuilder(token.Length);
+ for (var i = 0; i < token.Length; i++)
+ {
+ if (token[i] != '~')
+ {
+ builder.Append(token[i]);
+ continue;
+ }
+
+ var next = i + 1 < token.Length ? token[i + 1] : '\0';
+ if (next == '0')
+ {
+ builder.Append('~');
+ }
+ else if (next == '1')
+ {
+ builder.Append('/');
+ }
+ else
+ {
+ throw new AsyncApiException($"The JSON pointer '{pointer}' is malformed: '~' must be followed by '0' or '1'.");
+ }
+
+ i++;
+ }
+
+ return builder.ToString();
}
}
-}
\ No newline at end of file
+}
diff --git a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs
new file mode 100644
index 0000000..520dd24
--- /dev/null
+++ b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs
@@ -0,0 +1,133 @@
+namespace ByteBard.AsyncAPI.Tests
+{
+ using System;
+ using System.Text.Json.Nodes;
+ using ByteBard.AsyncAPI.Exceptions;
+ using ByteBard.AsyncAPI.Readers.ParseNodes;
+ using FluentAssertions;
+ using NUnit.Framework;
+
+ internal class JsonPointer_Should
+ {
+ // The example document from RFC 6901 section 5.
+ private const string RfcDocument =
+ """
+ {
+ "foo": ["bar", "baz"],
+ "": 0,
+ "a/b": 1,
+ "c%d": 2,
+ "e^f": 3,
+ "g|h": 4,
+ "i\\j": 5,
+ "k\"l": 6,
+ " ": 7,
+ "m~n": 8
+ }
+ """;
+
+ [TestCase("/foo/0", "\"bar\"")]
+ [TestCase("/", "0")]
+ [TestCase("/a~1b", "1")]
+ [TestCase("/c%d", "2")]
+ [TestCase("/e^f", "3")]
+ [TestCase("/g|h", "4")]
+ [TestCase("/i\\j", "5")]
+ [TestCase("/k\"l", "6")]
+ [TestCase("/ ", "7")]
+ [TestCase("/m~0n", "8")]
+ public void Find_ResolvesTheRfcStringRepresentationExamples(string pointer, string expected)
+ {
+ var json = JsonNode.Parse(RfcDocument);
+
+ new JsonPointer(pointer).Find(json).ToJsonString().Should().Be(expected);
+ }
+
+ [TestCase("/foo/0", "\"bar\"")]
+ [TestCase("/", "0")]
+ [TestCase("/a~1b", "1")]
+ [TestCase("/c%25d", "2")]
+ [TestCase("/e%5Ef", "3")]
+ [TestCase("/g%7Ch", "4")]
+ [TestCase("/i%5Cj", "5")]
+ [TestCase("/k%22l", "6")]
+ [TestCase("/%20", "7")]
+ [TestCase("/m~0n", "8")]
+ public void Find_ResolvesTheRfcUriFragmentExamples(string fragment, string expected)
+ {
+ var json = JsonNode.Parse(RfcDocument);
+
+ JsonPointer.FromUriFragment(fragment).Find(json).ToJsonString().Should().Be(expected);
+ }
+
+ [Test]
+ public void EmptyPointer_ReferencesTheWholeDocument()
+ {
+ var json = JsonNode.Parse(RfcDocument);
+
+ new JsonPointer(string.Empty).Find(json).Should().BeSameAs(json);
+ JsonPointer.FromUriFragment(string.Empty).Find(json).Should().BeSameAs(json);
+ }
+
+ [Test]
+ public void StringRepresentation_IsNotPercentDecoded()
+ {
+ var json = JsonNode.Parse("""{ "a b": 1 }""");
+
+ new JsonPointer("/a%20b").Find(json).Should().BeNull();
+ }
+
+ [Test]
+ public void Unescape_DecodesTilde1BeforeTilde0()
+ {
+ new JsonPointer("/~01").Tokens.Should().Equal("~1");
+ }
+
+ [TestCase("foo")]
+ [TestCase("#/foo")]
+ [TestCase("/foo~")]
+ [TestCase("/foo~2")]
+ [TestCase("/~a")]
+ public void Constructor_Throws_WhenThePointerIsMalformed(string pointer)
+ {
+ var act = () => new JsonPointer(pointer);
+
+ act.Should().Throw();
+ }
+
+ [TestCase("foo")]
+ [TestCase("/foo~2")]
+ [TestCase("/foo%7E2")]
+ public void FromUriFragment_Throws_WhenTheDecodedPointerIsMalformed(string fragment)
+ {
+ var act = () => JsonPointer.FromUriFragment(fragment);
+
+ act.Should().Throw();
+ }
+
+ [Test]
+ public void Constructor_Throws_WhenThePointerIsNull()
+ {
+ var act = () => new JsonPointer(null);
+
+ act.Should().Throw();
+ }
+
+ [TestCase("")]
+ [TestCase("/")]
+ [TestCase("/foo/0")]
+ [TestCase("/a~1b/m~0n")]
+ [TestCase("//")]
+ public void ToString_RoundTripsTheStringRepresentation(string pointer)
+ {
+ new JsonPointer(pointer).ToString().Should().Be(pointer);
+ }
+
+ [Test]
+ public void ParentPointer_OfASingleTokenPointer_IsTheWholeDocument()
+ {
+ new JsonPointer("/foo").ParentPointer.ToString().Should().BeEmpty();
+ new JsonPointer(string.Empty).ParentPointer.Should().BeNull();
+ }
+ }
+}
diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs
index c5ae485..c883748 100644
--- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs
+++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs
@@ -14,6 +14,130 @@ namespace ByteBard.AsyncAPI.Tests
public class AsyncApiReference_Should : TestBase
{
+ private const string ExternalSchemas =
+ """
+ {
+ "components": {
+ "schemas": {
+ "plain": { "type": "string" },
+ "a/b": { "type": "integer" },
+ "a~b": { "type": "boolean" },
+ "with space": { "type": "number" }
+ }
+ },
+ "list": [ { "type": "object" } ]
+ }
+ """;
+
+ private static readonly TestCaseData[] ExternalFragmentCases =
+ {
+ new TestCaseData("/components/schemas/plain", SchemaType.String).SetArgDisplayNames("nested path"),
+ new TestCaseData("/components/schemas/a~1b", SchemaType.Integer).SetArgDisplayNames("escaped slash"),
+ new TestCaseData("/components/schemas/a~0b", SchemaType.Boolean).SetArgDisplayNames("escaped tilde"),
+ new TestCaseData("/components/schemas/with%20space", SchemaType.Number).SetArgDisplayNames("percent-encoded"),
+ new TestCaseData("/list/0", SchemaType.Object).SetArgDisplayNames("array index"),
+ };
+
+ [TestCaseSource(nameof(ExternalFragmentCases))]
+ public void V2_ExternalReference_WithFragment_ResolvesFragment(string fragment, SchemaType expectedType)
+ {
+ var yaml =
+ $"""
+ asyncapi: 2.6.0
+ info:
+ title: test
+ version: 1.0.0
+ channels:
+ workspace:
+ publish:
+ message:
+ payload:
+ $ref: 'schemas.json#{fragment}'
+ """;
+
+ var doc = ReadWithExternalSchemas(yaml, out var diagnostic);
+
+ diagnostic.Errors.Should().BeEmpty();
+ doc.Channels["workspace"].Messages.Values.First().Payload.Schema.As().Type.Should().Be(expectedType);
+ }
+
+ [TestCaseSource(nameof(ExternalFragmentCases))]
+ public void V3_ExternalReference_WithFragment_ResolvesFragment(string fragment, SchemaType expectedType)
+ {
+ var yaml =
+ $"""
+ asyncapi: 3.0.0
+ info:
+ title: test
+ version: 1.0.0
+ channels:
+ workspace:
+ messages:
+ message:
+ payload:
+ schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0
+ schema:
+ $ref: 'schemas.json#{fragment}'
+ """;
+
+ var doc = ReadWithExternalSchemas(yaml, out var diagnostic);
+
+ diagnostic.Errors.Should().BeEmpty();
+ doc.Channels["workspace"].Messages["message"].Payload.Schema.As().Type.Should().Be(expectedType);
+ }
+
+ [TestCase("2.6.0", "/components/schemas/missing", "Could not resolve reference fragment.")]
+ [TestCase("3.0.0", "/components/schemas/missing", "Could not resolve reference fragment.")]
+ [TestCase("2.6.0", "components/schemas/plain", "The JSON pointer 'components/schemas/plain' is malformed: it must be empty or start with '/'.")]
+ [TestCase("3.0.0", "components/schemas/plain", "The JSON pointer 'components/schemas/plain' is malformed: it must be empty or start with '/'.")]
+ [TestCase("2.6.0", "/components/schemas/a~2b", "The JSON pointer '/components/schemas/a~2b' is malformed: '~' must be followed by '0' or '1'.")]
+ [TestCase("3.0.0", "/components/schemas/a~2b", "The JSON pointer '/components/schemas/a~2b' is malformed: '~' must be followed by '0' or '1'.")]
+ public void ExternalReference_WithUnresolvableFragment_ReportsError(string version, string fragment, string expectedMessage)
+ {
+ var yaml = version.StartsWith("2")
+ ? $"""
+ asyncapi: 2.6.0
+ info:
+ title: test
+ version: 1.0.0
+ channels:
+ workspace:
+ publish:
+ message:
+ payload:
+ $ref: 'schemas.json#{fragment}'
+ """
+ : $"""
+ asyncapi: 3.0.0
+ info:
+ title: test
+ version: 1.0.0
+ channels:
+ workspace:
+ messages:
+ message:
+ payload:
+ schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0
+ schema:
+ $ref: 'schemas.json#{fragment}'
+ """;
+
+ ReadWithExternalSchemas(yaml, out var diagnostic);
+
+ diagnostic.Errors.Should().ContainSingle(e => e.Message == expectedMessage);
+ }
+
+ private static AsyncApiDocument ReadWithExternalSchemas(string yaml, out AsyncApiDiagnostic diagnostic)
+ {
+ var settings = new AsyncApiReaderSettings
+ {
+ ReferenceResolution = ReferenceResolutionSetting.ResolveAllReferences,
+ ExternalReferenceLoader = new MockStringLoader(ExternalSchemas),
+ };
+
+ return new AsyncApiStringReader(settings).Read(yaml, out diagnostic);
+ }
+
[Test]
public void V2_ReferencePointers()
{