Skip to content

Commit 90b3966

Browse files
Merge pull request #2459 from martincostello/gh-2453-validate-schema-references
feat: adds a default validation rule for unresolved references
1 parent 10b46b9 commit 90b3966

File tree

6 files changed

+283
-3
lines changed

6 files changed

+283
-3
lines changed

src/Microsoft.OpenApi/Properties/SRResource.Designer.cs

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Microsoft.OpenApi/Properties/SRResource.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@
231231
<data name="ParseServerUrlValueNotValid" xml:space="preserve">
232232
<value>Value '{0}' is not a valid value for variable '{1}'. If an enum is provided, it should not be empty and the value provided should exist in the enum</value>
233233
</data>
234+
<data name="Validation_SchemaReferenceDoesNotExist" xml:space="preserve">
235+
<value>The schema reference '{0}' does not point to an existing schema.</value>
236+
</data>
234237
<data name="ArgumentNull" xml:space="preserve">
235238
<value>The argument '{0}' is null.</value>
236239
</data>

src/Microsoft.OpenApi/Validations/Rules/OpenApiDocumentRules.cs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,81 @@ public static class OpenApiDocumentRules
2323
if (item.Info == null)
2424
{
2525
context.CreateError(nameof(OpenApiDocumentFieldIsMissing),
26-
String.Format(SRResource.Validation_FieldIsRequired, "info", "document"));
26+
string.Format(SRResource.Validation_FieldIsRequired, "info", "document"));
2727
}
2828
context.Exit();
2929
});
30+
31+
/// <summary>
32+
/// All references in the OpenAPI document must be valid.
33+
/// </summary>
34+
public static ValidationRule<OpenApiDocument> OpenApiDocumentReferencesAreValid =>
35+
new(nameof(OpenApiDocumentReferencesAreValid),
36+
static (context, item) =>
37+
{
38+
const string RuleName = nameof(OpenApiDocumentReferencesAreValid);
39+
40+
var visitor = new OpenApiSchemaReferenceVisitor(RuleName, context);
41+
var walker = new OpenApiWalker(visitor);
42+
43+
walker.Walk(item);
44+
});
45+
46+
private sealed class OpenApiSchemaReferenceVisitor(
47+
string ruleName,
48+
IValidationContext context) : OpenApiVisitorBase
49+
{
50+
public override void Visit(IOpenApiReferenceHolder referenceHolder)
51+
{
52+
if (referenceHolder is OpenApiSchemaReference reference)
53+
{
54+
ValidateSchemaReference(reference);
55+
}
56+
}
57+
58+
public override void Visit(IOpenApiSchema schema)
59+
{
60+
if (schema is OpenApiSchemaReference reference)
61+
{
62+
ValidateSchemaReference(reference);
63+
}
64+
}
65+
66+
private void ValidateSchemaReference(OpenApiSchemaReference reference)
67+
{
68+
if (!reference.Reference.IsLocal)
69+
{
70+
return;
71+
}
72+
73+
try
74+
{
75+
if (reference.RecursiveTarget is null)
76+
{
77+
// The reference was not followed to a valid schema somewhere in the document
78+
context.Enter(GetSegment());
79+
context.CreateWarning(ruleName, string.Format(SRResource.Validation_SchemaReferenceDoesNotExist, reference.Reference.ReferenceV3));
80+
context.Exit();
81+
}
82+
}
83+
catch (InvalidOperationException ex)
84+
{
85+
context.Enter(GetSegment());
86+
context.CreateWarning(ruleName, ex.Message);
87+
context.Exit();
88+
}
89+
90+
string GetSegment()
91+
{
92+
// Trim off the leading "#/" as the context is already at the root of the document
93+
return
94+
#if NET8_0_OR_GREATER
95+
$"{PathString[2..]}/$ref";
96+
#else
97+
PathString.Substring(2) + "/$ref";
98+
#endif
99+
}
100+
}
101+
}
30102
}
31103
}

