Skip to content

Commit 7ed7e04

Browse files
authored
fix!: drop the JsonPointer.Net dependency from the readers (#56)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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.
1 parent 7eed22a commit 7ed7e04

6 files changed

Lines changed: 344 additions & 16 deletions

File tree

‎Directory.Packages.props‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
</PropertyGroup>
55

66
<ItemGroup Label="Library">
7-
<PackageVersion Include="JsonPointer.Net" Version="5.3.1" />
87
<PackageVersion Include="System.Text.Json" Version="9.0.1" />
98
<PackageVersion Include="YamlDotNet" Version="13.0.1" />
109
</ItemGroup>

‎src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ namespace ByteBard.AsyncAPI.Readers
66
using System.Text.Json.Nodes;
77
using System.Threading;
88
using System.Threading.Tasks;
9-
using Json.Pointer;
109
using ByteBard.AsyncAPI.Exceptions;
1110
using ByteBard.AsyncAPI.Extensions;
1211
using ByteBard.AsyncAPI.Models;
1312
using ByteBard.AsyncAPI.Models.Interfaces;
1413
using ByteBard.AsyncAPI.Readers.Interface;
14+
using ByteBard.AsyncAPI.Readers.ParseNodes;
1515
using ByteBard.AsyncAPI.Readers.Services;
1616
using ByteBard.AsyncAPI.Services;
1717
using ByteBard.AsyncAPI.Validations;
@@ -369,8 +369,19 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
369369

370370
if (reference.Reference.IsFragment)
371371
{
372-
var pointer = JsonPointer.Parse(reference.Reference.FragmentId);
373-
if (pointer.TryEvaluate(json, out var pointerResult))
372+
JsonPointer pointer;
373+
try
374+
{
375+
pointer = JsonPointer.FromUriFragment(reference.Reference.FragmentId);
376+
}
377+
catch (AsyncApiException ex)
378+
{
379+
diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, ex.Message));
380+
return null;
381+
}
382+
383+
var pointerResult = pointer.Find(json);
384+
if (pointerResult != null)
374385
{
375386
json = pointerResult;
376387
}

‎src/ByteBard.AsyncAPI.Readers/ByteBard.AsyncAPI.Readers.csproj‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="JsonPointer.Net" />
1312
<PackageReference Include="Microsoft.SourceLink.GitHub">
1413
<PrivateAssets>all</PrivateAssets>
1514
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

