Skip to content

Commit 91a989f

Browse files
baywetCopilot
andcommitted
fix(library): handle circular schema references
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 32905c7 commit 91a989f

3 files changed

Lines changed: 114 additions & 19 deletions

File tree

src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23

34
namespace Microsoft.OpenApi;
45
/// <summary>
@@ -23,13 +24,24 @@ public T? RecursiveTarget
2324
{
2425
get
2526
{
26-
return Target switch {
27-
BaseOpenApiReferenceHolder<T, U, V> recursiveTarget => recursiveTarget.RecursiveTarget,
28-
T concrete => concrete,
29-
_ => null
30-
};
27+
return ResolveRecursiveTarget(new HashSet<BaseOpenApiReferenceHolder<T, U, V>>());
3128
}
3229
}
30+
31+
private T? ResolveRecursiveTarget(ISet<BaseOpenApiReferenceHolder<T, U, V>> visitedReferences)
32+
{
33+
if (!visitedReferences.Add(this))
34+
{
35+
throw new InvalidOperationException($"Circular reference detected while resolving reference: {Reference.ReferenceV3}");
36+
}
37+
38+
return Target switch
39+
{
40+
BaseOpenApiReferenceHolder<T, U, V> recursiveTarget => recursiveTarget.ResolveRecursiveTarget(visitedReferences),
41+
T concrete => concrete,
42+
_ => null
43+
};
44+
}
3345
/// <summary>
3446
/// Copy the reference as a target element with overrides.
3547
/// </summary>

src/Microsoft.OpenApi/Services/OpenApiWalker.cs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -904,11 +904,17 @@ internal void Walk(OpenApiEncoding encoding)
904904
/// </summary>
905905
internal void Walk(IOpenApiSchema? schema, bool isComponent = false)
906906
{
907-
if (schema == null || schema is IOpenApiReferenceHolder holder && ProcessAsReference(holder, isComponent))
907+
if (schema == null)
908908
{
909909
return;
910910
}
911911

912+
if (schema is IOpenApiReferenceHolder holder)
913+
{
914+
Walk(holder);
915+
return;
916+
}
917+
912918
if (_schemaLoop.Contains(schema))
913919
{
914920
return; // Loop detected, this schema has already been walked.
@@ -1353,19 +1359,6 @@ private void WalkDictionary<T>(
13531359
}
13541360
}
13551361

1356-
/// <summary>
1357-
/// Identify if an element is just a reference to a component, or an actual component
1358-
/// </summary>
1359-
private bool ProcessAsReference(IOpenApiReferenceHolder referenceableHolder, bool isComponent = false)
1360-
{
1361-
var isReference = !isComponent || referenceableHolder.UnresolvedReference;
1362-
if (isReference)
1363-
{
1364-
Walk(referenceableHolder);
1365-
}
1366-
return isReference;
1367-
}
1368-
13691362
private void WalkTags<T>(ISet<T> tags, Action<OpenApiWalker, T> walk)
13701363
where T : IOpenApiTag
13711364
{

test/Microsoft.OpenApi.Tests/Reader/OpenApiModelFactoryTests.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,96 @@ namespace Microsoft.OpenApi.Tests.Reader;
99

1010
public class OpenApiModelFactoryTests
1111
{
12+
[Fact]
13+
public async Task LoadDocumentWithCircularSchemaPropertyReferencesShouldSucceed()
14+
{
15+
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json");
16+
await File.WriteAllTextAsync(filePath, """
17+
{
18+
"openapi": "3.0.0",
19+
"info": {
20+
"title": "Test",
21+
"version": "0.0.1"
22+
},
23+
"paths": {},
24+
"components": {
25+
"schemas": {
26+
"A": {
27+
"properties": {
28+
"b": {
29+
"$ref": "#/components/schemas/B"
30+
}
31+
}
32+
},
33+
"B": {
34+
"properties": {
35+
"a": {
36+
"$ref": "#/components/schemas/A"
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
""");
44+
45+
try
46+
{
47+
var result = await OpenApiDocument.LoadAsync(filePath);
48+
49+
Assert.NotNull(result.Document);
50+
Assert.Empty(result.Diagnostic.Errors);
51+
52+
var schemaA = Assert.IsType<OpenApiSchema>(result.Document.Components.Schemas["A"]);
53+
var schemaBReference = Assert.IsType<OpenApiSchemaReference>(schemaA.Properties["b"]);
54+
Assert.Same(result.Document.Components.Schemas["B"], schemaBReference.Target);
55+
}
56+
finally
57+
{
58+
File.Delete(filePath);
59+
}
60+
}
61+
62+
[Fact]
63+
public async Task LoadDocumentWithCircularRootSchemaReferencesShouldReturnDiagnosticWarning()
64+
{
65+
var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.json");
66+
await File.WriteAllTextAsync(filePath, """
67+
{
68+
"openapi": "3.0.0",
69+
"info": {
70+
"title": "Test",
71+
"version": "0.0.1"
72+
},
73+
"paths": {},
74+
"components": {
75+
"schemas": {
76+
"A": {
77+
"$ref": "#/components/schemas/B"
78+
},
79+
"B": {
80+
"$ref": "#/components/schemas/A"
81+
}
82+
}
83+
}
84+
}
85+
""");
86+
87+
try
88+
{
89+
var result = await OpenApiDocument.LoadAsync(filePath);
90+
91+
Assert.NotNull(result.Document);
92+
Assert.Empty(result.Diagnostic.Errors);
93+
Assert.Contains(result.Diagnostic.Warnings,
94+
warning => warning.Message.StartsWith("Circular reference detected while resolving reference:", StringComparison.Ordinal));
95+
}
96+
finally
97+
{
98+
File.Delete(filePath);
99+
}
100+
}
101+
12102
[Fact]
13103
public async Task UsesSettingsBaseUrl()
14104
{

0 commit comments

Comments
 (0)