From fe66369aac993641c07c85fd8dfbfb54bc5f237e Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Sun, 25 Oct 2026 00:15:57 +0200 Subject: [PATCH 1/2] fix!: resolve local references outside the components, in both versions Local references that do not point at a registered component, such as into another channel, operation or message, are resolved from the document. That fallback crashed or failed for most of them: - 2.x documents were resolved against their 3.x re-serialization, so 2.x pointers into publish/subscribe and channel parameters never resolved, and 2.x payloads lost their schemaFormat. They now resolve against the original document, and the payload's schemaFormat is kept in scoped temp storage for the 2.x version service to apply. - Correlation ID and operation trait fragments had no loader, and server variable, tag, external docs, reply and reply address references had no fallback, so reading threw or reported an error. - The walker skipped server variable and operation references. - A 2.x channel referencing another root channel crashed while registering components. It now points at the same channel, and is written with that channel's address in 2.x. Writing kept references whose target the written version does not have, such as a 2.x "#/channels/a/publish/message" written as 3.x, or a 3.x "#/operations/op/traits/0" written as 2.x. Those are now written inline; a reference that resolves in the written document stays a reference. The writer also sees what the reader resolved outside the components, and 2.x writes referenced tags and external docs inline instead of dropping them. BREAKING CHANGE: JsonPointerExtensions is removed; use JsonPointer.Find. --- .../AsyncApiJsonDocumentReader.cs | 26 +- .../Interface/IAsyncApiVersionService.cs | 8 +- .../ParseNodes/JsonPointerExtensions.cs | 40 - .../ParsingContext.cs | 2 +- .../TempStorageKeys.cs | 1 + .../V2/AsyncApiChannelDeserializer.cs | 19 +- .../V2/AsyncApiMessageDeserializer.cs | 9 +- .../V2/AsyncApiV2VersionService.cs | 27 +- .../V3/AsyncApiV3VersionService.cs | 8 + src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs | 35 +- src/ByteBard.AsyncAPI/JsonPointer.cs | 34 + .../Models/AsyncApiDocument.cs | 64 +- .../References/AsyncApiChannelReference.cs | 18 + .../AsyncApiExternalDocumentationReference.cs | 15 +- .../References/AsyncApiMessageReference.cs | 8 - .../Models/References/AsyncApiTagReference.cs | 15 +- .../Services/AsyncApiWalker.cs | 12 + .../Writers/AsyncApiWriterSettings.cs | 21 +- .../Writers/DanglingReferences.cs | 97 +++ .../JsonPointer_Should.cs | 1 - .../Models/ReferenceFallback_Should.cs | 726 ++++++++++++++++++ 21 files changed, 1094 insertions(+), 92 deletions(-) delete mode 100644 src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs create mode 100644 src/ByteBard.AsyncAPI/Writers/DanglingReferences.cs create mode 100644 test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs 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..4bdf9b7 100644 --- a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs +++ b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs @@ -55,6 +55,16 @@ public override void SerializeV2(IAsyncApiWriter writer) { if (!writer.GetSettings().ShouldInlineReference(this.Reference)) { + this.Reference.Workspace = writer.Workspace; + if (this.IsRootChannelReference() && this.Target?.Address != null) + { + // 2.x keys root channels by address instead of by key. + writer.WriteStartObject(); + writer.WriteOptionalProperty(AsyncApiConstants.DollarRef, "#/channels/" + JsonPointer.EscapeToken(this.Target.Address)); + writer.WriteEndObject(); + return; + } + this.Reference.SerializeV2(writer); return; } @@ -65,6 +75,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..a060882 --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs @@ -0,0 +1,726 @@ +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); + } + + [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); + } +} From 9e499f46d3da64e4bbf132b33cb73780b4ef22c4 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Sun, 25 Oct 2026 00:28:51 +0200 Subject: [PATCH 2/2] fix: inline a 2.x channel reference whose target is not written A 2.x channel reference is written with its target's address, since 2.x keys channels by address. When the target is not one of the document's channels, for example because it was removed after reading, that address points nowhere and did not match the reference, so it was left dangling. Such a reference now keeps its own pointer, which is found dangling and written inline. --- .../Models/References/AsyncApiChannelReference.cs | 8 ++++++-- .../Models/ReferenceFallback_Should.cs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs b/src/ByteBard.AsyncAPI/Models/References/AsyncApiChannelReference.cs index 4bdf9b7..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; @@ -56,9 +57,12 @@ public override void SerializeV2(IAsyncApiWriter writer) if (!writer.GetSettings().ShouldInlineReference(this.Reference)) { this.Reference.Workspace = writer.Workspace; - if (this.IsRootChannelReference() && this.Target?.Address != null) + 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. + // 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(); diff --git a/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs b/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs index a060882..88a1865 100644 --- a/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs +++ b/test/ByteBard.AsyncAPI.Tests/Models/ReferenceFallback_Should.cs @@ -591,6 +591,19 @@ public void WriteAsV2_ReadsBackTheSame(string name) 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) {