Skip to content

Commit 5a689cf

Browse files
authored
fix!: resolve local references outside the components, in both versions (#63)
* 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. * 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.
1 parent 4fcc2ad commit 5a689cf

21 files changed

Lines changed: 1111 additions & 92 deletions

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ namespace ByteBard.AsyncAPI.Readers
1111
using ByteBard.AsyncAPI.Models;
1212
using ByteBard.AsyncAPI.Models.Interfaces;
1313
using ByteBard.AsyncAPI.Readers.Interface;
14-
using ByteBard.AsyncAPI.Readers.ParseNodes;
1514
using ByteBard.AsyncAPI.Readers.Services;
1615
using ByteBard.AsyncAPI.Services;
1716
using ByteBard.AsyncAPI.Validations;
@@ -381,17 +380,17 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
381380
}
382381

383382
var pointerResult = pointer.Find(json);
384-
if (pointerResult != null)
385-
{
386-
json = pointerResult;
387-
}
388-
else
383+
if (pointerResult == null)
389384
{
390385
diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, "Could not resolve reference fragment."));
391386
return null;
392387
}
388+
389+
json = pointerResult;
393390
}
394391

392+
json = this.context.VersionService.PrepareReferenceFragment(json, reference, this.context);
393+
395394
AsyncApiDiagnostic fragmentDiagnostic = new AsyncApiDiagnostic
396395
{
397396
SpecificationVersion = diagnostic.SpecificationVersion,
@@ -453,6 +452,21 @@ private IAsyncApiSerializable ResolveStreamReference(Stream stream, IAsyncApiRef
453452
case ReferenceType.MessageBindings:
454453
result = this.ReadFragment<AsyncApiBindings<IMessageBinding>>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
455454
break;
455+
case ReferenceType.ServerVariable:
456+
result = this.ReadFragment<AsyncApiServerVariable>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
457+
break;
458+
case ReferenceType.Tag:
459+
result = this.ReadFragment<AsyncApiTag>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
460+
break;
461+
case ReferenceType.ExternalDocs:
462+
result = this.ReadFragment<AsyncApiExternalDocumentation>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
463+
break;
464+
case ReferenceType.OperationReply when diagnostic.SpecificationVersion == AsyncApiVersion.AsyncApi3_0:
465+
result = this.ReadFragment<AsyncApiOperationReply>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
466+
break;
467+
case ReferenceType.OperationReplyAddress when diagnostic.SpecificationVersion == AsyncApiVersion.AsyncApi3_0:
468+
result = this.ReadFragment<AsyncApiOperationReplyAddress>(json, diagnostic.SpecificationVersion, out fragmentDiagnostic);
469+
break;
456470
default:
457471
diagnostic.Errors.Add(new AsyncApiError(reference.Reference.Reference, "Could not resolve reference."));
458472
break;

‎src/ByteBard.AsyncAPI.Readers/Interface/IAsyncApiVersionService.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
namespace ByteBard.AsyncAPI.Readers.Interface
22
{
3+
using System.Text.Json.Nodes;
34
using ByteBard.AsyncAPI.Models;
45
using ByteBard.AsyncAPI.Models.Interfaces;
56
using ByteBard.AsyncAPI.Readers.ParseNodes;
@@ -12,5 +13,10 @@ T LoadElement<T>(ParseNode node)
1213
where T : IAsyncApiElement;
1314

1415
AsyncApiDocument LoadDocument(RootNode rootNode);
16+
17+
/// <summary>
18+
/// Adjusts the fragment a reference points at before it is loaded, for what the version keeps elsewhere.
19+
/// </summary>
20+
JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context);
1521
}
16-
}
22+
}

‎src/ByteBard.AsyncAPI.Readers/ParseNodes/JsonPointerExtensions.cs‎

Lines changed: 0 additions & 40 deletions
This file was deleted.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ internal AsyncApiDocument Parse(JsonNode jsonNode)
9090

9191
this.Workspace.SetRootDocument(doc);
9292
this.Workspace.RegisterComponents(doc);
93-
this.Workspace.RegisterComponent(string.Empty, this.ParseToStream(doc));
93+
this.Workspace.RegisterComponent(string.Empty, this.ParseToStream(jsonNode));
9494

