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..f14ac2285700 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,143 @@ 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); + } + + // The test below can be removed when https://github.com/microsoft/OpenAPI.NET/issues/2453 is implemented + + [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 ruleName = "OpenApiDocumentReferencesAreValid"; + var rule = new ValidationRule(ruleName, (context, item) => + { + var visitor = new OpenApiSchemaReferenceVisitor(ruleName, context, documentNode); + + var walker = new OpenApiWalker(visitor); + walker.Walk(item); + }); + + var ruleSet = new ValidationRuleSet(); + ruleSet.Add(typeof(OpenApiDocument), rule); + + var errors = document.Validate(ruleSet); + + Assert.Empty(errors); + } + + 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); + } + + private sealed class OpenApiSchemaReferenceVisitor( + string ruleName, + IValidationContext context, + JsonNode document) : OpenApiVisitorBase + { + public override void Visit(IOpenApiReferenceHolder referenceHolder) + { + if (referenceHolder is OpenApiSchemaReference { Reference.IsLocal: true } reference) + { + ValidateSchemaReference(reference); + } + } + + public override void Visit(IOpenApiSchema schema) + { + if (schema is OpenApiSchemaReference { Reference.IsLocal: true } reference) + { + ValidateSchemaReference(reference); + } + } + + private void ValidateSchemaReference(OpenApiSchemaReference reference) + { + try + { + if (reference.RecursiveTarget is not null) + { + return; + } + } + catch (InvalidOperationException ex) + { + // Thrown if a circular reference is detected + context.Enter($"{PathString[2..]}/{OpenApiSchemaKeywords.RefKeyword}"); + context.CreateError(ruleName, ex.Message); + context.Exit(); + + return; + } + + var id = reference.Reference.ReferenceV3; + + if (id is { Length: > 0 } && !IsValidSchemaReference(id, document)) + { + var isValid = false; + + // Sometimes ReferenceV3 is not a valid JSON pointer, but the $ref + // associated with it still points to a valid location in the document. + // In these cases, we need to find it manually to verify that fact before + // generating a warning that the schema reference is indeed invalid. + var parent = Find(PathString, document); + var @ref = parent[OpenApiSchemaKeywords.RefKeyword]; + var path = PathString[2..]; // Trim off the leading "#/" as the context is already at the root + + if (@ref is not null && @ref.GetValueKind() is System.Text.Json.JsonValueKind.String && + @ref.GetValue() is { Length: > 0 } refId) + { + id = refId; + path += $"/{OpenApiSchemaKeywords.RefKeyword}"; + isValid = IsValidSchemaReference(id, document); + } + + if (!isValid) + { + context.Enter(path); + context.CreateWarning(ruleName, $"The schema reference '{id}' does not point to an existing schema."); + context.Exit(); + } + } + + static bool IsValidSchemaReference(string id, JsonNode baseNode) + => Find(id, baseNode) is not null; + + static JsonNode Find(string id, JsonNode baseNode) + { + var pointer = new JsonPointer(id.Replace("#/", "/")); + return pointer.Find(baseNode); + } + } + } } 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": {