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() {