9595
this.Diagnostic.SpecificationVersion = AsyncApiVersion.AsyncApi2_0;
9696
break;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ public static class TempStorageKeys
88
public const string ComponentMessages = "ComponentMessages";
99
public const string ChannelAddresses = "ChannelAddresses";
1010
public const string ParameterSchemaReferences = "ParameterSchemaReferences";
11+
public const string PayloadSchemaFormat = "PayloadSchemaFormat";
1112
}
1213
}

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiChannelDeserializer.cs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public static AsyncApiChannel LoadChannel(ParseNode node, string channelAddress
3636
var pointer = mapNode.GetReferencePointer();
3737
if (pointer != null)
3838
{
39-
return new AsyncApiChannelReference(pointer);
39+
return new AsyncApiChannelReference(NormalizeChannelPointer(pointer));
4040
}
4141

4242
var channel = new AsyncApiChannel();
@@ -53,6 +53,23 @@ public static AsyncApiChannel LoadChannel(ParseNode node, string channelAddress
5353
return channel;
5454
}
5555

56+
/// <summary>
57+
/// 2.x keys channels by address, which is normalized into the channel's key, so a reference to a root channel
58+
/// is pointed at that key.
59+
/// </summary>
60+
private static string NormalizeChannelPointer(string pointer)
61+
{
62+
const string channelsPrefix = "#/channels/";
63+
if (!pointer.StartsWith(channelsPrefix)
64+
|| !JsonPointer.TryFromUriFragment(pointer.Substring(1), out var parsed)
65+
|| parsed.Tokens.Length != 2)
66+
{
67+
return pointer;
68+
}
69+
70+
return channelsPrefix + JsonPointer.EscapeToken(NormalizeChannelKey(parsed.Tokens[1]));
71+
}
72+
5673
public static string NormalizeChannelKey(string channelKey, ParseNode node = null)
5774
{
5875
string newKey = string.Empty;

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiMessageDeserializer.cs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,14 @@ public static AsyncApiMessage LoadMessage(ParseNode node)
152152
var payload = LoadPayload(mapNode["payload"].Value, schema);
153153
if (payload is IAsyncApiReferenceable reference && !reference.Reference.IsExternal)
154154
{
155-
message.Payload = new AsyncApiMultiFormatSchemaReference(reference.Reference.Reference);
155+
var payloadReference = new AsyncApiMultiFormatSchemaReference(reference.Reference.Reference);
156+
message.Payload = payloadReference;
157+
158+
// 2.x keeps the schema format on the message, so keep it for when the reference is resolved.
159+
if (schema != null)
160+
{
161+
node.Context.SetTempStorage(TempStorageKeys.PayloadSchemaFormat, schema, payloadReference);
162+
}
156163
}
157164
else
158165
{

‎src/ByteBard.AsyncAPI.Readers/V2/AsyncApiV2VersionService.cs‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
namespace ByteBard.AsyncAPI.Readers.V2
1+
namespace ByteBard.AsyncAPI.Readers.V2
22
{
33
using System;
44
using System.Collections.Generic;
5+
using System.Text.Json.Nodes;
56
using ByteBard.AsyncAPI.Exceptions;
67
using ByteBard.AsyncAPI.Models;
78
using ByteBard.AsyncAPI.Models.Interfaces;
@@ -32,12 +33,11 @@ public AsyncApiV2VersionService(AsyncApiDiagnostic diagnostic)
3233
[typeof(AsyncApiOAuthFlows)] = AsyncApiV2Deserializer.LoadOAuthFlows,
3334
[typeof(AsyncApiOperation)] = AsyncApiV2Deserializer.LoadOperation,
3435
[typeof(AsyncApiParameter)] = AsyncApiV2Deserializer.LoadParameter,
35-
[typeof(AsyncApiJsonSchema)] = AsyncApiJsonSchemaDeserializer.LoadSchema,
36-
[typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema,
3736
[typeof(AsyncApiJsonSchema)] = AsyncApiV2Deserializer.LoadJsonSchemaPayload,
3837
[typeof(AsyncApiAvroSchema)] = AsyncApiV2Deserializer.LoadAvroPayload,
39-
// 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.
4038
[typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema,
39+
[typeof(AsyncApiCorrelationId)] = AsyncApiV2Deserializer.LoadCorrelationId,
40+
[typeof(AsyncApiOperationTrait)] = AsyncApiV2Deserializer.LoadOperationTrait,
4141
[typeof(AsyncApiSecurityScheme)] = AsyncApiV2Deserializer.LoadSecurityScheme,
4242
[typeof(AsyncApiServer)] = AsyncApiV2Deserializer.LoadServer,
4343
[typeof(AsyncApiServerVariable)] = AsyncApiV2Deserializer.LoadServerVariable,
@@ -81,6 +81,25 @@ public AsyncApiDocument LoadDocument(RootNode rootNode)
8181
return AsyncApiV2Deserializer.LoadAsyncApi(rootNode);
8282
}
8383

84+
/// <summary>
85+
/// 2.x keeps a payload's schema format on its message, which the reader stored with the payload reference.
86+
/// Wrap the payload with it as a Multi Format Schema Object, so it is read in that format.
87+
/// </summary>
88+
public JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context)
89+
{
90+
if (reference is not AsyncApiMultiFormatSchemaReference
91+
|| context.GetFromTempStorage<string>(TempStorageKeys.PayloadSchemaFormat, reference) is not string schemaFormat)
92+
{
93+
return fragment;
94+
}
95+
96+
return new JsonObject
97+
{
98+
["schemaFormat"] = schemaFormat,
99+
["schema"] = fragment.DeepClone(),
100+
};
101+
}
102+
84103
public T LoadElement<T>(ParseNode node)
85104
where T : IAsyncApiElement
86105
{

‎src/ByteBard.AsyncAPI.Readers/V3/AsyncApiV3VersionService.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{
33
using System;
44
using System.Collections.Generic;
5+
using System.Text.Json.Nodes;
56
using ByteBard.AsyncAPI.Exceptions;
67
using ByteBard.AsyncAPI.Models;
78
using ByteBard.AsyncAPI.Models.Interfaces;
@@ -38,6 +39,8 @@ public AsyncApiV3VersionService(AsyncApiDiagnostic diagnostic)
3839
[typeof(AsyncApiAvroSchema)] = AsyncApiAvroSchemaDeserializer.LoadSchema,
3940
[typeof(AsyncApiSecurityScheme)] = AsyncApiV3Deserializer.LoadSecurityScheme,
4041
[typeof(AsyncApiMultiFormatSchema)] = AsyncApiV3Deserializer.LoadMultiFormatSchema,
42+
[typeof(AsyncApiCorrelationId)] = AsyncApiV3Deserializer.LoadCorrelationId,
43+
[typeof(AsyncApiOperationTrait)] = AsyncApiV3Deserializer.LoadOperationTrait,
4144
[typeof(AsyncApiServer)] = AsyncApiV3Deserializer.LoadServer,
4245
[typeof(AsyncApiServerVariable)] = AsyncApiV3Deserializer.LoadServerVariable,
4346
[typeof(AsyncApiTag)] = AsyncApiV3Deserializer.LoadTag,
@@ -80,6 +83,11 @@ public AsyncApiDocument LoadDocument(RootNode rootNode)
8083
return AsyncApiV3Deserializer.LoadAsyncApi(rootNode);
8184
}
8285

86+
public JsonNode PrepareReferenceFragment(JsonNode fragment, IAsyncApiReferenceable reference, ParsingContext context)
87+
{
88+
return fragment;
89+
}
90+
8391
public T LoadElement<T>(ParseNode node)
8492
where T : IAsyncApiElement
8593
{

‎src/ByteBard.AsyncAPI/AsyncApiWorkspace.cs‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ public void RegisterComponents(AsyncApiDocument document)
182182
this.RegisterComponent(location, item.Value);
183183
}
184184

185+
// Register references last, so one pointing at another root channel or server finds it registered.
185186
string channelBaseUri = "#/channels/";
186-
foreach (var channel in document.Channels)
187+
foreach (var channel in document.Channels.OrderBy(channel => channel.Value is IAsyncApiReferenceable))
187188
{
188189
var registerableChannelValue = channel.Value;
189190
if (channel.Value is IAsyncApiReferenceable reference)
@@ -196,6 +197,12 @@ public void RegisterComponents(AsyncApiDocument document)
196197
registerableChannelValue = this.ResolveReference<AsyncApiChannel>(reference.Reference);
197198
}
198199

200+
// A reference to a channel that is not registered (yet) is resolved by the reader later.
201+
if (registerableChannelValue == null)
202+
{
203+
continue;
204+
}
205+
199206
location = channelBaseUri + JsonPointer.EscapeToken(channel.Key);
200207
this.RegisterComponent(location, registerableChannelValue);
201208

@@ -206,7 +213,7 @@ public void RegisterComponents(AsyncApiDocument document)
206213
}
207214

208215
string serverBaseUri = "#/servers/";
209-
foreach (var server in document.Servers)
216+
foreach (var server in document.Servers.OrderBy(server => server.Value is IAsyncApiReferenceable))
210217
{
211218
var registerableServerValue = server.Value;
212219
if (server.Value is IAsyncApiReferenceable serverReference)
@@ -287,6 +294,30 @@ private Uri ToLocationUrl(string location)
287294
public void SetRootDocument(AsyncApiDocument doc)
288295
{
289296
this.RootDocument = doc;
297+
doc.SourceWorkspace = this;
298+
}
299+
300+
/// <summary>
301+
/// Prepares this workspace for writing the document: registers its components, then adds what the workspace
302+
/// the document was read into resolved outside them, so references to those can still be written inline.
303+
/// </summary>
304+
internal void RegisterForWriting(AsyncApiDocument doc)
305+
{
306+
this.RootDocument = doc;
307+
this.RegisterComponents(doc);
308+
309+
if (doc.SourceWorkspace == null || doc.SourceWorkspace == this)
310+
{
311+
return;
312+
}
313+
314+
foreach (var resolved in doc.SourceWorkspace.resolvedReferenceRegistry)
315+
{
316+
if (!this.resolvedReferenceRegistry.ContainsKey(resolved.Key))
317+
{
318+
this.resolvedReferenceRegistry[resolved.Key] = resolved.Value;
319+
}
320+
}
290321
}
291322
}
292323
}

0 commit comments

Comments
 (0)