Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 11 additions & 4 deletions src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ internal static class ApiDescriptionExtensions
/// </summary>
/// <param name="apiDescription">The ApiDescription to resolve an HttpMethod from.</param>
/// <returns>The <see cref="HttpMethod"/> associated with the given <paramref name="apiDescription"/>, if known.</returns>
Comment thread
baywet marked this conversation as resolved.
Outdated
public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription) =>
apiDescription.HttpMethod?.ToUpperInvariant() switch
public static HttpMethod? GetHttpMethod(this ApiDescription apiDescription)
{
var httpMethod = apiDescription.HttpMethod?.ToUpperInvariant();
if (string.IsNullOrWhiteSpace(httpMethod))
{
return null;
}

return httpMethod switch
{
// Only add methods documented in the OpenAPI spec: https://spec.openapis.org/oas/v3.2.0.html#path-item-object
"GET" => HttpMethod.Get,
"POST" => HttpMethod.Post,
"PUT" => HttpMethod.Put,
Expand All @@ -30,8 +36,9 @@ internal static class ApiDescriptionExtensions
"OPTIONS" => HttpMethod.Options,
"TRACE" => HttpMethod.Trace,
"QUERY" => HttpMethod.Query,
_ => null,
_ => new HttpMethod(httpMethod),
};
Comment thread
baywet marked this conversation as resolved.
Comment thread
baywet marked this conversation as resolved.
}
Comment thread
baywet marked this conversation as resolved.

/// <summary>
/// Maps the relative path included in the ApiDescription to the path
Expand Down
17 changes: 0 additions & 17 deletions src/OpenApi/src/Services/OpenApiConstants.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http;

namespace Microsoft.AspNetCore.OpenApi;

internal static class OpenApiConstants
Expand All @@ -19,21 +17,6 @@ internal static class OpenApiConstants
internal const string RefPrefix = "#";
internal const string NullableProperty = "x-is-nullable-property";
internal const string DefaultOpenApiResponseKey = "default";
// Since there's a finite set of HTTP methods that can be included in a given
// OpenApiPaths, we can pre-allocate an array of these methods and use a direct
// lookup on the OpenApiPaths dictionary to avoid allocating an enumerator
// over the KeyValuePairs in OpenApiPaths.
internal static readonly HttpMethod[] HttpMethods = [
HttpMethod.Get,
HttpMethod.Post,
HttpMethod.Put,
HttpMethod.Delete,
HttpMethod.Options,
HttpMethod.Head,
HttpMethod.Patch,
HttpMethod.Trace,
HttpMethod.Query
];
// Represents primitive types that should never be represented as
// a schema reference and always inlined.
internal static readonly List<Type> PrimitiveTypes =
Expand Down
13 changes: 6 additions & 7 deletions src/OpenApi/src/Services/OpenApiDocumentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,13 @@ internal async Task ForEachOperationAsync(
{
foreach (var pathItem in document.Paths.Values)
{
for (var i = 0; i < OpenApiConstants.HttpMethods.Length; i++)
if (pathItem.Operations is null)
{
var httpMethod = OpenApiConstants.HttpMethods[i];
if (pathItem.Operations is null || !pathItem.Operations.TryGetValue(httpMethod, out var operation))
{
continue;
}
continue;
}

foreach (var operation in pathItem.Operations.Values)
{
Comment on lines +166 to +172
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot fix this comment

if (operation.Metadata is { } annotations &&
annotations.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionId) &&
descriptionId is string descriptionIdString &&
Expand Down Expand Up @@ -294,7 +293,7 @@ private async Task<Dictionary<HttpMethod, OpenApiOperation>> GetOperationsAsync(

if (description.GetHttpMethod() is not { } method)
{
// Skip unsupported HTTP methods
// Skip descriptions with no HTTP method.
continue;
}
Comment thread
baywet marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,31 @@ public void GetHttpMethod_ReturnsHttpMethodForApiDescription(string httpMethod,
}

[Fact]
public void GetHttpMethod_ReturnsNullForUnsupportedMethod()
public void GetHttpMethod_ReturnsCustomHttpMethodForUnsupportedMethod()
{
// Arrange - Test that unsupported HTTP methods return null
var apiDescription = new ApiDescription
{
HttpMethod = "UNSUPPORTED"
HttpMethod = "foo"
};

var result = apiDescription.GetHttpMethod();

Assert.Equal(new HttpMethod("FOO"), result);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void GetHttpMethod_ReturnsNullWhenApiDescriptionHasNoHttpMethod(string httpMethod)
{
var apiDescription = new ApiDescription
{
HttpMethod = httpMethod
};

// Act
var result = apiDescription.GetHttpMethod();

// Assert
Assert.Null(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@
}
}
},
"/unsupported": {
"x-oai-additionalOperations": {
"FOO": {
"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"
}
}
}
}
}
}
}
},
"/query-with-body": {
"x-oai-additionalOperations": {
"QUERY": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@
}
}
},
"/unsupported": {
"x-oai-additionalOperations": {
"FOO": {
"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"
}
}
}
}
}
}
}
},
"/query-with-body": {
"x-oai-additionalOperations": {
"QUERY": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,37 @@
}
}
},
"/unsupported": {
"additionalOperations": {
"FOO": {
"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"
}
}
}
}
}
}
}
},
"/query-with-body": {
"query": {
"tags": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1654,6 +1654,37 @@
}
}
},
"/unsupported": {
"x-oai-additionalOperations": {
"FOO": {
"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"
}
}
}
}
}
}
}
},
"/query-with-body": {
"x-oai-additionalOperations": {
"QUERY": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;

public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase
{
Expand Down Expand Up @@ -71,7 +73,46 @@ await VerifyOpenApiDocument(builder, document =>
});
}

[Fact]
public async Task CustomMethod_AppearsInDocumentForMvcAction()
{
var action = CreateActionDescriptor(nameof(ActionWithCustomMethod));

await VerifyOpenApiDocument(action, document =>
{
var path = document.Paths["/api/custom"];
Assert.True(path.Operations.ContainsKey(new HttpMethod("FOO")));
Assert.DoesNotContain(HttpMethod.Post, path.Operations.Keys);
});
}

[Fact]
public async Task CustomMethod_IsVisitedByForEachOperationAsync()
{
var builder = CreateBuilder();
var action = CreateActionDescriptor(nameof(ActionWithCustomMethod));
var documentService = CreateDocumentService(builder, action);
var scopedService = builder.ServiceProvider.CreateScope();
var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider);
Comment thread
baywet marked this conversation as resolved.
Outdated

await documentService.ForEachOperationAsync(document, (operation, context, cancellationToken) =>
{
operation.Description = context.Description.HttpMethod;
return Task.CompletedTask;
}, CancellationToken.None);

var operation = document.Paths["/api/custom"].Operations[new HttpMethod("FOO")];
Assert.Equal("FOO", operation.Description);
}

#nullable enable
private record TodoItem(int Id, string Title, bool Completed);
#nullable restore

[Route("/api/custom")]
[HttpFoo]
private ActionResult<TodoItem> ActionWithCustomMethod()
=> new OkObjectResult(new TodoItem(100, "Title", true));

private sealed class HttpFooAttribute() : HttpMethodAttribute(["FOO"]);
}
Loading