Skip to content

Commit f1b83bf

Browse files
[OpenAPI] Validate OpenAPI documents (#63092)
* [OpenAPI] Validate OpenAPI documents - Validate OpenAPI documents with Microsoft.OpenApi. - Exclude HTTP QUERY endpoints. - Fix incorrect parameter casing. Relates to #63090. * [OpenApi] Test schema reference validity Add integration test for invalid OpenAPI schema references. Relates to #63090. * Apply code review suggestions - Update comments. - Update Copilot style suggestion.
1 parent fe0ccd9 commit f1b83bf

6 files changed

+150
-100
lines changed

src/OpenApi/sample/Controllers/TestController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ public class HttpFoo() : HttpMethodAttribute(["FOO"]);
6060

6161
public class RouteParamsContainer
6262
{
63-
[FromRoute]
63+
[FromRoute(Name = "id")]
6464
public int Id { get; set; }
6565

66-
[FromRoute]
66+
[FromRoute(Name = "name")]
6767
[MinLength(5)]
6868
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "MinLengthAttribute works without reflection on string properties.")]
6969
public string? Name { get; set; }

src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ internal static class ApiDescriptionExtensions
2020
public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) =>
2121
apiDescription.HttpMethod?.ToUpperInvariant() switch
2222
{
23+
// Only add methods documented in the OpenAPI spec: https://spec.openapis.org/oas/v3.1.1.html#path-item-object
2324
"GET" => HttpMethod.Get,
2425
"POST" => HttpMethod.Post,
2526
"PUT" => HttpMethod.Put,
@@ -28,7 +29,6 @@ internal static class ApiDescriptionExtensions
2829
"HEAD" => HttpMethod.Head,
2930
"OPTIONS" => HttpMethod.Options,
3031
"TRACE" => HttpMethod.Trace,
31-
"QUERY" => HttpMethod.Query,
3232
_ => null,
3333
};
3434

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Text.Json.Nodes;
45
using Microsoft.AspNetCore.InternalTesting;
56
using Microsoft.AspNetCore.OpenApi;
67
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.OpenApi.Reader;
79

