diff --git a/src/OpenApi/sample/Controllers/TestController.cs b/src/OpenApi/sample/Controllers/TestController.cs index c3c011c61385..43a1e22250f9 100644 --- a/src/OpenApi/sample/Controllers/TestController.cs +++ b/src/OpenApi/sample/Controllers/TestController.cs @@ -60,10 +60,10 @@ public class HttpFoo() : HttpMethodAttribute(["FOO"]); public class RouteParamsContainer { - [FromRoute] + [FromRoute(Name = "id")] public int Id { get; set; } - [FromRoute] + [FromRoute(Name = "name")] [MinLength(5)] [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "MinLengthAttribute works without reflection on string properties.")] public string? Name { get; set; } diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs index 20c19c02a258..40757002dc2d 100644 --- a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -28,7 +28,7 @@ internal static class ApiDescriptionExtensions "HEAD" => HttpMethod.Head, "OPTIONS" => HttpMethod.Options, "TRACE" => HttpMethod.Trace, - "QUERY" => HttpMethod.Query, + "QUERY" => null, // OpenAPI as of 3.1 does not yet support HTTP QUERY _ => null, }; diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs index 1ce73bd3e57c..d72b51c22e2d 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/OpenApiDocumentIntegrationTests.cs @@ -1,9 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json.Nodes; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Reader; [UsesVerify] public sealed class OpenApiDocumentIntegrationTests(SampleAppFixture fixture) : IClassFixture @@ -36,10 +38,7 @@ public static TheoryData OpenApiDocuments() [MemberData(nameof(OpenApiDocuments))] public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version) { - var documentService = fixture.Services.GetRequiredKeyedService(documentName); - var scopedServiceProvider = fixture.Services.CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider); - var json = await document.SerializeAsJsonAsync(version); + var json = await GetOpenApiDocument(documentName, version); var baseSnapshotsDirectory = SkipOnHelixAttribute.OnHelix() ? Path.Combine(Environment.GetEnvironmentVariable("HELIX_WORKITEM_ROOT"), "Integration", "snapshots") : "snapshots"; @@ -48,4 +47,178 @@ await Verify(json) .UseDirectory(outputDirectory) .UseParameters(documentName); } + + [Theory] + [MemberData(nameof(OpenApiDocuments))] + public async Task OpenApiDocumentIsValid(string documentName, OpenApiSpecVersion version) + { + var json = await GetOpenApiDocument(documentName, version); + + var actual = OpenApiDocument.Parse(json, format: "json"); + + Assert.NotNull(actual); + Assert.NotNull(actual.Document); + Assert.NotNull(actual.Diagnostic); + Assert.NotNull(actual.Diagnostic.Errors); + Assert.Empty(actual.Diagnostic.Errors); + + var ruleSet = ValidationRuleSet.GetDefaultRuleSet(); + + var errors = actual.Document.Validate(ruleSet); + Assert.Empty(errors); + } + + [Theory] // See https://github.com/dotnet/aspnetcore/issues/63090 + [MemberData(nameof(OpenApiDocuments))] + public async Task OpenApiDocumentReferencesAreValid(string documentName, OpenApiSpecVersion version) + { + var json = await GetOpenApiDocument(documentName, version); + + var result = OpenApiDocument.Parse(json, format: "json"); + + var document = result.Document; + var documentNode = JsonNode.Parse(json); + + var actual = new List(); + + // TODO What other parts of the document should also be validated for references to be comprehensive? + // Likely also needs to be recursive to validate all references in schemas, parameters, etc. + if (document.Components is { Schemas.Count: > 0 } components) + { + foreach (var schema in components.Schemas) + { + if (schema.Value.Properties is { Count: > 0 } properties) + { + foreach (var property in properties) + { + if (property.Value is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' on property '{property.Key}' of schema '{schema.Key}' is invalid."); + } + } + } + + if (schema.Value.AllOf is { Count: > 0 } allOf) + { + foreach (var child in allOf) + { + if (child is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' for AllOf of schema '{schema.Key}' is invalid."); + } + } + } + + if (schema.Value.AnyOf is { Count: > 0 } anyOf) + { + foreach (var child in anyOf) + { + if (child is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' for AnyOf of schema '{schema.Key}' is invalid."); + } + } + } + + if (schema.Value.OneOf is { Count: > 0 } oneOf) + { + foreach (var child in oneOf) + { + if (child is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' for OneOf of schema '{schema.Key}' is invalid."); + } + } + } + + if (schema.Value.Discriminator is { Mapping.Count: > 0 } discriminator) + { + foreach (var child in discriminator.Mapping) + { + if (child.Value is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' for Discriminator '{child.Key}' of schema '{schema.Key}' is invalid."); + } + } + } + } + } + + foreach (var path in document.Paths) + { + foreach (var operation in path.Value.Operations) + { + if (operation.Value.Parameters is not { Count: > 0 } parameters) + { + continue; + } + + foreach (var parameter in parameters) + { + if (parameter.Schema is not OpenApiSchemaReference reference) + { + continue; + } + + var id = reference.Reference.ReferenceV3; + + if (!IsValidSchemaReference(id, documentNode)) + { + actual.Add($"Reference '{id}' on parameter '{parameter.Name}' of path '{path.Key}' of operation '{operation.Key}' is invalid."); + } + } + } + } + + Assert.Empty(actual); + + static bool IsValidSchemaReference(string id, JsonNode baseNode) + { + var pointer = new JsonPointer(id.Replace("#/", "/")); + return pointer.Find(baseNode) is not null; + } + } + + private async Task GetOpenApiDocument(string documentName, OpenApiSpecVersion version) + { + var documentService = fixture.Services.GetRequiredKeyedService(documentName); + var scopedServiceProvider = fixture.Services.CreateScope(); + var document = await documentService.GetOpenApiDocumentAsync(scopedServiceProvider.ServiceProvider); + return await document.SerializeAsJsonAsync(version); + } } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index 5f9f4d1dd9e6..07aaa0b07c41 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_0/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -12,7 +12,7 @@ ], "parameters": [ { - "name": "Id", + "name": "id", "in": "path", "required": true, "schema": { @@ -21,7 +21,7 @@ } }, { - "name": "Name", + "name": "name", "in": "path", "required": true, "schema": { @@ -124,35 +124,6 @@ } } } - }, - "/query": { - "query": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - } - } - } - } - } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt index f41a4ddb6a43..54815495aa78 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApi3_1/OpenApiDocumentIntegrationTests.VerifyOpenApiDocument_documentName=controllers.verified.txt @@ -12,7 +12,7 @@ ], "parameters": [ { - "name": "Id", + "name": "id", "in": "path", "required": true, "schema": { @@ -21,7 +21,7 @@ } }, { - "name": "Name", + "name": "name", "in": "path", "required": true, "schema": { @@ -124,35 +124,6 @@ } } } - }, - "/query": { - "query": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - } - } - } - } - } } }, "components": { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt index f7870bc2fe95..4c8502f366b3 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Integration/snapshots/OpenApiDocumentLocalizationTests.VerifyOpenApiDocumentIsInvariant.verified.txt @@ -1165,7 +1165,7 @@ ], "parameters": [ { - "name": "Id", + "name": "id", "in": "path", "required": true, "schema": { @@ -1174,7 +1174,7 @@ } }, { - "name": "Name", + "name": "name", "in": "path", "required": true, "schema": { @@ -1277,35 +1277,6 @@ } } } - }, - "/query": { - "query": { - "tags": [ - "Test" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/CurrentWeather" - } - } - } - } - } - } } }, "components": {