test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ namespace Microsoft.OpenApi
627627
public static class OpenApiDocumentRules
628628
{
629629
public static Microsoft.OpenApi.ValidationRule<Microsoft.OpenApi.OpenApiDocument> OpenApiDocumentFieldIsMissing { get; }
630+
public static Microsoft.OpenApi.ValidationRule<Microsoft.OpenApi.OpenApiDocument> OpenApiDocumentReferencesAreValid { get; }
630631
}
631632
public static class OpenApiElementExtensions
632633
{
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Net.Http;
7+
using Xunit;
8+
9+
namespace Microsoft.OpenApi.Validations.Tests;
10+
11+
public static class OpenApiDocumentValidationTests
12+
{
13+
[Fact]
14+
public static void ValidateSchemaReferencesAreValid()
15+
{
16+
// Arrange
17+
var document = new OpenApiDocument
18+
{
19+
Components = new OpenApiComponents(),
20+
Info = new OpenApiInfo
21+
{
22+
Title = "People Document",
23+
Version = "1.0.0"
24+
},
25+
Paths = [],
26+
Workspace = new()
27+
};
28+
29+
document.AddComponent("Person", new OpenApiSchema
30+
{
31+
Type = JsonSchemaType.Object,
32+
Properties = new Dictionary<string, IOpenApiSchema>()
33+
{
34+
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
35+
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
36+
}
37+
});
38+
39+
document.Paths.Add("/people", new OpenApiPathItem
40+
{
41+
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
42+
{
43+
[HttpMethod.Get] = new OpenApiOperation
44+
{
45+
Responses = new()
46+
{
47+
["200"] = new OpenApiResponse
48+
{
49+
Description = "OK",
50+
Content = new Dictionary<string, OpenApiMediaType>()
51+
{
52+
["application/json"] = new OpenApiMediaType
53+
{
54+
Schema = new OpenApiSchemaReference("Person", document),
55+
}
56+
}
57+
}
58+
}
59+
}
60+
}
61+
});
62+
63+
// Act
64+
var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet());
65+
var result = !errors.Any();
66+
67+
// Assert
68+
Assert.True(result);
69+
Assert.NotNull(errors);
70+
Assert.Empty(errors);
71+
}
72+
73+
[Fact]
74+
public static void ValidateSchemaReferencesAreInvalid()
75+
{
76+
// Arrange
77+
var document = new OpenApiDocument
78+
{
79+
Components = new OpenApiComponents(),
80+
Info = new OpenApiInfo
81+
{
82+
Title = "Pets Document",
83+
Version = "1.0.0"
84+
},
85+
Paths = [],
86+
Workspace = new()
87+
};
88+
89+
document.AddComponent("Person", new OpenApiSchema
90+
{
91+
Type = JsonSchemaType.Object,
92+
Properties = new Dictionary<string, IOpenApiSchema>()
93+
{
94+
["name"] = new OpenApiSchema { Type = JsonSchemaType.String },
95+
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
96+
}
97+
});
98+
99+
document.Paths.Add("/pets", new OpenApiPathItem
100+
{
101+
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
102+
{
103+
[HttpMethod.Get] = new OpenApiOperation
104+
{
105+
Responses = new()
106+
{
107+
["200"] = new OpenApiResponse
108+
{
109+
Description = "OK",
110+
Content = new Dictionary<string, OpenApiMediaType>()
111+
{
112+
["application/json"] = new OpenApiMediaType
113+
{
114+
Schema = new OpenApiSchemaReference("Pet", document),
115+
}
116+
}
117+
}
118+
}
119+
}
120+
}
121+
});
122+
123+
// Act
124+
var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet());
125+
var result = !errors.Any();
126+
127+
// Assert
128+
Assert.False(result);
129+
Assert.NotNull(errors);
130+
var error = Assert.Single(errors);
131+
Assert.Equal("The schema reference '#/components/schemas/Pet' does not point to an existing schema.", error.Message);
132+
Assert.Equal("#/paths/~1pets/get/responses/200/content/application~1json/schema/$ref", error.Pointer);
133+
}
134+
135+
[Fact]
136+
public static void ValidateCircularSchemaReferencesAreDetected()
137+
{
138+
// Arrange
139+
var document = new OpenApiDocument
140+
{
141+
Components = new OpenApiComponents(),
142+
Info = new OpenApiInfo
143+
{
144+
Title = "Infinite Document",
145+
Version = "1.0.0"
146+
},
147+
Paths = [],
148+
Workspace = new()
149+
};
150+
151+
document.AddComponent("Cycle", new OpenApiSchema
152+
{
153+
Type = JsonSchemaType.Object,
154+
Properties = new Dictionary<string, IOpenApiSchema>()
155+
{
156+
["self"] = new OpenApiSchemaReference("#/components/schemas/Cycle/properties/self", document)
157+
}
158+
});
159+
160+
document.Paths.Add("/cycle", new OpenApiPathItem
161+
{
162+
Operations = new Dictionary<HttpMethod, OpenApiOperation>()
163+
{
164+
[HttpMethod.Get] = new OpenApiOperation
165+
{
166+
Responses = new()
167+
{
168+
["200"] = new OpenApiResponse
169+
{
170+
Description = "OK",
171+
Content = new Dictionary<string, OpenApiMediaType>()
172+
{
173+
["application/json"] = new OpenApiMediaType
174+
{
175+
Schema = new OpenApiSchemaReference("Cycle", document)
176+
}
177+
}
178+
}
179+
}
180+
}
181+
}
182+
});
183+
184+
// Act
185+
var errors = document.Validate(ValidationRuleSet.GetDefaultRuleSet());
186+
var result = !errors.Any();
187+
188+
// Assert
189+
Assert.False(result);
190+
Assert.NotNull(errors);
191+
var error = Assert.Single(errors);
192+
Assert.Equal("Circular reference detected while resolving schema: #/components/schemas/Cycle/properties/self", error.Message);
193+
Assert.Equal("#/components/schemas/Cycle/properties/self/$ref", error.Pointer);
194+
}
195+
}

test/Microsoft.OpenApi.Tests/Validations/ValidationRuleSetTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ public void RuleSetConstructorsReturnsTheCorrectRules()
5353
Assert.Empty(ruleSet_4.Rules);
5454

5555
// Update the number if you add new default rule(s).
56-
Assert.Equal(19, ruleSet_1.Rules.Count);
57-
Assert.Equal(19, ruleSet_2.Rules.Count);
56+
Assert.Equal(20, ruleSet_1.Rules.Count);
57+
Assert.Equal(20, ruleSet_2.Rules.Count);
5858
Assert.Equal(3, ruleSet_3.Rules.Count);
5959
}
6060

0 commit comments

Comments
 (0)