‎src/ByteBard.AsyncAPI/JsonPointer.cs‎

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,39 @@ namespace ByteBard.AsyncAPI
22
{
33
using System;
44
using System.Linq;
5+
using System.Text;
6+
using ByteBard.AsyncAPI.Exceptions;
57

68
/// <summary>
7-
/// JSON pointer.
9+
/// JSON pointer as defined by RFC 6901.
810
/// </summary>
911
public class JsonPointer
1012
{
1113
/// <summary>
1214
/// Initializes the <see cref="JsonPointer"/> class.
1315
/// </summary>
14-
/// <param name="pointer">Pointer as string.</param>
16+
/// <param name="pointer">Pointer in its JSON string representation (RFC 6901 section 5), e.g. <c>/components/schemas/a~1b</c>.
17+
/// An empty string points to the whole document; <c>/</c> points to the member with an empty key.</param>
18+
/// <exception cref="AsyncApiException">The pointer is malformed.</exception>
1519
public JsonPointer(string pointer)
1620
{
17-
this.Tokens = string.IsNullOrEmpty(pointer) || pointer == "/"
18-
? new string[0]
19-
: pointer.Split('/').Skip(1).Select(this.Decode).ToArray();
21+
if (pointer == null)
22+
{
23+
throw new ArgumentNullException(nameof(pointer));
24+
}
25+
26+
if (pointer.Length == 0)
27+
{
28+
this.Tokens = new string[0];
29+
return;
30+
}
31+
32+
if (pointer[0] != '/')
33+
{
34+
throw new AsyncApiException($"The JSON pointer '{pointer}' is malformed: it must be empty or start with '/'.");
35+
}
36+
37+
this.Tokens = pointer.Substring(1).Split('/').Select(token => Unescape(token, pointer)).ToArray();
2038
}
2139

2240
/// <summary>
@@ -50,19 +68,63 @@ public JsonPointer ParentPointer
5068
}
5169

5270
/// <summary>
53-
/// Decode the string.
71+
/// Creates a pointer from its URI fragment identifier representation (RFC 6901 section 6), e.g. the part after <c>#</c>
72+
/// in <c>schemas.json#/components/schemas/with%20space</c>. The fragment is percent-decoded before it is parsed.
5473
/// </summary>
55-
private string Decode(string token)
74+
/// <param name="fragment">The fragment, without the leading <c>#</c>.</param>
75+
/// <exception cref="AsyncApiException">The fragment is not a valid JSON pointer.</exception>
76+
public static JsonPointer FromUriFragment(string fragment)
5677
{
57-
return Uri.UnescapeDataString(token).Replace("~1", "/").Replace("~0", "~");
78+
if (fragment == null)
79+
{
80+
throw new ArgumentNullException(nameof(fragment));
81+
}
82+
83+
return new JsonPointer(Uri.UnescapeDataString(fragment));
5884
}
5985

6086
/// <summary>
61-
/// Gets the string representation of this JSON pointer.
87+
/// Gets the JSON string representation of this JSON pointer.
6288
/// </summary>
6389
public override string ToString()
6490
{
65-
return "/" + string.Join("/", this.Tokens);
91+
return string.Concat(this.Tokens.Select(token => "/" + token.Replace("~", "~0").Replace("/", "~1")));
92+
}
93+
94+
private static string Unescape(string token, string pointer)
95+
{
96+
if (token.IndexOf('~') < 0)
97+
{
98+
return token;
99+
}
100+
101+
var builder = new StringBuilder(token.Length);
102+
for (var i = 0; i < token.Length; i++)
103+
{
104+
if (token[i] != '~')
105+
{
106+
builder.Append(token[i]);
107+
continue;
108+
}
109+
110+
var next = i + 1 < token.Length ? token[i + 1] : '\0';
111+
if (next == '0')
112+
{
113+
builder.Append('~');
114+
}
115+
else if (next == '1')
116+
{
117+
builder.Append('/');
118+
}
119+
else
120+
{
121+
throw new AsyncApiException($"The JSON pointer '{pointer}' is malformed: '~' must be followed by '0' or '1'.");
122+
}
123+
124+
i++;
125+
}
126+
127+
return builder.ToString();
66128
}
67129
}
68-
}
130+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
namespace ByteBard.AsyncAPI.Tests
2+
{
3+
using System;
4+
using System.Text.Json.Nodes;
5+
using ByteBard.AsyncAPI.Exceptions;
6+
using ByteBard.AsyncAPI.Readers.ParseNodes;
7+
using FluentAssertions;
8+
using NUnit.Framework;
9+
10+
internal class JsonPointer_Should
11+
{
12+
// The example document from RFC 6901 section 5.
13+
private const string RfcDocument =
14+
"""
15+
{
16+
"foo": ["bar", "baz"],
17+
"": 0,
18+
"a/b": 1,
19+
"c%d": 2,
20+
"e^f": 3,
21+
"g|h": 4,
22+
"i\\j": 5,
23+
"k\"l": 6,
24+
" ": 7,
25+
"m~n": 8
26+
}
27+
""";
28+
29+
[TestCase("/foo/0", "\"bar\"")]
30+
[TestCase("/", "0")]
31+
[TestCase("/a~1b", "1")]
32+
[TestCase("/c%d", "2")]
33+
[TestCase("/e^f", "3")]
34+
[TestCase("/g|h", "4")]
35+
[TestCase("/i\\j", "5")]
36+
[TestCase("/k\"l", "6")]
37+
[TestCase("/ ", "7")]
38+
[TestCase("/m~0n", "8")]
39+
public void Find_ResolvesTheRfcStringRepresentationExamples(string pointer, string expected)
40+
{
41+
var json = JsonNode.Parse(RfcDocument);
42+
43+
new JsonPointer(pointer).Find(json).ToJsonString().Should().Be(expected);
44+
}
45+
46+
[TestCase("/foo/0", "\"bar\"")]
47+
[TestCase("/", "0")]
48+
[TestCase("/a~1b", "1")]
49+
[TestCase("/c%25d", "2")]
50+
[TestCase("/e%5Ef", "3")]
51+
[TestCase("/g%7Ch", "4")]
52+
[TestCase("/i%5Cj", "5")]
53+
[TestCase("/k%22l", "6")]
54+
[TestCase("/%20", "7")]
55+
[TestCase("/m~0n", "8")]
56+
public void Find_ResolvesTheRfcUriFragmentExamples(string fragment, string expected)
57+
{
58+
var json = JsonNode.Parse(RfcDocument);
59+
60+
JsonPointer.FromUriFragment(fragment).Find(json).ToJsonString().Should().Be(expected);
61+
}
62+
63+
[Test]
64+
public void EmptyPointer_ReferencesTheWholeDocument()
65+
{
66+
var json = JsonNode.Parse(RfcDocument);
67+
68+
new JsonPointer(string.Empty).Find(json).Should().BeSameAs(json);
69+
JsonPointer.FromUriFragment(string.Empty).Find(json).Should().BeSameAs(json);
70+
}
71+
72+
[Test]
73+
public void StringRepresentation_IsNotPercentDecoded()
74+
{
75+
var json = JsonNode.Parse("""{ "a b": 1 }""");
76+
77+
new JsonPointer("/a%20b").Find(json).Should().BeNull();
78+
}
79+
80+
[Test]
81+
public void Unescape_DecodesTilde1BeforeTilde0()
82+
{
83+
new JsonPointer("/~01").Tokens.Should().Equal("~1");
84+
}
85+
86+
[TestCase("foo")]
87+
[TestCase("#/foo")]
88+
[TestCase("/foo~")]
89+
[TestCase("/foo~2")]
90+
[TestCase("/~a")]
91+
public void Constructor_Throws_WhenThePointerIsMalformed(string pointer)
92+
{
93+
var act = () => new JsonPointer(pointer);
94+
95+
act.Should().Throw<AsyncApiException>();
96+
}
97+
98+
[TestCase("foo")]
99+
[TestCase("/foo~2")]
100+
[TestCase("/foo%7E2")]
101+
public void FromUriFragment_Throws_WhenTheDecodedPointerIsMalformed(string fragment)
102+
{
103+
var act = () => JsonPointer.FromUriFragment(fragment);
104+
105+
act.Should().Throw<AsyncApiException>();
106+
}
107+
108+
[Test]
109+
public void Constructor_Throws_WhenThePointerIsNull()
110+
{
111+
var act = () => new JsonPointer(null);
112+
113+
act.Should().Throw<ArgumentNullException>();
114+
}
115+
116+
[TestCase("")]
117+
[TestCase("/")]
118+
[TestCase("/foo/0")]
119+
[TestCase("/a~1b/m~0n")]
120+
[TestCase("//")]
121+
public void ToString_RoundTripsTheStringRepresentation(string pointer)
122+
{
123+
new JsonPointer(pointer).ToString().Should().Be(pointer);
124+
}
125+
126+
[Test]
127+
public void ParentPointer_OfASingleTokenPointer_IsTheWholeDocument()
128+
{
129+
new JsonPointer("/foo").ParentPointer.ToString().Should().BeEmpty();
130+
new JsonPointer(string.Empty).ParentPointer.Should().BeNull();
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)