From d167365b469a67858e0dbfed3d026e6e5f7288a8 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:05:44 +0100 Subject: [PATCH 1/3] fix: drop the JsonPointer.Net dependency from the readers The readers were built against JsonPointer.Net 5.3.1 and used it only to resolve the fragment of an external reference. JsonPointer.Net 7 removed JsonPointer.Parse(string), so an app that pulls in 7.x (for example through JsonSchema.Net 9) hit MissingMethodException when a reference was resolved. Resolve the fragment with the library's own JsonPointer instead. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 1 - src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs | 6 +++--- .../ByteBard.AsyncAPI.Readers.csproj | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) 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..ee29a8a 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,8 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef if (reference.Reference.IsFragment) { - var pointer = JsonPointer.Parse(reference.Reference.FragmentId); - if (pointer.TryEvaluate(json, out var pointerResult)) + var pointerResult = new JsonPointer(reference.Reference.FragmentId).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 From 815428890d478fe2c86efac76a106f773359f2a5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:57:56 +0100 Subject: [PATCH 2/3] test: cover external reference fragment resolution for 2.x and 3.x Pins down how the fragment of an external reference resolves: nested paths, ~1 and ~0 escapes, array indices, and a missing fragment. Run against the previous JsonPointer.Net-based code, these all behave the same except a percent-encoded fragment, which JsonPointer.Net left encoded and failed to resolve. RFC 6901 section 6 requires a pointer in a URI fragment to be percent-decoded, which the library's own JsonPointer does. Co-Authored-By: Claude Opus 5.5 --- .../Models/AsyncApiReference_Should.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs index c5ae485..b0b598b 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -14,6 +14,126 @@ 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")] + [TestCase("3.0.0")] + public void ExternalReference_WithMissingFragment_ReportsError(string version) + { + var yaml = version.StartsWith("2") + ? """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + channels: + workspace: + publish: + message: + payload: + $ref: 'schemas.json#/components/schemas/missing' + """ + : """ + 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#/components/schemas/missing' + """; + + ReadWithExternalSchemas(yaml, out var diagnostic); + + diagnostic.Errors.Should().ContainSingle(e => e.Message == "Could not resolve reference fragment."); + } + + 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() { From 3d84290b343162923d963c8966d99089a64a6c63 Mon Sep 17 00:00:00 2001 From: Arkadiusz Biel - bielu <2244074+bielu@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:27:31 +0100 Subject: [PATCH 3/3] fix!: make JsonPointer follow RFC 6901 Addresses review feedback on the JsonPointer.Net removal: - Malformed pointers (not starting with '/', or '~' not followed by '0'/'1') now throw AsyncApiException instead of resolving to something unintended. The reader reports them as diagnostic errors. - "" is the whole document; "/" is the member with an empty key. - Percent-decoding only applies to the URI fragment form, via the new JsonPointer.FromUriFragment. The string form is taken literally. - ToString re-escapes '~' and '/' and returns "" for the root. BREAKING CHANGE: new JsonPointer("/") no longer points to the root, the constructor no longer percent-decodes, and malformed pointers throw. Co-Authored-By: Claude Opus 5.5 --- .../AsyncApiJsonDocumentReader.cs | 13 +- src/ByteBard.AsyncAPI/JsonPointer.cs | 84 +++++++++-- .../JsonPointer_Should.cs | 133 ++++++++++++++++++ .../Models/AsyncApiReference_Should.cs | 20 +-- 4 files changed, 230 insertions(+), 20 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs index ee29a8a..9b77719 100644 --- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs +++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs @@ -369,7 +369,18 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef if (reference.Reference.IsFragment) { - var pointerResult = new JsonPointer(reference.Reference.FragmentId).Find(json); + 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/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 b0b598b..c883748 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/AsyncApiReference_Should.cs @@ -86,12 +86,16 @@ public void V3_ExternalReference_WithFragment_ResolvesFragment(string fragment, doc.Channels["workspace"].Messages["message"].Payload.Schema.As().Type.Should().Be(expectedType); } - [TestCase("2.6.0")] - [TestCase("3.0.0")] - public void ExternalReference_WithMissingFragment_ReportsError(string version) + [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 @@ -101,9 +105,9 @@ public void ExternalReference_WithMissingFragment_ReportsError(string version) publish: message: payload: - $ref: 'schemas.json#/components/schemas/missing' + $ref: 'schemas.json#{fragment}' """ - : """ + : $""" asyncapi: 3.0.0 info: title: test @@ -115,12 +119,12 @@ public void ExternalReference_WithMissingFragment_ReportsError(string version) payload: schemaFormat: application/vnd.aai.asyncapi+json;version=3.0.0 schema: - $ref: 'schemas.json#/components/schemas/missing' + $ref: 'schemas.json#{fragment}' """; ReadWithExternalSchemas(yaml, out var diagnostic); - diagnostic.Errors.Should().ContainSingle(e => e.Message == "Could not resolve reference fragment."); + diagnostic.Errors.Should().ContainSingle(e => e.Message == expectedMessage); } private static AsyncApiDocument ReadWithExternalSchemas(string yaml, out AsyncApiDiagnostic diagnostic)