810
[UsesVerify]
911
public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture<SampleAppFixture>
@@ -36,10 +38,7 @@ public static TheoryData<string, OpenApiSpecVersion> OpenApiDocuments()
3638
[MemberData(nameof(OpenApiDocuments))]
3739
public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version)
3840
{
39-
var documentService = fixture.Services.GetRequiredKeyedService<OpenApiDocumentService>(documentName);
40-
var scopedServiceProvider = fixture.Services.CreateScope();
41-
var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider);
42-
var json = await document.SerializeAsJsonAsync(version);
41+
var json = await GetOpenApiDocument(documentName, version);
4342
var baseSnapshotsDirectory = SkipOnHelixAttribute.OnHelix()
4443
? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots")
4544
: "snapshots";
@@ -48,4 +47,142 @@ await Verify(json)
4847
.UseDirectory(outputDirectory)
4948
.UseParameters(documentName);
5049
}
50+
51+
[Theory]
52+
[MemberData(nameof(OpenApiDocuments))]
53+
public async Task OpenApiDocumentIsValid(string documentName, OpenApiSpecVersion version)
54+
{
55+
var json = await GetOpenApiDocument(documentName, version);
56+
57+
var actual = OpenApiDocument.Parse(json, format: "json");
58+
59+
Assert.NotNull(actual);
60+
Assert.NotNull(actual.Document);
61+
Assert.NotNull(actual.Diagnostic);
62+
Assert.NotNull(actual.Diagnostic.Errors);
63+
Assert.Empty(actual.Diagnostic.Errors);
64+
65+
var ruleSet = ValidationRuleSet.GetDefaultRuleSet();
66+
67+
var errors = actual.Document.Validate(ruleSet);
68+
Assert.Empty(errors);
69+
}
70+
71+
[Theory] // See https://github.com/dotnet/aspnetcore/issues/63090
72+
[MemberData(nameof(OpenApiDocuments))]
73+
public async Task OpenApiDocumentReferencesAreValid(string documentName, OpenApiSpecVersion version)
74+
{
75+
var json = await GetOpenApiDocument(documentName, version);
76+
77+
var result = OpenApiDocument.Parse(json, format: "json");
78+
79+
var document = result.Document;
80+
var documentNode = JsonNode.Parse(json);
81+
82+
var ruleName = "OpenApiDocumentReferencesAreValid";
83+
var rule = new ValidationRule<OpenApiDocument>(ruleName, (context, item) =>
84+
{
85+
var visitor = new OpenApiSchemaReferenceVisitor(ruleName, context, documentNode);
86+
87+
var walker = new OpenApiWalker(visitor);
88+
walker.Walk(item);
89+
});
90+
91+
var ruleSet = new ValidationRuleSet();
92+
ruleSet.Add(typeof(OpenApiDocument), rule);
93+
94+
var errors = document.Validate(ruleSet);
95+
96+
Assert.Empty(errors);
97+
}
98+
99+
private async Task<string> GetOpenApiDocument(string documentName, OpenApiSpecVersion version)
100+
{
101+
var documentService = fixture.Services.GetRequiredKeyedService<OpenApiDocumentService>(documentName);
102+
var scopedServiceProvider = fixture.Services.CreateScope();
103+
var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider);
104+
105+
return await document.SerializeAsJsonAsync(version);
106+
}
107+
108+
private sealed class OpenApiSchemaReferenceVisitor(
109+
string ruleName,
110+
IValidationContext context,
111+
JsonNode document) : OpenApiVisitorBase
112+
{
113+
public override void Visit(IOpenApiReferenceHolder referenceHolder)
114+
{
115+
if (referenceHolder is OpenApiSchemaReference { Reference.IsLocal: true } reference)
116+
{
117+
ValidateSchemaReference(reference);
118+
}
119+
}
120+
121+
public override void Visit(IOpenApiSchema schema)
122+
{
123+
if (schema is OpenApiSchemaReference { Reference.IsLocal: true } reference)
124+
{
125+
ValidateSchemaReference(reference);
126+
}
127+
}
128+
129+
private void ValidateSchemaReference(OpenApiSchemaReference reference)
130+
{
131+
try
132+
{
133+
if (reference.RecursiveTarget is not null)
134+
{
135+
return;
136+
}
137+
}
138+
catch (InvalidOperationException ex)
139+
{
140+
// Thrown if a circular reference is detected
141+
context.Enter($"{PathString[2..]}/{OpenApiSchemaKeywords.RefKeyword}");
142+
context.CreateError(ruleName, ex.Message);
143+
context.Exit();
144+
145+
return;
146+
}
147+
148+
var id = reference.Reference.ReferenceV3;
149+
150+
if (id is { Length: > 0 } && !IsValidSchemaReference(id, document))
151+
{
152+
var isValid = false;
153+
154+
// Sometimes ReferenceV3 is not a valid JSON pointer, but the $ref
155+
// associated with it still points to a valid location in the document.
156+
// In these cases, we need to find it manually to verify that fact before
157+
// generating a warning that the schema reference is indeed invalid.
158+
var parent = Find(PathString, document);
159+
var @ref = parent[OpenApiSchemaKeywords.RefKeyword];
160+
var path = PathString[2..]; // Trim off the leading "#/" as the context is already at the root
161+
162+
if (@ref is not null && @ref.GetValueKind() is System.Text.Json.JsonValueKind.String &&
163+
@ref.GetValue<string>() is { Length: > 0 } refId)
164+
{
165+
id = refId;
166+
path += $"/{OpenApiSchemaKeywords.RefKeyword}";
167+
isValid = IsValidSchemaReference(id, document);
168+
}
169+
170+
if (!isValid)
171+
{
172+
context.Enter(path);
173+
context.CreateWarning(ruleName, $"The schema reference '{id}' does not point to an existing schema.");
174+
context.Exit();
175+
}
176+
}
177+
178+
static bool IsValidSchemaReference(string id, JsonNode baseNode)
179+
=> Find(id, baseNode) is not null;
180+
181+
static JsonNode Find(string id, JsonNode baseNode)
182+
{
183+
var pointer = new JsonPointer(id.Replace("#/", "/"));
184+
return pointer.Find(baseNode);
185+
}
186+
}
187+
}
51188
}

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
],
1313
"parameters": [
1414
{
15-
"name": "Id",
15+
"name": "id",
1616
"in": "path",
1717
"required": true,
1818
"schema": {
@@ -21,7 +21,7 @@
2121
}
2222
},
2323
{
24-
"name": "Name",
24+
"name": "name",
2525
"in": "path",
2626
"required": true,
2727
"schema": {
@@ -124,35 +124,6 @@
124124
}
125125
}
126126
}
127-
},
128-
"/query": {
129-
"query": {
130-
"tags": [
131-
"Test"
132-
],
133-
"responses": {
134-
"200": {
135-
"description": "OK",
136-
"content": {
137-
"text/plain": {
138-
"schema": {
139-
"$ref": "#/components/schemas/CurrentWeather"
140-
}
141-
},
142-
"application/json": {
143-
"schema": {
144-
"$ref": "#/components/schemas/CurrentWeather"
145-
}
146-
},
147-
"text/json": {
148-
"schema": {
149-
"$ref": "#/components/schemas/CurrentWeather"
150-
}
151-
}
152-
}
153-
}
154-
}
155-
}
156127
}
157128
},
158129
"components": {

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
],
1313
"parameters": [
1414
{
15-
"name": "Id",
15+
"name": "id",
1616
"in": "path",
1717
"required": true,
1818
"schema": {
@@ -21,7 +21,7 @@
2121
}
2222
},
2323
{
24-
"name": "Name",
24+
"name": "name",
2525
"in": "path",
2626
"required": true,
2727
"schema": {
@@ -124,35 +124,6 @@
124124
}
125125
}
126126
}
127-
},
128-
"/query": {
129-
"query": {
130-
"tags": [
131-
"Test"
132-
],
133-
"responses": {
134-
"200": {
135-
"description": "OK",
136-
"content": {
137-
"text/plain": {
138-
"schema": {
139-
"$ref": "#/components/schemas/CurrentWeather"
140-
}
141-
},
142-
"application/json": {
143-
"schema": {
144-
"$ref": "#/components/schemas/CurrentWeather"
145-
}
146-
},
147-
"text/json": {
148-
"schema": {
149-
"$ref": "#/components/schemas/CurrentWeather"
150-
}
151-
}
152-
}
153-
}
154-
}
155-
}
156127
}
157128
},
158129
"components": {

src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,7 +1250,7 @@
12501250
],
12511251
"parameters": [
12521252
{
1253-
"name": "Id",
1253+
"name": "id",
12541254
"in": "path",
12551255
"required": true,
12561256
"schema": {
@@ -1259,7 +1259,7 @@
12591259
}
12601260
},
12611261
{
1262-
"name": "Name",
1262+
"name": "name",
12631263
"in": "path",
12641264
"required": true,
12651265
"schema": {
@@ -1362,35 +1362,6 @@
13621362
}
13631363
}
13641364
}
1365-
},
1366-
"/query": {
1367-
"query": {
1368-
"tags": [
1369-
"Test"
1370-
],
1371-
"responses": {
1372-
"200": {
1373-
"description": "OK",
1374-
"content": {
1375-
"text/plain": {
1376-
"schema": {
1377-
"$ref": "#/components/schemas/CurrentWeather"
1378-
}
1379-
},
1380-
"application/json": {
1381-
"schema": {
1382-
"$ref": "#/components/schemas/CurrentWeather"
1383-
}
1384-
},
1385-
"text/json": {
1386-
"schema": {
1387-
"$ref": "#/components/schemas/CurrentWeather"
1388-
}
1389-
}
1390-
}
1391-
}
1392-
}
1393-
}
13941365
}
13951366
},
13961367
"components": {

0 commit comments

Comments
 (0)