diff --git a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs index a544830..45452b3 100644 --- a/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs +++ b/src/ByteBard.AsyncAPI.Readers/AsyncApiJsonDocumentReader.cs @@ -11,7 +11,6 @@ namespace ByteBard.AsyncAPI.Readers 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; @@ -381,17 +380,17 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef } var pointerResult = pointer.Find(json); - if (pointerResult != null) - { - json = pointerResult; - } - else + if (pointerResult == null) { diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, "Could not resolve reference fragment.")); return null; } + + json = pointerResult; } + json = this.context.VersionService.PrepareReferenceFragment(json, reference, this.context); + AsyncApiDiagnostic fragmentDiagnostic = new AsyncApiDiagnostic { SpecificationVersion = diagnostic.SpecificationVersion, @@ -453,6 +452,21 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef case ReferenceType.MessageBindings: result = this.ReadFragment>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); break; + case ReferenceType.ServerVariable: + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + break; + case ReferenceType.Tag: + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + break; + case ReferenceType.ExternalDocs: + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + break; + case ReferenceType.OperationReply when diagnostic.SpecificationVersion == AsyncApiVersion.AsyncApi3_0: + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + break; + case ReferenceType.OperationReplyAddress when diagnostic.SpecificationVersion == AsyncApiVersion.AsyncApi3_0: + result = this.ReadFragment(json, diagnostic.SpecificationVersion, out fragmentDiagnostic); + break; default: diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, "Could not resolve reference.")); break; diff --git a/src/ByteBard.AsyncAPI.Readers/Interface/IAsyncApiVersionService.cs b/src/ByteBard.AsyncAPI.Readers/Interface/IAsyncApiVersionService.cs index 66c29e7..fbee8b5 100644 --- a/src/ByteBard.AsyncAPI.Readers/Interface/IAsyncApiVersionService.cs +++ b/src/ByteBard.AsyncAPI.Readers/Interface/IAsyncApiVersionService.cs @@ -1,5 +1,6 @@ namespace ByteBard.AsyncAPI.Readers.Interface { + using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Readers.ParseNodes; @@ -12,5 +13,10 @@ T LoadElement(ParseNode node) where T : IAsyncApiElement; AsyncApiDocument LoadDocument(RootNode rootNode); + + /// + /// Adjusts the fragment a reference points at before it is loaded, for what the version keeps elsewhere. + /// + JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context); } -} \ No newline at end of file +} diff --git a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs b/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs deleted file mode 100644 index a287b95..0000000 --- a/src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace ByteBard.AsyncAPI.Readers.ParseNodes -{ - using System.Globalization; - using System.Text.Json.Nodes; - - public static class JsonPointerExtensions - { - public static JsonNode Find(this JsonPointer currentPointer, JsonNode baseJsonNode) - { - if (currentPointer.Tokens.Length == 0) - { - return baseJsonNode; - } - - var pointer = baseJsonNode; - foreach (var token in currentPointer.Tokens) - { - if (pointer is JsonArray sequence) - { - // RFC 6901 array indices are "0" or digits without a leading zero. - if ((token.Length > 1 && token[0] == '0') - || !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) - || index >= sequence.Count) - { - return null; - } - - pointer = sequence[index]; - } - else if (pointer is not JsonObject map || !map.TryGetPropertyValue(token, out pointer)) - { - // The token walks into a scalar or a missing property. - return null; - } - } - - return pointer; - } - } -} \ No newline at end of file diff --git a/src/ByteBard.AsyncAPI.Readers/ParsingContext.cs b/src/ByteBard.AsyncAPI.Readers/ParsingContext.cs index 9b8fc9a..ee01fff 100644 --- a/src/ByteBard.AsyncAPI.Readers/ParsingContext.cs +++ b/src/ByteBard.AsyncAPI.Readers/ParsingContext.cs @@ -90,7 +90,7 @@ internal AsyncApiDocument Parse(JsonNode jsonNode) this.Workspace.SetRootDocument(doc); this.Workspace.RegisterComponents(doc); - this.Workspace.RegisterComponent(string.Empty, this.ParseToStream(doc)); + this.Workspace.RegisterComponent(string.Empty, this.ParseToStream(jsonNode)); this.Diagnostic.SpecificationVersion = AsyncApiVersion.AsyncApi2_0; break; diff --git a/src/ByteBard.AsyncAPI.Readers/TempStorageKeys.cs b/src/ByteBard.AsyncAPI.Readers/TempStorageKeys.cs index c570ced..16ddf5f 100644 --- a/src/ByteBard.AsyncAPI.Readers/TempStorageKeys.cs +++ b/src/ByteBard.AsyncAPI.Readers/TempStorageKeys.cs @@ -8,5 +8,6 @@ public static class TempStorageKeys public const string ComponentMessages = "ComponentMessages"; public const string ChannelAddresses = "ChannelAddresses"; public const string ParameterSchemaReferences = "ParameterSchemaReferences"; + public const string PayloadSchemaFormat = "PayloadSchemaFormat"; } } \ No newline at end of file diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs index 5a1cf3e..6b95488 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs @@ -36,7 +36,7 @@ public static AsyncApiChannel LoadChannel(ParseNode node, string channelAddress var pointer = mapNode.GetReferencePointer(); if (pointer != null) { - return new AsyncApiChannelReference(pointer); + return new AsyncApiChannelReference(NormalizeChannelPointer(pointer)); } var channel = new AsyncApiChannel(); @@ -53,6 +53,23 @@ public static AsyncApiChannel LoadChannel(ParseNode node, string channelAddress return channel; } + /// + /// 2.x keys channels by address, which is normalized into the channel's key, so a reference to a root channel + /// is pointed at that key. + /// + private static string NormalizeChannelPointer(string pointer) + { + const string channelsPrefix = "#/channels/"; + if (!pointer.StartsWith(channelsPrefix) + || !JsonPointer.TryFromUriFragment(pointer.Substring(1), out var parsed) + || parsed.Tokens.Length != 2) + { + return pointer; + } + + return channelsPrefix + JsonPointer.EscapeToken(NormalizeChannelKey(parsed.Tokens[1])); + } + public static string NormalizeChannelKey(string channelKey, ParseNode node = null) { string newKey = string.Empty; diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs index 618f2f8..504d8f6 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs @@ -152,7 +152,14 @@ public static AsyncApiMessage LoadMessage(ParseNode node) var payload = LoadPayload(mapNode["payload"].Value, schema); if (payload is IAsyncApiReferenceable reference && !reference.Reference.IsExternal) { - message.Payload = new AsyncApiMultiFormatSchemaReference(reference.Reference.Reference); + var payloadReference = new AsyncApiMultiFormatSchemaReference(reference.Reference.Reference); + message.Payload = payloadReference; + + // 2.x keeps the schema format on the message, so keep it for when the reference is resolved. + if (schema != null) + { + node.Context.SetTempStorage(TempStorageKeys.PayloadSchemaFormat, schema, payloadReference); + } } else { diff --git a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs index 868ace6..63acf99 100644 --- a/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs +++ b/src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs @@ -1,7 +1,8 @@ -namespace ByteBard.AsyncAPI.Readers.V2 +namespace ByteBard.AsyncAPI.Readers.V2 { using System; using System.Collections.Generic; + using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Models.Interfaces; @@ -32,12 +33,11 @@ public AsyncApiV2VersionService(AsyncApiDiagnostic diagnostic) [typeof(AsyncApiOAuthFlows)] = AsyncApiV2Deserializer.LoadOAuthFlows, [typeof(AsyncApiOperation)] = AsyncApiV2Deserializer.LoadOperation, [typeof(AsyncApiParameter)] = AsyncApiV2Deserializer.LoadParameter, - [typeof(AsyncApiJsonSchema)] = AsyncApiJsonSchemaDeserializer.LoadSchema, - [typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema, [typeof(AsyncApiJsonSchema)] = AsyncApiV2Deserializer.LoadJsonSchemaPayload, [typeof(AsyncApiAvroSchema)] = AsyncApiV2Deserializer.LoadAvroPayload, - // 2.x has no Multi Format Schema Object. Only the local reference fallback asks for one, and it reads the document after the upgrade to 3.0. [typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema, + [typeof(AsyncApiCorrelationId)] = AsyncApiV2Deserializer.LoadCorrelationId, + [typeof(AsyncApiOperationTrait)] = AsyncApiV2Deserializer.LoadOperationTrait, [typeof(AsyncApiSecurityScheme)] = AsyncApiV2Deserializer.LoadSecurityScheme, [typeof(AsyncApiServer)] = AsyncApiV2Deserializer.LoadServer, [typeof(AsyncApiServerVariable)] = AsyncApiV2Deserializer.LoadServerVariable, @@ -81,6 +81,25 @@ public AsyncApiDocument LoadDocument(RootNode rootNode) return AsyncApiV2Deserializer.LoadAsyncApi(rootNode); } + /// + /// 2.x keeps a payload's schema format on its message, which the reader stored with the payload reference. + /// Wrap the payload with it as a Multi Format Schema Object, so it is read in that format. + /// + public JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context) + { + if (reference is not AsyncApiMultiFormatSchemaReference + || context.GetFromTempStorage(TempStorageKeys.PayloadSchemaFormat, reference) is not string schemaFormat) + { + return fragment; + } + + return new JsonObject + { + ["schemaFormat"] = schemaFormat, + ["schema"] = fragment.DeepClone(), + }; + } + public T LoadElement(ParseNode node) where T : IAsyncApiElement { diff --git a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiV3VersionService.cs b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiV3VersionService.cs index 88672a0..2d7a509 100644 --- a/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiV3VersionService.cs +++ b/src/ByteBard.AsyncAPI.Readers/V3/AsyncApiV3VersionService.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Exceptions; using ByteBard.AsyncAPI.Models; using ByteBard.AsyncAPI.Models.Interfaces; @@ -38,6 +39,8 @@ public AsyncApiV3VersionService(AsyncApiDiagnostic diagnostic) [typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema, [typeof(AsyncApiSecurityScheme)] = AsyncApiV3Deserializer.LoadSecurityScheme, [typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema, + [typeof(AsyncApiCorrelationId)] = AsyncApiV3Deserializer.LoadCorrelationId, + [typeof(AsyncApiOperationTrait)] = AsyncApiV3Deserializer.LoadOperationTrait, [typeof(AsyncApiServer)] = AsyncApiV3Deserializer.LoadServer, [typeof(AsyncApiServerVariable)] = AsyncApiV3Deserializer.LoadServerVariable, [typeof(AsyncApiTag)] = AsyncApiV3Deserializer.LoadTag, @@ -80,6 +83,11 @@ public AsyncApiDocument LoadDocument(RootNode rootNode) return AsyncApiV3Deserializer.LoadAsyncApi(rootNode); } + public JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context) + { + return fragment; + } + public T LoadElement(ParseNode node) where T : IAsyncApiElement { diff --git a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs index 62d723e..7756340 100644 --- a/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs +++ b/src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs @@ -182,8 +182,9 @@ public void RegisterComponents(AsyncApiDocument document) this.RegisterComponent(location, item.Value); } + // Register references last, so one pointing at another root channel or server finds it registered. string channelBaseUri = "#/channels/"; - foreach (var channel in document.Channels) + foreach (var channel in document.Channels.OrderBy(channel => channel.Value is IAsyncApiReferenceable)) { var registerableChannelValue = channel.Value; if (channel.Value is IAsyncApiReferenceable reference) @@ -196,6 +197,12 @@ public void RegisterComponents(AsyncApiDocument document) registerableChannelValue = this.ResolveReference(reference.Reference); } + // A reference to a channel that is not registered (yet) is resolved by the reader later. + if (registerableChannelValue == null) + { + continue; + } + location = channelBaseUri + JsonPointer.EscapeToken(channel.Key); this.RegisterComponent(location, registerableChannelValue); @@ -206,7 +213,7 @@ public void RegisterComponents(AsyncApiDocument document) } string serverBaseUri = "#/servers/"; - foreach (var server in document.Servers) + foreach (var server in document.Servers.OrderBy(server => server.Value is IAsyncApiReferenceable)) { var registerableServerValue = server.Value; if (server.Value is IAsyncApiReferenceable serverReference) @@ -287,6 +294,30 @@ private Uri ToLocationUrl(string location) public void SetRootDocument(AsyncApiDocument doc) { this.RootDocument = doc; + doc.SourceWorkspace = this; + } + + /// + /// Prepares this workspace for writing the document: registers its components, then adds what the workspace + /// the document was read into resolved outside them, so references to those can still be written inline. + /// + internal void RegisterForWriting(AsyncApiDocument doc) + { + this.RootDocument = doc; + this.RegisterComponents(doc); + + if (doc.SourceWorkspace == null || doc.SourceWorkspace == this) + { + return; + } + + foreach (var resolved in doc.SourceWorkspace.resolvedReferenceRegistry) + { + if (!this.resolvedReferenceRegistry.ContainsKey(resolved.Key)) + { + this.resolvedReferenceRegistry[resolved.Key] = resolved.Value; + } + } } } } diff --git a/src/ByteBard.AsyncAPI/JsonPointer.cs b/src/ByteBard.AsyncAPI/JsonPointer.cs index 597d0d5..0450c7e 100644 --- a/src/ByteBard.AsyncAPI/JsonPointer.cs +++ b/src/ByteBard.AsyncAPI/JsonPointer.cs @@ -1,8 +1,10 @@ namespace ByteBard.AsyncAPI { using System; + using System.Globalization; using System.Linq; using System.Text; + using System.Text.Json.Nodes; using ByteBard.AsyncAPI.Exceptions; /// @@ -117,6 +119,38 @@ public static string EscapeToken(string token) return token.Replace("~", "~0").Replace("/", "~1"); } + /// + /// Finds the node this pointer references. + /// + /// The document to evaluate the pointer against. + /// The referenced node, or null when it does not exist. + public JsonNode Find(JsonNode document) + { + var node = document; + foreach (var token in this.Tokens) + { + if (node is JsonArray sequence) + { + // RFC 6901 array indices are "0" or digits without a leading zero. + if ((token.Length > 1 && token[0] == '0') + || !int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) + || index >= sequence.Count) + { + return null; + } + + node = sequence[index]; + } + else if (node is not JsonObject map || !map.TryGetPropertyValue(token, out node)) + { + // The token walks into a scalar or a missing property. + return null; + } + } + + return node; + } + /// /// Gets the JSON string representation of this JSON pointer. /// diff --git a/src/ByteBard.AsyncAPI/Models/AsyncApiDocument.cs b/src/ByteBard.AsyncAPI/Models/AsyncApiDocument.cs index d489f69..3d8bed0 100644 --- a/src/ByteBard.AsyncAPI/Models/AsyncApiDocument.cs +++ b/src/ByteBard.AsyncAPI/Models/AsyncApiDocument.cs @@ -57,6 +57,11 @@ public class AsyncApiDocument : IAsyncApiExtensible, IAsyncApiSerializable /// public IDictionary Extensions { get; set; } = new Dictionary(); + /// + /// The workspace the document was read into. + /// + internal AsyncApiWorkspace SourceWorkspace { get; set; } + public void SerializeV2(IAsyncApiWriter writer) { if (writer is null) @@ -64,8 +69,45 @@ public void SerializeV2(IAsyncApiWriter writer) throw new ArgumentNullException(nameof(writer)); } - writer.Workspace.SetRootDocument(this); - writer.Workspace.RegisterComponents(this); + this.Write(writer, this.WriteV2); + } + + public void SerializeV3(IAsyncApiWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + this.Write(writer, this.WriteV3); + } + + /// + /// Writes the document, inlining local references whose target the written document does not contain. + /// + private void Write(IAsyncApiWriter writer, Action write) + { + if (writer is not AsyncApiWriterBase writerBase) + { + write(writer); + return; + } + + var settings = writerBase.Settings; + writerBase.Settings = DanglingReferences.Inline(settings, write); + try + { + write(writer); + } + finally + { + writerBase.Settings = settings; + } + } + + private void WriteV2(IAsyncApiWriter writer) + { + writer.Workspace.RegisterForWriting(this); writer.WriteStartObject(); @@ -124,25 +166,27 @@ public void SerializeV2(IAsyncApiWriter writer) writer.WriteEndObject(); } + /// + /// 2.x keys channels by address. A reference to another root channel would take that channel's address, + /// so it keeps its own key instead (a null address falls back to the key). + /// private string GetChannelAddress(AsyncApiChannel channel, AsyncApiWorkspace workspace) { if (channel is AsyncApiChannelReference reference) { reference.Reference.Workspace = workspace; + if (reference.IsRootChannelReference()) + { + return null; + } } return channel.Address; } - public void SerializeV3(IAsyncApiWriter writer) + private void WriteV3(IAsyncApiWriter writer) { - if (writer is null) - { - throw new ArgumentNullException(nameof(writer)); - } - - writer.Workspace.SetRootDocument(this); - writer.Workspace.RegisterComponents(this); + writer.Workspace.RegisterForWriting(this); writer.WriteStartObject(); diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs index c5f65b6..56080e8 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs @@ -3,6 +3,7 @@ namespace ByteBard.AsyncAPI.Models using System; using System.Collections.Generic; using System.Diagnostics; + using System.Linq; using ByteBard.AsyncAPI.Models.Interfaces; using ByteBard.AsyncAPI.Writers; @@ -55,6 +56,19 @@ public override void SerializeV2(IAsyncApiWriter writer) { if (!writer.GetSettings().ShouldInlineReference(this.Reference)) { + this.Reference.Workspace = writer.Workspace; + if (this.IsRootChannelReference() + && this.Target?.Address != null + && writer.Workspace.RootDocument?.Channels.Values.Any(channel => ReferenceEquals(channel, this.Target)) == true) + { + // 2.x keys root channels by address instead of by key. A target the document does not write + // keeps its own reference, so it can be found dangling and written inline. + writer.WriteStartObject(); + writer.WriteOptionalProperty(AsyncApiConstants.DollarRef, "#/channels/" + JsonPointer.EscapeToken(this.Target.Address)); + writer.WriteEndObject(); + return; + } + this.Reference.SerializeV2(writer); return; } @@ -65,6 +79,14 @@ public override void SerializeV2(IAsyncApiWriter writer) } } + internal bool IsRootChannelReference() + { + return !this.Reference.IsExternal + && JsonPointer.TryFromUriFragment(this.Reference.FragmentId, out var pointer) + && pointer.Tokens.Length == 2 + && pointer.Tokens[0] == AsyncApiConstants.Channels; + } + public override void SerializeV3(IAsyncApiWriter writer) { if (!writer.GetSettings().ShouldInlineReference(this.Reference)) diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiExternalDocumentationReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiExternalDocumentationReference.cs index 2048937..124372a 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiExternalDocumentationReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiExternalDocumentationReference.cs @@ -86,21 +86,20 @@ public bool Equals(AsyncApiExternalDocumentation other) /// Serializes the v2. /// /// - /// If serialization of the referenced ExternalDocs will be skipped. + /// V2 cannot reference external documentation, so the referenced ExternalDocs is always written inline when it can be resolved. /// /// The writer. public override void SerializeV2(IAsyncApiWriter writer) { - if (!writer.GetSettings().ShouldInlineReference(this.Reference)) + this.Reference.Workspace = writer.Workspace; + if (this.Target == null) { - // We cannot serialize the ExternalDocs as a reference under V2. + // Keep the reference rather than write a property without a value. + this.Reference.SerializeV2(writer); return; } - else - { - this.Reference.Workspace = writer.Workspace; - this.Target.SerializeV2(writer); - } + + this.Target.SerializeV2(writer); } public override void SerializeV3(IAsyncApiWriter writer) diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiMessageReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiMessageReference.cs index af893a5..1f960b2 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiMessageReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiMessageReference.cs @@ -118,14 +118,6 @@ public override void SerializeV2(IAsyncApiWriter writer) return; } - if (this.Reference.Reference.StartsWith("#/channels")) - { - // Try force inline anyway. - this.Reference.Workspace = writer.Workspace; - this.Target?.SerializeV2(writer); - return; - } - this.Reference.SerializeV2(writer); return; } diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiTagReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiTagReference.cs index 146896a..827602b 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiTagReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiTagReference.cs @@ -88,21 +88,20 @@ public bool Equals(AsyncApiTag other) /// Serializes the v2. /// /// - /// If serialization of the referenced tag will be skipped. + /// V2 cannot reference a tag, so the referenced tag is always written inline when it can be resolved. /// /// The writer. public override void SerializeV2(IAsyncApiWriter writer) { - if (!writer.GetSettings().ShouldInlineReference(this.Reference)) + this.Reference.Workspace = writer.Workspace; + if (this.Target == null) { - // We cannot serialize the Tag as a reference under V2. + // Keep the reference rather than drop the tag. + this.Reference.SerializeV2(writer); return; } - else - { - this.Reference.Workspace = writer.Workspace; - this.Target.SerializeV2(writer); - } + + this.Target.SerializeV2(writer); } public override void SerializeV3(IAsyncApiWriter writer) diff --git a/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs b/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs index 5fc5e7a..3ed49ef 100644 --- a/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs +++ b/src/ByteBard.AsyncAPI/Services/AsyncApiWalker.cs @@ -592,6 +592,12 @@ internal void Walk(AsyncApiOperation operation) return; } + if (operation is AsyncApiOperationReference reference) + { + this.Walk(reference as IAsyncApiReferenceable); + return; + } + this.visitor.Visit(operation); if (operation != null) @@ -1142,6 +1148,12 @@ internal void Walk(AsyncApiServerVariable serverVariable) return; } + if (serverVariable is AsyncApiServerVariableReference reference) + { + this.Walk(reference as IAsyncApiReferenceable); + return; + } + this.visitor.Visit(serverVariable); this.Walk(serverVariable as IAsyncApiExtensible); } diff --git a/src/ByteBard.AsyncAPI/Writers/AsyncApiWriterSettings.cs b/src/ByteBard.AsyncAPI/Writers/AsyncApiWriterSettings.cs index 7b78bc5..29226bd 100644 --- a/src/ByteBard.AsyncAPI/Writers/AsyncApiWriterSettings.cs +++ b/src/ByteBard.AsyncAPI/Writers/AsyncApiWriterSettings.cs @@ -1,5 +1,6 @@ namespace ByteBard.AsyncAPI.Writers { + using System.Collections.Generic; using ByteBard.AsyncAPI.Models; /// @@ -63,6 +64,24 @@ public ReferenceInlineSetting ReferenceInline /// internal LoopDetector LoopDetector { get; } + /// + /// Local references to write inline because their target does not exist in the written document, + /// for example a 2.x "#/channels/a/publish/message" written as 3.x. + /// + internal ISet DanglingReferences { get; private set; } = new HashSet(); + + /// + /// Creates a copy of these settings that also inlines the given references. + /// + internal AsyncApiWriterSettings WithDanglingReferences(ISet danglingReferences) + { + return new AsyncApiWriterSettings + { + ReferenceInline = this.ReferenceInline, + DanglingReferences = danglingReferences, + }; + } + /// /// Returns back if the refernece should be inlined or not. /// @@ -75,7 +94,7 @@ public bool ShouldInlineReference(AsyncApiReference reference) return false; } - return this.InlineLocalReferences; + return this.InlineLocalReferences || this.DanglingReferences.Contains(reference.Reference); } } } diff --git a/src/ByteBard.AsyncAPI/Writers/DanglingReferences.cs b/src/ByteBard.AsyncAPI/Writers/DanglingReferences.cs new file mode 100644 index 0000000..a24acc8 --- /dev/null +++ b/src/ByteBard.AsyncAPI/Writers/DanglingReferences.cs @@ -0,0 +1,97 @@ +namespace ByteBard.AsyncAPI.Writers +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.Json.Nodes; + + /// + /// A local reference can point at a location the written document does not have, for example a 2.x + /// "#/channels/a/publish/message" written as 3.x, or a 3.x "#/operations/a/traits/0" written as 2.x. + /// Such references are written inline instead. + /// + internal static class DanglingReferences + { + private const int MaxPasses = 10; + + /// + /// Writes the document as JSON to find references whose target it does not contain, and returns settings that write them inline. + /// + /// The settings the document is written with. + /// Writes the document to the given writer. + internal static AsyncApiWriterSettings Inline(AsyncApiWriterSettings settings, Action write) + { + if (settings.InlineLocalReferences) + { + return settings; + } + + var dangling = new HashSet(); + + // Inlining a reference writes its target, which can hold further references, so repeat until no new ones dangle. + for (var pass = 0; pass < MaxPasses; pass++) + { + var passSettings = dangling.Count == 0 ? settings : settings.WithDanglingReferences(new HashSet(dangling)); + var json = WriteJson(passSettings, write, out var workspace); + var document = JsonNode.Parse(json); + + // A reference nothing can resolve stays a reference, as it was written. + var found = new HashSet(); + Collect(document, document, found); + found.RemoveWhere(reference => !workspace.Contains(reference)); + if (found.IsSubsetOf(dangling)) + { + return passSettings; + } + + dangling.UnionWith(found); + } + + return settings.WithDanglingReferences(dangling); + } + + private static string WriteJson(AsyncApiWriterSettings settings, Action write, out AsyncApiWorkspace workspace) + { + using (var stringWriter = new StringWriter(settings.CultureInfo)) + { + var writer = new AsyncApiJsonWriter(stringWriter, settings, false); + write(writer); + writer.Flush(); + workspace = writer.Workspace; + return stringWriter.ToString(); + } + } + + private static void Collect(JsonNode node, JsonNode document, ISet dangling) + { + if (node is JsonArray array) + { + foreach (var item in array) + { + Collect(item, document, dangling); + } + + return; + } + + if (node is not JsonObject map) + { + return; + } + + if (map.TryGetPropertyValue("$ref", out var value) + && value is JsonValue referenceValue + && referenceValue.TryGetValue(out var reference) + && reference.StartsWith("#") + && !(JsonPointer.TryFromUriFragment(reference.Substring(1), out var pointer) && pointer.Find(document) != null)) + { + dangling.Add(reference); + } + + foreach (var property in map) + { + Collect(property.Value, document, dangling); + } + } + } +} diff --git a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs index 85b4398..954761b 100644 --- a/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/JsonPointer_Should.cs @@ -3,7 +3,6 @@ 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; diff --git a/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs new file mode 100644 index 0000000..88a1865 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs @@ -0,0 +1,739 @@ +namespace ByteBard.AsyncAPI.Tests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json.Nodes; + using ByteBard.AsyncAPI.Bindings; + using ByteBard.AsyncAPI.Models; + using ByteBard.AsyncAPI.Readers; + using FluentAssertions; + using NUnit.Framework; + + /// + /// Local references that do not point at a registered component (for example into another channel, operation + /// or message) are resolved from the document itself. They must resolve, survive writing as either version, + /// and read back the same. + /// + public class ReferenceFallback_Should + { + private const string Marker = "MARK"; + + private const string V2Header = + """ + asyncapi: 2.6.0 + info: + title: test + version: 1.0.0 + + """; + + private const string V3Header = + """ + asyncapi: 3.0.0 + info: + title: test + version: 1.0.0 + + """; + + private static readonly Dictionary Cases = new() + { + ["v2 nested schema"] = new(V2Header + """ + channels: + a: + publish: + message: + payload: + $ref: '#/components/schemas/B' + components: + schemas: + B: + type: object + properties: + y: + $ref: '#/components/schemas/C/properties/z' + C: + type: object + properties: + z: + type: integer + description: MARK + """, d => (d.Components.Schemas["B"].Schema as AsyncApiJsonSchema)?.Properties["y"].Description), + + ["v2 json payload"] = new(V2Header + """ + channels: + a: + publish: + message: + payload: + $ref: '#/components/messages/m/payload' + components: + messages: + m: + payload: + type: string + description: MARK + """, d => (MessageOf(d, "a")?.Payload?.Schema as AsyncApiJsonSchema)?.Description), + + ["v2 avro payload"] = new(V2Header + """ + channels: + a: + publish: + message: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + payload: + $ref: '#/components/messages/m/payload' + components: + messages: + m: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + payload: + type: record + name: MARK + fields: + - name: id + type: string + """, d => (MessageOf(d, "a")?.Payload?.Schema as AvroRecord)?.Name), + + ["v2 headers"] = new(V2Header + """ + channels: + a: + publish: + message: + headers: + $ref: '#/components/messages/m/headers' + components: + messages: + m: + headers: + type: object + description: MARK + """, d => (MessageOf(d, "a")?.Headers?.Schema as AsyncApiJsonSchema)?.Description), + + ["v2 message in an operation"] = new(V2Header + """ + channels: + a: + publish: + message: + name: MARK + payload: + type: string + b: + subscribe: + message: + $ref: '#/channels/a/publish/message' + """, d => MessageOf(d, "b")?.Name), + + ["v2 channel by address"] = new(V2Header + """ + channels: + user/signedup: + description: MARK + publish: + message: + payload: + type: string + other: + $ref: '#/channels/user~1signedup' + """, d => d.Channels.TryGetValue("other", out var other) ? other.Description : null), + + ["v2 channel parameter"] = new(V2Header + """ + channels: + a/{id}: + parameters: + id: + description: MARK + schema: + type: string + b/{id}: + parameters: + id: + $ref: '#/channels/a~1{id}/parameters/id' + """, d => d.Channels.Values.FirstOrDefault(c => c.Address == "b/{id}")?.Parameters["id"].Description), + + ["v2 server variable"] = new(V2Header + """ + servers: + prod: + url: '{host}' + protocol: kafka + variables: + host: + default: a.example.com + description: MARK + dev: + url: '{host}' + protocol: kafka + variables: + host: + $ref: '#/servers/prod/variables/host' + channels: + a: {} + """, d => d.Servers["dev"].Variables["host"].Description), + + ["v2 correlation id"] = new(V2Header + """ + channels: + a: + publish: + message: + correlationId: + $ref: '#/components/messages/m/correlationId' + components: + messages: + m: + correlationId: + location: '$message.header#/id' + description: MARK + """, d => MessageOf(d, "a")?.CorrelationId?.Description), + + ["v2 operation trait"] = new(V2Header + """ + channels: + a: + publish: + traits: + - description: MARK + b: + subscribe: + traits: + - $ref: '#/channels/a/publish/traits/0' + """, d => OperationOf(d, "b")?.Traits.FirstOrDefault()?.Description), + + ["v2 message trait"] = new(V2Header + """ + channels: + a: + publish: + message: + traits: + - $ref: '#/components/messages/m/traits/0' + components: + messages: + m: + traits: + - description: MARK + """, d => MessageOf(d, "a")?.Traits.FirstOrDefault()?.Description), + + ["v2 channel bindings"] = new(V2Header + """ + channels: + a: + bindings: + ws: + method: GET + b: + bindings: + $ref: '#/channels/a/bindings' + """, d => d.Channels["b"].Bindings?.Count == 1 ? Marker : null), + + ["v2 operation bindings"] = new(V2Header + """ + channels: + a: + publish: + bindings: + kafka: + clientId: + type: string + b: + subscribe: + bindings: + $ref: '#/channels/a/publish/bindings' + """, d => OperationOf(d, "b")?.Bindings?.Count == 1 ? Marker : null), + + ["v2 message bindings"] = new(V2Header + """ + channels: + a: + publish: + message: + bindings: + $ref: '#/components/messages/m/bindings' + components: + messages: + m: + bindings: + kafka: + key: + type: string + """, d => MessageOf(d, "a")?.Bindings?.Count == 1 ? Marker : null), + + ["v2 server bindings"] = new(V2Header + """ + servers: + prod: + url: a.example.com + protocol: kafka + bindings: + kafka: + schemaRegistryUrl: 'https://registry.example.com' + dev: + url: b.example.com + protocol: kafka + bindings: + $ref: '#/servers/prod/bindings' + channels: + a: {} + """, d => d.Servers["dev"].Bindings?.Count == 1 ? Marker : null), + + ["v3 nested schema"] = new(V3Header + """ + channels: + a: + address: a + components: + schemas: + B: + type: object + properties: + y: + $ref: '#/components/schemas/C/properties/z' + C: + type: object + properties: + z: + type: integer + description: MARK + """, d => (d.Components.Schemas["B"].Schema as AsyncApiJsonSchema)?.Properties["y"].Description), + + ["v3 json payload"] = new(V3Header + MessageOnChannelA(""" + payload: + $ref: '#/components/messages/m/payload' + """, """ + payload: + type: string + description: MARK + """), d => (MessageOf(d, "a")?.Payload?.Schema as AsyncApiJsonSchema)?.Description), + + ["v3 avro payload"] = new(V3Header + MessageOnChannelA(""" + payload: + $ref: '#/components/messages/m/payload' + """, """ + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: MARK + fields: + - name: id + type: string + """), d => (MessageOf(d, "a")?.Payload?.Schema as AvroRecord)?.Name), + + ["v3 headers"] = new(V3Header + MessageOnChannelA(""" + headers: + $ref: '#/components/messages/m/headers' + """, """ + headers: + type: object + description: MARK + """), d => (MessageOf(d, "a")?.Headers?.Schema as AsyncApiJsonSchema)?.Description), + + ["v3 correlation id"] = new(V3Header + MessageOnChannelA(""" + correlationId: + $ref: '#/components/messages/m/correlationId' + """, """ + correlationId: + location: '$message.header#/id' + description: MARK + """), d => MessageOf(d, "a")?.CorrelationId?.Description), + + ["v3 message trait"] = new(V3Header + MessageOnChannelA(""" + traits: + - $ref: '#/components/messages/m/traits/0' + """, """ + traits: + - description: MARK + """), d => MessageOf(d, "a")?.Traits.FirstOrDefault()?.Description), + + ["v3 message bindings"] = new(V3Header + MessageOnChannelA(""" + bindings: + $ref: '#/components/messages/m/bindings' + """, """ + bindings: + kafka: + key: + type: string + """), d => MessageOf(d, "a")?.Bindings?.Count == 1 ? Marker : null), + + ["v3 channel by key"] = new(V3Header + """ + channels: + signup: + address: user/signedup + description: MARK + other: + $ref: '#/channels/signup' + """, d => d.Channels.TryGetValue("other", out var other) ? other.Description : null), + + ["v3 channel bindings"] = new(V3Header + """ + channels: + a: + address: a + bindings: + ws: + method: GET + b: + address: b + bindings: + $ref: '#/channels/a/bindings' + """, d => d.Channels["b"].Bindings?.Count == 1 ? Marker : null), + + ["v3 operation bindings"] = new(V3Header + TwoOperations(""" + bindings: + kafka: + clientId: + type: string + """, """ + bindings: + $ref: '#/operations/op1/bindings' + """), d => OperationOf(d, "b")?.Bindings?.Count == 1 ? Marker : null), + + ["v3 server bindings"] = new(V3Header + """ + servers: + prod: + host: a.example.com + protocol: kafka + bindings: + kafka: + schemaRegistryUrl: 'https://registry.example.com' + dev: + host: b.example.com + protocol: kafka + bindings: + $ref: '#/servers/prod/bindings' + channels: + a: + address: a + """, d => d.Servers["dev"].Bindings?.Count == 1 ? Marker : null), + + ["v3 operation trait"] = new(V3Header + TwoOperations(""" + traits: + - description: MARK + """, """ + traits: + - $ref: '#/operations/op1/traits/0' + """), d => OperationOf(d, "b")?.Traits.FirstOrDefault()?.Description), + + ["v3 server variable"] = new(V3Header + """ + servers: + prod: + host: '{h}' + protocol: kafka + variables: + h: + default: a.example.com + description: MARK + dev: + host: '{h}' + protocol: kafka + variables: + h: + $ref: '#/servers/prod/variables/h' + channels: + a: + address: a + """, d => d.Servers["dev"].Variables["h"].Description), + + ["v3 tag"] = new(V3Header + TwoOperations(""" + tags: + - name: t + description: MARK + """, """ + tags: + - $ref: '#/operations/op1/tags/0' + """), d => OperationOf(d, "b")?.Tags.FirstOrDefault()?.Description), + + ["v3 external docs"] = new(V3Header + TwoOperations(""" + externalDocs: + url: https://example.com + description: MARK + """, """ + externalDocs: + $ref: '#/operations/op1/externalDocs' + """), d => OperationOf(d, "b")?.ExternalDocs?.Description), + + ["v3 reply"] = new(V3Header + TwoOperations(""" + reply: + address: + location: '$message.header#/replyTo' + description: MARK + """, """ + reply: + $ref: '#/operations/op1/reply' + """), d => OperationOf(d, "b")?.Reply?.Address?.Description, V3Only: true), + + ["v3 reply address"] = new(V3Header + TwoOperations(""" + reply: + address: + location: '$message.header#/replyTo' + description: MARK + """, """ + reply: + address: + $ref: '#/operations/op1/reply/address' + """), d => OperationOf(d, "b")?.Reply?.Address?.Description, V3Only: true), + }; + + /// + /// The references each case writes as 2.x and as 3.x: those that resolve in that version are kept, the others are inlined. + /// + private static readonly Dictionary WrittenReferences = new() + { + ["v2 nested schema"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/schemas/C/properties/z", "#/components/schemas/B" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/schemas/C/properties/z", "#/components/schemas/B" }), + ["v2 json payload"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/payload" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/payload" }), + ["v2 avro payload"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/payload" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/payload" }), + ["v2 headers"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/headers" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/headers" }), + ["v2 message in an operation"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/channels/a/publish/message" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/channels/b", "#/channels/b/messages/message" }), + ["v2 channel by address"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/channels/user~1signedup" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/usersignedup", "#/channels/usersignedup", "#/channels/usersignedup/messages/anonymous-message-1" }), + ["v2 channel parameter"] = ( + new[] { "#/channels/a~1{id}/parameters/id" }, + Array.Empty()), + ["v2 server variable"] = ( + new[] { "#/servers/prod/variables/host" }, + new[] { "#/servers/prod/variables/host" }), + ["v2 correlation id"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/correlationId" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/correlationId" }), + ["v2 operation trait"] = ( + new[] { "#/channels/a/publish/traits/0" }, + new[] { "#/channels/a", "#/channels/b" }), + ["v2 message trait"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/traits/0" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/traits/0" }), + ["v2 channel bindings"] = ( + new[] { "#/channels/a/bindings" }, + new[] { "#/channels/a/bindings" }), + ["v2 operation bindings"] = ( + new[] { "#/channels/a/publish/bindings" }, + new[] { "#/channels/a", "#/channels/b" }), + ["v2 message bindings"] = ( + new[] { "#/components/messages/anonymous-message-1", "#/components/messages/m/bindings" }, + new[] { "#/components/messages/anonymous-message-1", "#/channels/a", "#/channels/a/messages/anonymous-message-1", "#/components/messages/m/bindings" }), + ["v2 server bindings"] = ( + new[] { "#/servers/prod/bindings" }, + new[] { "#/servers/prod/bindings" }), + // The 3.x writer wraps component schemas in a Multi Format Schema Object, so the pointer into C no longer exists and is inlined. + ["v3 nested schema"] = ( + new[] { "#/components/schemas/C/properties/z" }, + Array.Empty()), + ["v3 json payload"] = ( + new[] { "#/components/messages/m/payload" }, + new[] { "#/components/messages/m/payload", "#/channels/a", "#/channels/a/messages/msg" }), + ["v3 avro payload"] = ( + new[] { "#/components/messages/m/payload" }, + new[] { "#/components/messages/m/payload", "#/channels/a", "#/channels/a/messages/msg" }), + // The 2.x writer writes headers as their schema, which inlines the referenced headers. + ["v3 headers"] = ( + Array.Empty(), + new[] { "#/components/messages/m/headers", "#/channels/a", "#/channels/a/messages/msg" }), + ["v3 correlation id"] = ( + new[] { "#/components/messages/m/correlationId" }, + new[] { "#/components/messages/m/correlationId", "#/channels/a", "#/channels/a/messages/msg" }), + ["v3 message trait"] = ( + new[] { "#/components/messages/m/traits/0" }, + new[] { "#/components/messages/m/traits/0", "#/channels/a", "#/channels/a/messages/msg" }), + ["v3 message bindings"] = ( + new[] { "#/components/messages/m/bindings" }, + new[] { "#/components/messages/m/bindings", "#/channels/a", "#/channels/a/messages/msg" }), + ["v3 channel by key"] = ( + new[] { "#/channels/user~1signedup" }, + new[] { "#/channels/signup" }), + ["v3 channel bindings"] = ( + new[] { "#/channels/a/bindings" }, + new[] { "#/channels/a/bindings" }), + ["v3 operation bindings"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/bindings" }), + ["v3 server bindings"] = ( + new[] { "#/servers/prod/bindings" }, + new[] { "#/servers/prod/bindings" }), + ["v3 operation trait"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/traits/0" }), + ["v3 server variable"] = ( + new[] { "#/servers/prod/variables/h" }, + new[] { "#/servers/prod/variables/h" }), + ["v3 tag"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/tags/0" }), + ["v3 external docs"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/externalDocs" }), + ["v3 reply"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/reply" }), + ["v3 reply address"] = ( + Array.Empty(), + new[] { "#/channels/a", "#/channels/b", "#/operations/op1/reply/address" }), + }; + + public static IEnumerable CaseNames => Cases.Keys; + + [TestCaseSource(nameof(CaseNames))] + public void Read_ResolvesTheReference(string name) + { + var doc = Read(Cases[name].Yaml); + + Cases[name].Inspect(doc).Should().Be(Marker); + } + + [TestCaseSource(nameof(CaseNames))] + public void WriteAsV2_ReadsBackTheSame(string name) + { + if (Cases[name].V3Only) + { + Assert.Ignore("AsyncAPI 2.x has no equivalent of this object."); + } + + this.AssertRoundTrip(name, AsyncApiVersion.AsyncApi2_0); + } + + [Test] + public void WriteAsV2_ChannelReference_ToARemovedChannel_IsInlined() + { + var document = Read(Cases["v2 channel by address"].Yaml); + + // The reference still resolves to the channel it was read with, but the document no longer has it. + document.Channels.Remove("usersignedup"); + var written = document.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0); + + References(document.SerializeAsJson(AsyncApiVersion.AsyncApi2_0)).Should().NotContain(r => r.StartsWith("#/channels/"), written); + Cases["v2 channel by address"].Inspect(Read(written)).Should().Be(Marker, written); + } + + [TestCaseSource(nameof(CaseNames))] + public void WriteAsV3_ReadsBackTheSame(string name) + { + this.AssertRoundTrip(name, AsyncApiVersion.AsyncApi3_0); + } + + private void AssertRoundTrip(string name, AsyncApiVersion version) + { + var document = Read(Cases[name].Yaml); + var written = document.SerializeAsYaml(version); + + var expected = version == AsyncApiVersion.AsyncApi2_0 ? WrittenReferences[name].V2 : WrittenReferences[name].V3; + References(document.SerializeAsJson(version)).Should().Equal(expected, "only references that resolve should be kept:\n{0}", written); + + var reread = Read(written); + + Cases[name].Inspect(reread).Should().Be(Marker, "the reference should survive:\n{0}", written); + } + + private static AsyncApiDocument Read(string yaml) + { + var settings = new AsyncApiReaderSettings { Bindings = BindingsCollection.All }; + var doc = new AsyncApiStringReader(settings).Read(yaml, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty("reading should not report errors:\n{0}", yaml); + return doc; + } + + /// + /// The "$ref" values in a written document, in the order they are written. + /// + internal static List References(string json) + { + var references = new List(); + Collect(JsonNode.Parse(json)); + return references; + + void Collect(JsonNode node) + { + switch (node) + { + case JsonArray array: + foreach (var item in array) + { + Collect(item); + } + + break; + case JsonObject map: + foreach (var property in map) + { + if (property.Key == "$ref") + { + references.Add(property.Value.GetValue()); + } + else + { + Collect(property.Value); + } + } + + break; + } + } + } + + private static AsyncApiMessage MessageOf(AsyncApiDocument doc, string channel) + { + return doc.Channels.TryGetValue(channel, out var c) ? c.Messages?.Values.FirstOrDefault() : null; + } + + private static AsyncApiOperation OperationOf(AsyncApiDocument doc, string channelAddress) + { + return doc.Operations.Values.FirstOrDefault(o => o.Channel?.Address == channelAddress); + } + + /// + /// A message on channel "a" and a message "m" in the components for it to reference. The message needs an + /// operation, since 2.x only has messages in operations. + /// + private static string MessageOnChannelA(string message, string componentMessage) + { + return + $""" + channels: + a: + address: a + messages: + msg: + {message} + operations: + op: + action: send + channel: + $ref: '#/channels/a' + messages: + - $ref: '#/channels/a/messages/msg' + components: + messages: + m: + {componentMessage} + """; + } + + /// + /// Two operations on separate channels, since 2.x has one operation per action and channel. + /// + private static string TwoOperations(string op1, string op2) + { + return + $""" + channels: + a: + address: a + b: + address: b + operations: + op1: + action: send + channel: + $ref: '#/channels/a' + {op1} + op2: + action: send + channel: + $ref: '#/channels/b' + {op2} + """; + } + + private sealed record FallbackCase(string Yaml, Func Inspect, bool V3Only = false); + } +}