Skip to content

Commit b0c91c5

Browse files
committed
fix: inline every non-recursive schema reference and keep cyclic ones as $ref
The loop detector never popped, so a schema referenced a second time was written as $ref instead of inlined, and V3 looked up loops under the wrong type, leaving $refs pointing at an empty components section. Recursive schemas were also unrolled one level on every serialization, so inlined output grew with each round trip. References to schemas that are part of a cycle are now always written as $ref with the schema rendered in components; everything else is inlined. Inlined and non-inlined output is now stable across YAML and JSON round trips. Inlining a schema reference that can't be resolved now throws an AsyncApiWriterException naming the reference instead of a NullReferenceException.
1 parent e653de6 commit b0c91c5

4 files changed

Lines changed: 807 additions & 7 deletions

File tree

‎src/ByteBard.AsyncAPI/Models/AsyncApiComponents.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ public void SerializeV3(IAsyncApiWriter writer)
389389
{
390390
var loops = writer.GetSettings().LoopDetector.Loops;
391391
writer.WriteStartObject();
392-
if (loops.TryGetValue(typeof(AsyncApiMultiFormatSchemaReference), out List<object> schemas))
392+
if (loops.TryGetValue(typeof(AsyncApiJsonSchemaReference), out List<object> schemas))
393393
{
394394
writer.WriteOptionalMap(
395395
AsyncApiConstants.Schemas,

‎src/ByteBard.AsyncAPI/Models/References/AsyncApiJsonSchemaReference.cs‎

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,15 +347,27 @@ public override void SerializeV2(IAsyncApiWriter writer)
347347
}
348348

349349
this.Reference.Workspace = writer.Workspace;
350-
// If Loop is detected then just Serialize as a reference.
351-
if (!settings.LoopDetector.PushLoop(this))
350+
if (this.Target is null)
351+
{
352+
throw new AsyncApiWriterException($"Cannot inline '{this.Reference.Reference}': the reference could not be resolved.");
353+
}
354+
355+
// A schema that references itself can't be inlined, so it is written as a reference and rendered in components.
356+
if (IsInReferenceCycle(this.Target, writer.Workspace) || !settings.LoopDetector.PushLoop(this))
352357
{
353358
settings.LoopDetector.SaveLoop(this);
354359
this.Reference.SerializeV2(writer);
355360
return;
356361
}
357362

358-
this.Target.SerializeV2(writer);
363+
try
364+
{
365+
this.Target.SerializeV2(writer);
366+
}
367+
finally
368+
{
369+
settings.LoopDetector.PopLoop<AsyncApiJsonSchemaReference>();
370+
}
359371
}
360372

361373
public override void SerializeV3(IAsyncApiWriter writer)
@@ -373,15 +385,121 @@ public override void SerializeV3(IAsyncApiWriter writer)
373385
}
374386

375387
this.Reference.Workspace = writer.Workspace;
376-
// If Loop is detected then just Serialize as a reference.
377-
if (!settings.LoopDetector.PushLoop(this))
388+
if (this.Target is null)
389+
{
390+
throw new AsyncApiWriterException($"Cannot inline '{this.Reference.Reference}': the reference could not be resolved.");
391+
}
392+
393+
// A schema that references itself can't be inlined, so it is written as a reference and rendered in components.
394+
if (IsInReferenceCycle(this.Target, writer.Workspace) || !settings.LoopDetector.PushLoop(this))
378395
{
379396
settings.LoopDetector.SaveLoop(this);
380397
this.Reference.SerializeV3(writer);
381398
return;
382399
}
383400

384-
this.Target.SerializeV3(writer);
401+
try
402+
{
403+
this.Target.SerializeV3(writer);
404+
}
405+
finally
406+
{
407+
settings.LoopDetector.PopLoop<AsyncApiJsonSchemaReference>();
408+
}
409+
}
410+
411+
/// <summary>
412+
/// Determines whether the schema can reach itself through references.
413+
/// </summary>
414+
private static bool IsInReferenceCycle(AsyncApiJsonSchema schema, AsyncApiWorkspace workspace)
415+
{
416+
schema = Dereference(schema, workspace);
417+
if (schema is null)
418+
{
419+
return false;
420+
}
421+
422+
var visited = new HashSet<AsyncApiJsonSchema>();
423+
var pending = new Stack<AsyncApiJsonSchema>();
424+
PushChildren(schema, pending);
425+
426+
while (pending.Count > 0)
427+
{
428+
var current = Dereference(pending.Pop(), workspace);
429+
if (current is null)
430+
{
431+
continue;
432+
}
433+
434+
if (ReferenceEquals(current, schema))
435+
{
436+
return true;
437+
}
438+
439+
if (visited.Add(current))
440+
{
441+
PushChildren(current, pending);
442+
}
443+
}
444+
445+
return false;
446+
}
447+
448+
private static AsyncApiJsonSchema Dereference(AsyncApiJsonSchema schema, AsyncApiWorkspace workspace)
449+
{
450+
var hops = 0;
451+
while (schema is AsyncApiJsonSchemaReference reference)
452+
{
453+
// Guards against references that only point at each other.
454+
if (++hops > 64)
455+
{
456+
return null;
457+
}
458+
459+
reference.Reference.Workspace ??= workspace;
460+
schema = reference.Target;
461+
}
462+
463+
return schema;
464+
}
465+
466+
private static void PushChildren(AsyncApiJsonSchema schema, Stack<AsyncApiJsonSchema> pending)
467+
{
468+
void Push(AsyncApiJsonSchema child)
469+
{
470+
if (child is not null)
471+
{
472+
pending.Push(child);
473+
}
474+
}
475+
476+
void PushAll(IEnumerable<AsyncApiJsonSchema> children)
477+
{
478+
if (children is null)
479+
{
480+
return;
481+
}
482+
483+
foreach (var child in children)
484+
{
485+
Push(child);
486+
}
487+
}
488+
489+
PushAll(schema.AllOf);
490+
PushAll(schema.OneOf);
491+
PushAll(schema.AnyOf);
492+
Push(schema.Not);
493+
Push(schema.Contains);
494+
Push(schema.If);
495+
Push(schema.Then);
496+
Push(schema.Else);
497+
Push(schema.Items);
498+
Push(schema.AdditionalItems);
499+
PushAll(schema.Properties?.Values);
500+
Push(schema.AdditionalProperties);
501+
PushAll(schema.PatternProperties?.Values);
502+
Push(schema.PropertyNames);
385503
}
386504
}
387505
}

‎src/ByteBard.AsyncAPI/Models/References/AsyncApiMultiFormatSchemaReference.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public override void SerializeV2(IAsyncApiWriter writer)
4545
else
4646
{
4747
this.Reference.Workspace = writer.Workspace;
48+
if (this.Target is null)
49+
{
50+
throw new AsyncApiWriterException($"Cannot inline '{this.Reference.Reference}': the reference could not be resolved.");
51+
}
52+
4853
this.Target.SerializeV2(writer);
4954
}
5055
}
@@ -59,6 +64,11 @@ public override void SerializeV3(IAsyncApiWriter writer)
5964
else
6065
{
6166
this.Reference.Workspace = writer.Workspace;
67+
if (this.Target is null)
68+
{
69+
throw new AsyncApiWriterException($"Cannot inline '{this.Reference.Reference}': the reference could not be resolved.");
70+
}
71+
6272
this.Target.SerializeV3(writer);
6373
}
6474
}

0 commit comments

Comments
 (0)