Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/OpenApi/sample/Controllers/TestController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SampleAppFixture>
Expand Down Expand Up @@ -36,10 +38,7 @@ public static TheoryData<string, OpenApiSpecVersion> OpenApiDocuments()
[MemberData(nameof(OpenApiDocuments))]
public async Task VerifyOpenApiDocument(string documentName, OpenApiSpecVersion version)
{
var documentService = fixture.Services.GetRequiredKeyedService<OpenApiDocumentService>(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";
Expand All @@ -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<OpenApiDocument>(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<string> GetOpenApiDocument(string documentName, OpenApiSpecVersion version)
{
var documentService = fixture.Services.GetRequiredKeyedService<OpenApiDocumentService>(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<string>() 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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"parameters": [
{
"name": "Id",
"name": "id",
"in": "path",
"required": true,
"schema": {
Expand All @@ -21,7 +21,7 @@
}
},
{
"name": "Name",
"name": "name",
"in": "path",
"required": true,
"schema": {
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"parameters": [
{
"name": "Id",
"name": "id",
"in": "path",
"required": true,
"schema": {
Expand All @@ -21,7 +21,7 @@
}
},
{
"name": "Name",
"name": "name",
"in": "path",
"required": true,
"schema": {
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,7 @@
],
"parameters": [
{
"name": "Id",
"name": "id",
"in": "path",
"required": true,
"schema": {
Expand All @@ -1259,7 +1259,7 @@
}
},
{
"name": "Name",
"name": "name",
"in": "path",
"required": true,
"schema": {
Expand Down Expand Up @@ -1362,35 +1362,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": {
Expand Down
Loading