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
1 change: 0 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
</PropertyGroup>

<ItemGroup Label="Library">
<PackageVersion Include="JsonPointer.Net" Version="5.3.1" />
<PackageVersion Include="System.Text.Json" Version="9.0.1" />
<PackageVersion Include="YamlDotNet" Version="13.0.1" />
</ItemGroup>
Expand Down
17 changes: 14 additions & 3 deletions src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="JsonPointer.Net" />
<PackageReference Include="Microsoft.SourceLink.GitHub">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
84 changes: 73 additions & 11 deletions src/ByteBard.AsyncAPI/JsonPointer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,39 @@ namespace ByteBard.AsyncAPI
{
using System;
using System.Linq;
using System.Text;
using ByteBard.AsyncAPI.Exceptions;

/// <summary>
/// JSON pointer.
/// JSON pointer as defined by RFC 6901.
/// </summary>
public class JsonPointer
{
/// <summary>
/// Initializes the <see cref="JsonPointer"/> class.
/// </summary>
/// <param name="pointer">Pointer as string.</param>
/// <param name="pointer">Pointer in its JSON string representation (RFC 6901 section 5), e.g. <c>/components/schemas/a~1b</c>.
/// An empty string points to the whole document; <c>/</c> points to the member with an empty key.</param>
/// <exception cref="AsyncApiException">The pointer is malformed.</exception>
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();
}

/// <summary>
Expand Down Expand Up @@ -50,19 +68,63 @@ public JsonPointer ParentPointer
}

/// <summary>
/// Decode the string.
/// Creates a pointer from its URI fragment identifier representation (RFC 6901 section 6), e.g. the part after <c>#</c>
/// in <c>schemas.json#/components/schemas/with%20space</c>. The fragment is percent-decoded before it is parsed.
/// </summary>
private string Decode(string token)
/// <param name="fragment">The fragment, without the leading <c>#</c>.</param>
/// <exception cref="AsyncApiException">The fragment is not a valid JSON pointer.</exception>
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));
}

/// <summary>
/// Gets the string representation of this JSON pointer.
/// Gets the JSON string representation of this JSON pointer.
/// </summary>
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();
}
}
}
}
133 changes: 133 additions & 0 deletions test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs
Original file line number Diff line number Diff line change
@@ -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<AsyncApiException>();
}

[TestCase("foo")]
[TestCase("/foo~2")]
[TestCase("/foo%7E2")]
public void FromUriFragment_Throws_WhenTheDecodedPointerIsMalformed(string fragment)
{
var act = () => JsonPointer.FromUriFragment(fragment);

act.Should().Throw<AsyncApiException>();
}

[Test]
public void Constructor_Throws_WhenThePointerIsNull()
{
var act = () => new JsonPointer(null);

act.Should().Throw<ArgumentNullException>();
}

[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();
}
}
}
Loading
Loading